@danhachuel/thunderbolt 0.2.53 → 0.2.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANUAL-INSTALACAO.md +7 -5
- package/README.md +14 -6
- package/app/main.py +195 -2
- package/hermes_ui/cuts.py +454 -0
- 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.54
|
|
6
6
|
> **Pacote npm:** `@danhachuel/thunderbolt`
|
|
7
7
|
> **Porta padrão da UI:** `localhost:3030`
|
|
8
8
|
> **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
|
|
@@ -100,13 +100,13 @@ Execute:
|
|
|
100
100
|
Windows PowerShell ou MobaXterm:
|
|
101
101
|
|
|
102
102
|
```powershell
|
|
103
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
103
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.54 install
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
Linux/macOS:
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
|
-
npx --yes @danhachuel/thunderbolt@0.2.
|
|
109
|
+
npx --yes @danhachuel/thunderbolt@0.2.54 install
|
|
110
110
|
```
|
|
111
111
|
|
|
112
112
|
A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
|
|
@@ -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.54 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
432
432
|
|
|
433
433
|
### Niche Finder Apify
|
|
434
434
|
|
|
@@ -440,7 +440,7 @@ O menu **Models AI** foi adicionado abaixo de **Edição**. As abas **Personagen
|
|
|
440
440
|
|
|
441
441
|
## Edição: Limpador de Metadados, Cortes e Editor Python
|
|
442
442
|
|
|
443
|
-
A aba **Limpador de Metadados** continua funcional e foi movida para **Edição**. A aba **Cortes**
|
|
443
|
+
A aba **Limpador de Metadados** continua funcional e foi movida para **Edição**. A aba **Cortes** é um Clip Generator local inspirado no [OpenShorts](https://github.com/mutonby/openshorts): permite upload de vídeo, URL directa, vídeos gerados ou pasta local, formatos 9:16/1:1/16:9, opções avançadas, modo manual ou automático por segmentos locais e confirmação de direitos antes da geração. Os clips são processados com FFmpeg, guardados em `storage/cuts/runs/<id>/`, apresentados com preview e downloads individual/ZIP, acompanhados de manifesto JSON e histórico. A aba **Editor Python** é funcional e permite escolher vídeos gerados, indicar uma pasta local ou fazer upload manual; as operações são manuais e criam cópias sem alterar os originais.
|
|
444
444
|
|
|
445
445
|
## Editor Python baseado no PYEdit
|
|
446
446
|
|
|
@@ -448,6 +448,8 @@ O **Editor Python** adapta o recorte do [PYEdit](https://github.com/Congren/PYEd
|
|
|
448
448
|
|
|
449
449
|
Cada operação usa FFmpeg local, preserva o original, cria a saída em `storage/python_editor/outputs/`, guarda o histórico em `storage/state/python_editor_edits.json` e permite descarregar o resultado e um manifesto JSON. Na subaba **Código Python**, pode criar e guardar scripts em `storage/python_editor/scripts/` ou carregar scripts existentes. Por segurança, a UI não executa código Python e não executa scripts automaticamente.
|
|
450
450
|
|
|
451
|
+
No modo automático de **Cortes**, a aplicação não inventa uma selecção viral: sem transcrição ou provider de IA configurado, são gerados segmentos locais distribuídos pelo vídeo; o modo manual permite definir início e fim exactos. Abrir a página não inicia download, análise ou processamento.
|
|
452
|
+
|
|
451
453
|
## Criação de Vídeos — IA editorial e pacotes criativos
|
|
452
454
|
|
|
453
455
|
O formulário permite escrever manualmente o **Tópico ou briefing** ou clicar em **Gerar tópico/briefing com IA**. O botão usa o provider LLM, Base URL, API key e modelo guardados em **Configurações Técnicas > API Keys**, incorpora a descrição, o nicho e o Blueprint do canal e coloca o resultado no campo para revisão. Abrir a página não inicia chamadas externas.
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ A primeira versão implementa a camada UI independente com:
|
|
|
19
19
|
| Automação | Menu expansível com a subaba **Automação Youtube**, onde ficam os vídeos e canais, selectores editáveis de Blueprint/voz padrão, Automação ON, horário diário HH:MM e worker local baseado no relógio do computador |
|
|
20
20
|
| Models AI | Menu expansível com Personagens e Redes Sociais reservados para desenvolvimento futuro |
|
|
21
21
|
| Niche Finder | Menu expansível com duas alternativas independentes: Niche Finder Kaggle e Niche Finder Apify, com parâmetros, execução e resultados separados |
|
|
22
|
-
| Edição | Menu expansível abaixo de Automação com Limpador de Metadados
|
|
22
|
+
| Edição | Menu expansível abaixo de Automação com Limpador de Metadados, Clip Generator local em Cortes e Editor Python inspirado no PYEdit para vídeos e scripts locais |
|
|
23
23
|
| Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo via sessão Frontend API com cookies/sessionInfo por conta e DELEGATED_SESSION_ID por canal, TikTok, Instagram e Facebook Pages no front end |
|
|
24
24
|
| MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
|
|
25
25
|
| Configurações Técnicas | Provedores LLM, OpenAI/NVIDIA NIM com API key, Base URL e selector de modelos, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, contas Google/YouTube para canais em lote com e-mail, Client ID, Client Secret e sessionInfo por conta, cartões expansíveis por nome/e-mail, documento credentials.json criado automaticamente e eliminação individual, cookies SID/SSID/HSID/APISID/SAPISID no documento JSON, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, TikTok Client ID/Secret e Upload-Post |
|
|
@@ -38,7 +38,7 @@ 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.54
|
|
42
42
|
|
|
43
43
|
A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Automação**, **Edição**, **Models AI** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas** e **Upload**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes** e **Editor Python**, nessa ordem. **Models AI** é expansível e contém **Personagens** e **Redes Sociais**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP** e **Configurações Técnicas**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
|
|
44
44
|
|
|
@@ -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.54 install
|
|
165
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.54 doctor
|
|
166
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.54
|
|
167
167
|
```
|
|
168
168
|
|
|
169
169
|
Como alternativa, pode permitir scripts para o seu utilizador:
|
|
@@ -187,7 +187,7 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
|
|
|
187
187
|
|
|
188
188
|
A interface apresenta dentro da aba os parâmetros da busca: número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, categoria de engagement, intervalo de datas e tags. A página permanece sem resultados até o primeiro clique em **Analisar Nichos**; depois, se os parâmetros forem alterados, mostra os resultados anteriores e pede novo clique para aplicar os filtros actuais. O núcleo aplica normalização, filtros, `log1p`, `StandardScaler`, K-Means e FP-Growth. Os resultados aparecem em DataFrames para clusters, itemsets frequentes, regras de associação e dados analisados, acompanhados por uma visualização Plotly nativa e pesquisa de palavras nos clusters.
|
|
189
189
|
|
|
190
|
-
**Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em Configurações Técnicas. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
190
|
+
**Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em Configurações Técnicas. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.54 install`; o instalador detecta e reutiliza componentes já válidos.
|
|
191
191
|
|
|
192
192
|
## Editor Python baseado no PYEdit
|
|
193
193
|
|
|
@@ -240,6 +240,14 @@ A aba **Limpador de Metadados** foi adaptada do workflow `YTBMetadataGenerator.j
|
|
|
240
240
|
|
|
241
241
|
A descrição segue a composição do workflow de referência: preview, secção de links e capítulos/timestamps. O resultado inclui um manifesto JSON para utilizar os metadados num fluxo posterior de upload. A versão local não usa o trigger RSS, o Apify ou a actualização automática de um vídeo YouTube; em vez disso, o utilizador fornece directamente o vídeo terceiro e controla a edição antes de publicar.
|
|
242
242
|
|
|
243
|
+
## Cortes — Clip Generator local
|
|
244
|
+
|
|
245
|
+
A aba **Cortes** é um Clip Generator local inspirado no fluxo do [OpenShorts](https://github.com/mutonby/openshorts). Permite fazer upload de um vídeo, descarregar uma URL directa de vídeo, seleccionar um vídeo gerado pela pipeline ou escolher uma pasta local. O original é preservado em `storage/cuts/inputs/`.
|
|
246
|
+
|
|
247
|
+
O formulário apresenta os formatos **9:16 — Shorts/Reels/TikTok**, **1:1 — Feed posts** e **16:9 — YouTube/landscape**, além de opções avançadas para modo automático por segmentos locais ou corte manual, número de clips, durações mínima/máxima e intervalo de início/fim. A execução só começa depois da confirmação explícita de direitos e do clique em **Gerar Clips**.
|
|
248
|
+
|
|
249
|
+
Os clips são gerados localmente com FFmpeg, redimensionados para o formato escolhido sem ampliar desnecessariamente a fonte e guardados em `storage/cuts/runs/<id>/`. A página apresenta estado de análise, preview dos resultados, download individual, download ZIP, manifesto JSON e histórico em `storage/state/cuts_runs.json`. A selecção automática de momentos virais por IA permanece um ponto de extensão: sem transcrição/provider configurado, o modo manual continua disponível e não são inventados resultados.
|
|
250
|
+
|
|
243
251
|
## Canais YouTube
|
|
244
252
|
|
|
245
253
|
A aba **Canais Youtube** está dividida em dois fluxos independentes. Em **Importar do YouTube**, o método padrão **Página pública — sem API Key** consulta a página pública do canal e preenche nome, ID, handle, URL, descrição e thumbnail quando esses dados estão disponíveis. A opção **YouTube Data API — API Key opcional** pode ser escolhida para métricas oficiais quando uma **YouTube Data API Key própria** estiver configurada em **Configurações** ou em `YOUTUBE_API_KEY`; essa chave é distinta do OAuth Client ID e Client Secret.
|
package/app/main.py
CHANGED
|
@@ -30,6 +30,7 @@ from app.modules.niche_finder.summarizer import summarize_items
|
|
|
30
30
|
from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
|
|
31
31
|
from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
|
|
32
32
|
from hermes_ui.python_editor import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, PythonEditorError, change_speed, editor_manifest, extract_audio, list_edit_records as list_python_editor_records, list_generated_videos, list_scripts, list_video_files, read_script, remove_audio, replace_audio, resize_video, save_edit_record as save_python_editor_record, save_script, store_uploaded_asset, trim_video
|
|
33
|
+
from hermes_ui.cuts import CutsError, download_direct_video_url, generate_clips, list_generated_videos as list_cut_generated_videos, list_runs as list_cut_runs, list_video_files as list_cut_video_files, manifest_bytes as cut_manifest_bytes, store_uploaded_video, zip_run as zip_cut_run
|
|
33
34
|
from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
|
|
34
35
|
from hermes_ui.mcp_server import server_status, start_server, stop_server
|
|
35
36
|
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
|
|
@@ -150,7 +151,16 @@ st.markdown("""
|
|
|
150
151
|
.content-label { color:#8ba6bb; font-size:.8rem; text-transform:uppercase; letter-spacing:.07em; }
|
|
151
152
|
.content-value { color:#f4f8fb; font-size:1.8rem; font-weight:700; margin-top:.3rem; }
|
|
152
153
|
.stage { border-left:3px solid #35a7ff; padding:.65rem .8rem; margin:.4rem 0; background:#101d2a; border-radius:8px; }
|
|
153
|
-
.small-muted { color:#8ba6bb; font-size:.85rem; }
|
|
154
|
+
.small-muted { color:#8ba6bb; font-size:.85rem; }
|
|
155
|
+
.tb-cuts-hero { max-width:860px; margin:0 auto 1.1rem; padding:1.6rem 1.4rem 1.35rem; text-align:center; border:1px solid #263d50; border-radius:18px; background:radial-gradient(circle at 50% 0, rgba(164,126,55,.16), transparent 58%), linear-gradient(145deg, rgba(17,27,37,.98), rgba(9,15,22,.98)); box-shadow:0 18px 48px rgba(0,0,0,.18); }
|
|
156
|
+
.tb-cuts-hero .tb-cuts-kicker { color:#c59b55; font-size:.68rem; letter-spacing:.18em; text-transform:uppercase; font-weight:700; }
|
|
157
|
+
.tb-cuts-hero h2 { color:#f5f0e8; font-family:Georgia,serif; font-size:2rem; font-weight:500; margin:.42rem 0 .25rem; text-transform:lowercase; }
|
|
158
|
+
.tb-cuts-hero p { color:#9cafbf; margin:0 auto; max-width:620px; font-size:.9rem; }
|
|
159
|
+
[data-testid="stRadio"] [role="radiogroup"] { gap:.6rem; }
|
|
160
|
+
[data-testid="stRadio"] label { border:1px solid #2a4052; border-radius:12px; padding:.65rem .8rem; background:#101b25; min-height:4.3rem; }
|
|
161
|
+
[data-testid="stRadio"] label:has(input:checked) { border-color:#c59b55; background:linear-gradient(145deg, rgba(96,71,33,.42), rgba(17,27,37,.95)); }
|
|
162
|
+
[data-testid="stStatusWidget"] { border-color:#2a4052 !important; background:#101b25 !important; }
|
|
163
|
+
|
|
154
164
|
/* Cores dos chips por identidade da plataforma, sem depender da ordem de selecção. */
|
|
155
165
|
[data-testid="stMultiSelectTagsContainer"] span[data-tag] { color:#ffffff !important; border:0 !important; font-weight:700 !important; }
|
|
156
166
|
[data-testid="stMultiSelectTagsContainer"] span[data-tag] span[title],
|
|
@@ -1485,6 +1495,189 @@ def render_edit_placeholder(page_title: str, description: str):
|
|
|
1485
1495
|
st.info("Esta aba está reservada para desenvolvimento futuro e ainda não executa nenhuma operação.")
|
|
1486
1496
|
|
|
1487
1497
|
|
|
1498
|
+
def render_cuts():
|
|
1499
|
+
st.title("Cortes")
|
|
1500
|
+
st.caption("Crie clips verticais, quadrados ou horizontais a partir de vídeos longos, com um fluxo local inspirado no Clip Generator do OpenShorts.")
|
|
1501
|
+
|
|
1502
|
+
st.markdown(
|
|
1503
|
+
"<div class='tb-cuts-hero'><div class='tb-cuts-kicker'>01 · CLIP GENERATOR</div><h2>Create Viral Shorts</h2><p>Escolha um vídeo longo, defina o formato e gere clips locais sem sobrescrever a fonte.</p></div>",
|
|
1504
|
+
unsafe_allow_html=True,
|
|
1505
|
+
)
|
|
1506
|
+
|
|
1507
|
+
source_path = None
|
|
1508
|
+
source_tab, url_tab, generated_tab, folder_tab = st.tabs(["Upload ficheiro", "URL de vídeo", "Vídeos gerados", "Pasta local"])
|
|
1509
|
+
with source_tab:
|
|
1510
|
+
uploaded_video = st.file_uploader(
|
|
1511
|
+
"Clique para carregar ou arraste um vídeo",
|
|
1512
|
+
type=sorted(extension.lstrip(".") for extension in VIDEO_EXTENSIONS),
|
|
1513
|
+
key="cuts_video_upload",
|
|
1514
|
+
help="MP4, MOV, MKV, WEBM e formatos suportados pelo FFmpeg. O original fica preservado.",
|
|
1515
|
+
)
|
|
1516
|
+
if uploaded_video is not None:
|
|
1517
|
+
try:
|
|
1518
|
+
source_path = store_uploaded_video(uploaded_video.name, uploaded_video.getvalue())
|
|
1519
|
+
st.session_state["cuts_source_path"] = str(source_path)
|
|
1520
|
+
st.session_state["cuts_source_label"] = f"Upload · {uploaded_video.name}"
|
|
1521
|
+
except CutsError as exc:
|
|
1522
|
+
st.error(str(exc))
|
|
1523
|
+
with url_tab:
|
|
1524
|
+
url_value = st.text_input("URL directa do vídeo", placeholder="https://exemplo.com/video.mp4", key="cuts_video_url")
|
|
1525
|
+
st.caption("A URL deve apontar directamente para um ficheiro de vídeo HTTP/HTTPS. O download só ocorre depois de clicar no botão.")
|
|
1526
|
+
if st.button("Descarregar vídeo", key="cuts_download_url", use_container_width=True):
|
|
1527
|
+
try:
|
|
1528
|
+
source_path = download_direct_video_url(url_value)
|
|
1529
|
+
st.session_state["cuts_source_path"] = str(source_path)
|
|
1530
|
+
st.session_state["cuts_source_label"] = f"URL · {url_value}"
|
|
1531
|
+
st.success("Vídeo descarregado e pronto para análise.")
|
|
1532
|
+
except CutsError as exc:
|
|
1533
|
+
st.error(str(exc))
|
|
1534
|
+
with generated_tab:
|
|
1535
|
+
generated_paths = list_cut_generated_videos(read_json("tasks.json", []))
|
|
1536
|
+
if not generated_paths:
|
|
1537
|
+
st.info("Ainda não existem vídeos gerados com caminho registado na pipeline.")
|
|
1538
|
+
else:
|
|
1539
|
+
generated_labels = [f"{path.name} — {path}" for path in generated_paths]
|
|
1540
|
+
selected_generated = st.selectbox("Vídeo gerado", range(len(generated_paths)), format_func=lambda index: generated_labels[index], key="cuts_generated_index")
|
|
1541
|
+
if st.button("Usar vídeo seleccionado", key="cuts_use_generated", use_container_width=True):
|
|
1542
|
+
source_path = generated_paths[selected_generated]
|
|
1543
|
+
st.session_state["cuts_source_path"] = str(source_path)
|
|
1544
|
+
st.session_state["cuts_source_label"] = f"Pipeline · {source_path.name}"
|
|
1545
|
+
with folder_tab:
|
|
1546
|
+
folder_value = st.text_input("Pasta de vídeos", value=str(STORAGE / "videos"), key="cuts_video_folder")
|
|
1547
|
+
folder_paths = list_cut_video_files(folder_value)
|
|
1548
|
+
if not folder_paths:
|
|
1549
|
+
st.info("Não foram encontrados vídeos nessa pasta.")
|
|
1550
|
+
else:
|
|
1551
|
+
folder_labels = [f"{path.name} — {path}" for path in folder_paths]
|
|
1552
|
+
selected_folder = st.selectbox("Vídeo da pasta", range(len(folder_paths)), format_func=lambda index: folder_labels[index], key="cuts_folder_index")
|
|
1553
|
+
if st.button("Usar vídeo da pasta", key="cuts_use_folder", use_container_width=True):
|
|
1554
|
+
source_path = folder_paths[selected_folder]
|
|
1555
|
+
st.session_state["cuts_source_path"] = str(source_path)
|
|
1556
|
+
st.session_state["cuts_source_label"] = f"Pasta local · {source_path.name}"
|
|
1557
|
+
|
|
1558
|
+
stored_source = st.session_state.get("cuts_source_path", "")
|
|
1559
|
+
if not source_path and stored_source:
|
|
1560
|
+
candidate = Path(stored_source)
|
|
1561
|
+
if candidate.is_file():
|
|
1562
|
+
source_path = candidate
|
|
1563
|
+
if source_path and source_path.is_file():
|
|
1564
|
+
st.markdown(f"**Fonte seleccionada:** `{st.session_state.get('cuts_source_label', source_path.name)}`")
|
|
1565
|
+
source_cols = st.columns([1.4, 1])
|
|
1566
|
+
with source_cols[0]:
|
|
1567
|
+
st.video(str(source_path))
|
|
1568
|
+
with source_cols[1]:
|
|
1569
|
+
st.caption(f"{source_path.name}")
|
|
1570
|
+
st.caption(f"{source_path.stat().st_size / (1024 * 1024):.2f} MB")
|
|
1571
|
+
st.caption("A fonte original não é alterada.")
|
|
1572
|
+
else:
|
|
1573
|
+
st.info("Escolha um ficheiro, descarregue uma URL, seleccione um vídeo gerado ou indique uma pasta local.")
|
|
1574
|
+
|
|
1575
|
+
with st.container(border=True):
|
|
1576
|
+
st.markdown("**Output format**")
|
|
1577
|
+
format_options = ["9:16", "1:1", "16:9"]
|
|
1578
|
+
output_format = st.radio(
|
|
1579
|
+
"Formato de saída",
|
|
1580
|
+
format_options,
|
|
1581
|
+
format_func=lambda value: {"9:16": "9:16\\nShorts · Reels · TikTok", "1:1": "1:1\\nFeed posts", "16:9": "16:9\\nYouTube · landscape"}[value],
|
|
1582
|
+
horizontal=True,
|
|
1583
|
+
key="cuts_output_format",
|
|
1584
|
+
label_visibility="collapsed",
|
|
1585
|
+
)
|
|
1586
|
+
with st.expander("advanced options", expanded=False):
|
|
1587
|
+
strategy_label = st.selectbox("Estratégia", ["Automático · segmentos locais", "Manual · um intervalo"], key="cuts_strategy")
|
|
1588
|
+
strategy = "manual" if strategy_label.startswith("Manual") else "automatic"
|
|
1589
|
+
options_cols = st.columns(3)
|
|
1590
|
+
with options_cols[0]:
|
|
1591
|
+
max_clips = st.number_input("Número máximo de clips", min_value=1, max_value=20, value=3, step=1, key="cuts_max_clips")
|
|
1592
|
+
with options_cols[1]:
|
|
1593
|
+
min_duration = st.number_input("Duração mínima (s)", min_value=1.0, max_value=600.0, value=15.0, step=1.0, key="cuts_min_duration")
|
|
1594
|
+
with options_cols[2]:
|
|
1595
|
+
max_duration = st.number_input("Duração máxima (s)", min_value=1.0, max_value=600.0, value=60.0, step=1.0, key="cuts_max_duration")
|
|
1596
|
+
manual_start = manual_end = 0.0
|
|
1597
|
+
if strategy == "manual":
|
|
1598
|
+
manual_cols = st.columns(2)
|
|
1599
|
+
with manual_cols[0]:
|
|
1600
|
+
manual_start = st.number_input("Início do intervalo (s)", min_value=0.0, value=0.0, step=0.5, key="cuts_manual_start")
|
|
1601
|
+
with manual_cols[1]:
|
|
1602
|
+
manual_end = st.number_input("Fim do intervalo (s)", min_value=0.5, value=30.0, step=0.5, key="cuts_manual_end")
|
|
1603
|
+
st.caption("O modo automático cria segmentos locais distribuídos pelo vídeo. A selecção viral por IA fica disponível como extensão quando houver transcrição/provider configurado.")
|
|
1604
|
+
|
|
1605
|
+
rights_confirmed = st.checkbox(
|
|
1606
|
+
"Confirmo que possuo os direitos ou autorização para processar este conteúdo.",
|
|
1607
|
+
key="cuts_rights_confirmed",
|
|
1608
|
+
)
|
|
1609
|
+
generate_button = st.button(
|
|
1610
|
+
"Gerar Clips",
|
|
1611
|
+
type="primary",
|
|
1612
|
+
use_container_width=True,
|
|
1613
|
+
disabled=not (source_path and source_path.is_file() and rights_confirmed),
|
|
1614
|
+
key="cuts_generate_button",
|
|
1615
|
+
)
|
|
1616
|
+
|
|
1617
|
+
if generate_button and source_path and source_path.is_file():
|
|
1618
|
+
settings = read_json("settings.json", {})
|
|
1619
|
+
with st.status("A analisar e a gerar clips…", expanded=True) as status:
|
|
1620
|
+
st.write("A validar fonte e parâmetros…")
|
|
1621
|
+
try:
|
|
1622
|
+
record = generate_clips(
|
|
1623
|
+
source_path,
|
|
1624
|
+
output_format=output_format,
|
|
1625
|
+
strategy=strategy,
|
|
1626
|
+
max_clips=int(max_clips),
|
|
1627
|
+
min_duration=float(min_duration),
|
|
1628
|
+
max_duration=float(max_duration),
|
|
1629
|
+
rights_confirmed=rights_confirmed,
|
|
1630
|
+
ffmpeg_path=settings.get("ffmpeg_path", ""),
|
|
1631
|
+
manual_start=float(manual_start),
|
|
1632
|
+
manual_end=float(manual_end) if strategy == "manual" else None,
|
|
1633
|
+
)
|
|
1634
|
+
st.session_state["cuts_last_run"] = record
|
|
1635
|
+
status.update(label="Clips gerados", state="complete", expanded=False)
|
|
1636
|
+
except CutsError as exc:
|
|
1637
|
+
status.update(label="A geração falhou", state="error", expanded=True)
|
|
1638
|
+
st.error(str(exc))
|
|
1639
|
+
|
|
1640
|
+
last_run = st.session_state.get("cuts_last_run")
|
|
1641
|
+
if last_run:
|
|
1642
|
+
st.divider()
|
|
1643
|
+
status_label = {"complete": "CONCLUÍDO", "processing": "A PROCESSAR", "error": "ERRO"}.get(last_run.get("status"), str(last_run.get("status", "—")).upper())
|
|
1644
|
+
st.markdown(f"### Live Analysis · `{status_label}`")
|
|
1645
|
+
if last_run.get("status") == "complete":
|
|
1646
|
+
clips = [clip for clip in last_run.get("clips", []) if Path(str(clip.get("path", ""))).is_file()]
|
|
1647
|
+
result_cols = st.columns([3, 1, 1])
|
|
1648
|
+
with result_cols[0]:
|
|
1649
|
+
st.subheader("Generated Shorts")
|
|
1650
|
+
with result_cols[1]:
|
|
1651
|
+
st.metric("Clips", len(clips))
|
|
1652
|
+
with result_cols[2]:
|
|
1653
|
+
st.metric("Formato", last_run.get("output_format", "—"))
|
|
1654
|
+
for index in range(0, len(clips), 3):
|
|
1655
|
+
clip_cols = st.columns(3)
|
|
1656
|
+
for col, clip in zip(clip_cols, clips[index:index + 3]):
|
|
1657
|
+
with col:
|
|
1658
|
+
with st.container(border=True):
|
|
1659
|
+
st.caption(f"Clip {clip.get('index', '—')} · {float(clip.get('duration', 0)):.1f}s")
|
|
1660
|
+
st.video(str(clip["path"]))
|
|
1661
|
+
st.download_button("Descarregar clip", data=Path(clip["path"]).read_bytes(), file_name=clip["name"], mime="video/mp4", key=f"cuts_download_{last_run['id']}_{clip['index']}", use_container_width=True)
|
|
1662
|
+
try:
|
|
1663
|
+
_, archive_bytes = zip_cut_run(last_run)
|
|
1664
|
+
st.download_button("Descarregar todos os clips (ZIP)", data=archive_bytes, file_name=f"{last_run['id']}.zip", mime="application/zip", key=f"cuts_download_zip_{last_run['id']}", use_container_width=True)
|
|
1665
|
+
except CutsError as exc:
|
|
1666
|
+
st.warning(str(exc))
|
|
1667
|
+
st.download_button("Descarregar manifesto JSON", data=cut_manifest_bytes(last_run), file_name=f"{last_run['id']}.json", mime="application/json", key=f"cuts_download_manifest_{last_run['id']}", use_container_width=True)
|
|
1668
|
+
elif last_run.get("error"):
|
|
1669
|
+
st.error(last_run["error"])
|
|
1670
|
+
|
|
1671
|
+
runs = list_cut_runs()
|
|
1672
|
+
if runs:
|
|
1673
|
+
with st.expander("Histórico do Clip Generator"):
|
|
1674
|
+
st.dataframe(
|
|
1675
|
+
[{"Data": run.get("created_at", "—"), "Fonte": run.get("source_name", "—"), "Formato": run.get("output_format", "—"), "Clips": len(run.get("clips", [])), "Estado": run.get("status", "—")} for run in runs[:20]],
|
|
1676
|
+
use_container_width=True,
|
|
1677
|
+
hide_index=True,
|
|
1678
|
+
)
|
|
1679
|
+
|
|
1680
|
+
|
|
1488
1681
|
def render_python_editor():
|
|
1489
1682
|
st.title("Editor Python")
|
|
1490
1683
|
st.caption("Editor local inspirado no PYEdit para scripts Python e edição manual de vídeos do Thunderbolt.")
|
|
@@ -2686,7 +2879,7 @@ def main():
|
|
|
2686
2879
|
"Niche Finder Apify": render_niche_finder_apify,
|
|
2687
2880
|
"Edição": lambda: render_edit_placeholder("Edição", "Seleccione uma das abas de edição no menu expansível."),
|
|
2688
2881
|
"Limpador de Metadados": render_metadata_cleaner,
|
|
2689
|
-
"Cortes":
|
|
2882
|
+
"Cortes": render_cuts,
|
|
2690
2883
|
"Editor Python": render_python_editor,
|
|
2691
2884
|
"Models AI": lambda: render_edit_placeholder("Models AI", "Seleccione uma das abas Models AI no menu expansível."),
|
|
2692
2885
|
"Personagens": lambda: render_edit_placeholder("Personagens", "Área reservada para a futura funcionalidade de personagens."),
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import unicodedata
|
|
9
|
+
import uuid
|
|
10
|
+
import zipfile
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Iterable
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from . import storage
|
|
19
|
+
from .metadata_cleaner import VIDEO_EXTENSIONS, _resolve_ffmpeg
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CutsError(RuntimeError):
|
|
23
|
+
"""Raised when a local clip-generation operation cannot be completed."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
OUTPUT_FORMATS: dict[str, dict[str, Any]] = {
|
|
27
|
+
"9:16": {"width": 1080, "height": 1920, "label": "9:16 · Shorts / Reels / TikTok"},
|
|
28
|
+
"1:1": {"width": 1080, "height": 1080, "label": "1:1 · Feed posts"},
|
|
29
|
+
"16:9": {"width": 1920, "height": 1080, "label": "16:9 · YouTube / landscape"},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
VIDEO_SUFFIXES = {f".{extension.lower().lstrip('.')}" for extension in VIDEO_EXTENSIONS}
|
|
33
|
+
MAX_SOURCE_BYTES = 500 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _now() -> str:
|
|
37
|
+
return datetime.now(timezone.utc).isoformat()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _root() -> Path:
|
|
41
|
+
path = storage.STORAGE / "cuts"
|
|
42
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _inputs() -> Path:
|
|
47
|
+
path = _root() / "inputs"
|
|
48
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
return path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _runs_root() -> Path:
|
|
53
|
+
path = _root() / "runs"
|
|
54
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
return path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _safe_filename(name: str, fallback: str = "video") -> str:
|
|
59
|
+
normalized = unicodedata.normalize("NFKD", Path(name).name)
|
|
60
|
+
ascii_name = normalized.encode("ascii", "ignore").decode("ascii")
|
|
61
|
+
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", ascii_name).strip(".-")
|
|
62
|
+
return safe or fallback
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _validate_video(source: Path) -> Path:
|
|
66
|
+
candidate = Path(source).expanduser()
|
|
67
|
+
if not candidate.is_file():
|
|
68
|
+
raise CutsError("O vídeo seleccionado não existe ou não é um ficheiro.")
|
|
69
|
+
if candidate.suffix.lower() not in VIDEO_SUFFIXES:
|
|
70
|
+
raise CutsError("O vídeo seleccionado não tem uma extensão suportada.")
|
|
71
|
+
return candidate.resolve()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _safe_float(value: Any, fallback: float = 0.0) -> float:
|
|
75
|
+
try:
|
|
76
|
+
return float(value)
|
|
77
|
+
except (TypeError, ValueError):
|
|
78
|
+
return fallback
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def normalize_output_format(output_format: str) -> str:
|
|
82
|
+
key = str(output_format or "").strip()
|
|
83
|
+
if key not in OUTPUT_FORMATS:
|
|
84
|
+
raise CutsError("Seleccione um formato de saída válido: 9:16, 1:1 ou 16:9.")
|
|
85
|
+
return key
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def store_uploaded_video(filename: str, content: bytes) -> Path:
|
|
89
|
+
if not content:
|
|
90
|
+
raise CutsError("O ficheiro de vídeo está vazio.")
|
|
91
|
+
suffix = Path(filename).suffix.lower()
|
|
92
|
+
if suffix not in VIDEO_SUFFIXES:
|
|
93
|
+
raise CutsError("O ficheiro enviado não tem uma extensão de vídeo suportada.")
|
|
94
|
+
digest = hashlib.sha256(content).hexdigest()[:16]
|
|
95
|
+
existing = next(iter(sorted(_inputs().glob(f"{digest}-*"))), None)
|
|
96
|
+
if existing and existing.is_file():
|
|
97
|
+
return existing.resolve()
|
|
98
|
+
target = _inputs() / f"{digest}-{_safe_filename(filename, 'video')}"
|
|
99
|
+
if not target.exists():
|
|
100
|
+
target.write_bytes(content)
|
|
101
|
+
return target.resolve()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def list_video_files(folder: str | Path) -> list[Path]:
|
|
105
|
+
candidate = Path(folder).expanduser()
|
|
106
|
+
if not candidate.exists():
|
|
107
|
+
return []
|
|
108
|
+
if candidate.is_file():
|
|
109
|
+
return [candidate.resolve()] if candidate.suffix.lower() in VIDEO_SUFFIXES else []
|
|
110
|
+
try:
|
|
111
|
+
return sorted(
|
|
112
|
+
(path.resolve() for path in candidate.iterdir() if path.is_file() and path.suffix.lower() in VIDEO_SUFFIXES),
|
|
113
|
+
key=lambda path: path.stat().st_mtime,
|
|
114
|
+
reverse=True,
|
|
115
|
+
)
|
|
116
|
+
except OSError as exc:
|
|
117
|
+
raise CutsError(f"Não foi possível ler a pasta de vídeos: {exc}") from exc
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def list_generated_videos(tasks: Iterable[dict[str, Any]]) -> list[Path]:
|
|
121
|
+
paths: dict[str, Path] = {}
|
|
122
|
+
for task in tasks:
|
|
123
|
+
artifacts = task.get("artifacts") or {}
|
|
124
|
+
value = artifacts.get("video")
|
|
125
|
+
if isinstance(value, str) and value.strip():
|
|
126
|
+
candidate = Path(value).expanduser()
|
|
127
|
+
if candidate.is_file() and candidate.suffix.lower() in VIDEO_SUFFIXES:
|
|
128
|
+
paths[str(candidate.resolve())] = candidate.resolve()
|
|
129
|
+
return sorted(paths.values(), key=lambda path: path.stat().st_mtime, reverse=True)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def resolve_ffmpeg(configured_path: str | None = None) -> str:
|
|
133
|
+
try:
|
|
134
|
+
return _resolve_ffmpeg(configured_path)
|
|
135
|
+
except RuntimeError as exc:
|
|
136
|
+
raise CutsError(str(exc)) from exc
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def resolve_ffprobe(configured_path: str | None = None) -> str | None:
|
|
140
|
+
configured = str(configured_path or "").strip()
|
|
141
|
+
candidates: list[str] = []
|
|
142
|
+
if configured:
|
|
143
|
+
configured_path_obj = Path(configured).expanduser()
|
|
144
|
+
if configured_path_obj.name.lower().startswith("ffmpeg"):
|
|
145
|
+
candidates.append(str(configured_path_obj.with_name("ffprobe" + configured_path_obj.suffix)))
|
|
146
|
+
candidates.append(configured)
|
|
147
|
+
detected = shutil.which("ffprobe")
|
|
148
|
+
if detected:
|
|
149
|
+
candidates.append(detected)
|
|
150
|
+
for candidate in candidates:
|
|
151
|
+
resolved = shutil.which(candidate) or (candidate if Path(candidate).is_file() else None)
|
|
152
|
+
if resolved:
|
|
153
|
+
return str(resolved)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def probe_duration(source: Path, *, ffprobe_path: str | None = None) -> float | None:
|
|
158
|
+
source = _validate_video(source)
|
|
159
|
+
binary = resolve_ffprobe(ffprobe_path)
|
|
160
|
+
if not binary:
|
|
161
|
+
return None
|
|
162
|
+
command = [binary, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(source)]
|
|
163
|
+
try:
|
|
164
|
+
completed = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
165
|
+
except OSError:
|
|
166
|
+
return None
|
|
167
|
+
if completed.returncode != 0:
|
|
168
|
+
return None
|
|
169
|
+
duration = _safe_float(completed.stdout.strip(), 0.0)
|
|
170
|
+
return duration if duration > 0 else None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def validate_cut_request(
|
|
174
|
+
*,
|
|
175
|
+
source: Path,
|
|
176
|
+
output_format: str,
|
|
177
|
+
strategy: str,
|
|
178
|
+
max_clips: int,
|
|
179
|
+
min_duration: float,
|
|
180
|
+
max_duration: float,
|
|
181
|
+
rights_confirmed: bool,
|
|
182
|
+
manual_start: float = 0.0,
|
|
183
|
+
manual_end: float | None = None,
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
source = _validate_video(source)
|
|
186
|
+
output_format = normalize_output_format(output_format)
|
|
187
|
+
if strategy not in {"automatic", "manual"}:
|
|
188
|
+
raise CutsError("Seleccione uma estratégia de corte válida.")
|
|
189
|
+
if not rights_confirmed:
|
|
190
|
+
raise CutsError("Confirme que possui os direitos ou autorização para processar este conteúdo.")
|
|
191
|
+
max_clips = int(max_clips)
|
|
192
|
+
if max_clips < 1 or max_clips > 20:
|
|
193
|
+
raise CutsError("A quantidade de clips deve estar entre 1 e 20.")
|
|
194
|
+
min_duration = _safe_float(min_duration)
|
|
195
|
+
max_duration = _safe_float(max_duration)
|
|
196
|
+
if min_duration < 1 or max_duration > 600 or max_duration < min_duration:
|
|
197
|
+
raise CutsError("Defina uma duração válida: mínimo ≥ 1s, máximo ≤ 600s e máximo ≥ mínimo.")
|
|
198
|
+
manual_start = _safe_float(manual_start)
|
|
199
|
+
if manual_start < 0:
|
|
200
|
+
raise CutsError("O início manual não pode ser negativo.")
|
|
201
|
+
normalized_end = None if manual_end is None else _safe_float(manual_end)
|
|
202
|
+
if strategy == "manual" and (normalized_end is None or normalized_end <= manual_start):
|
|
203
|
+
raise CutsError("No modo manual, o fim deve ser superior ao início.")
|
|
204
|
+
return {
|
|
205
|
+
"source": str(source),
|
|
206
|
+
"source_name": source.name,
|
|
207
|
+
"output_format": output_format,
|
|
208
|
+
"strategy": strategy,
|
|
209
|
+
"max_clips": max_clips,
|
|
210
|
+
"min_duration": min_duration,
|
|
211
|
+
"max_duration": max_duration,
|
|
212
|
+
"manual_start": manual_start,
|
|
213
|
+
"manual_end": normalized_end,
|
|
214
|
+
"rights_confirmed": True,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def plan_segments(
|
|
219
|
+
duration: float | None,
|
|
220
|
+
*,
|
|
221
|
+
strategy: str,
|
|
222
|
+
max_clips: int,
|
|
223
|
+
min_duration: float,
|
|
224
|
+
max_duration: float,
|
|
225
|
+
manual_start: float = 0.0,
|
|
226
|
+
manual_end: float | None = None,
|
|
227
|
+
) -> list[dict[str, float]]:
|
|
228
|
+
if strategy == "manual":
|
|
229
|
+
if manual_end is None or manual_end <= manual_start:
|
|
230
|
+
raise CutsError("No modo manual, o fim deve ser superior ao início.")
|
|
231
|
+
return [{"index": 1, "start": round(float(manual_start), 3), "end": round(float(manual_end), 3), "duration": round(float(manual_end - manual_start), 3)}]
|
|
232
|
+
if duration is None or duration <= 0:
|
|
233
|
+
raise CutsError("Não foi possível detectar a duração do vídeo. Use o modo manual ou instale/configure FFprobe.")
|
|
234
|
+
if duration < min_duration:
|
|
235
|
+
raise CutsError(f"O vídeo tem apenas {duration:.1f}s e é menor que a duração mínima configurada.")
|
|
236
|
+
count = min(int(max_clips), max(1, int(duration // min_duration)))
|
|
237
|
+
clip_duration = min(float(max_duration), max(float(min_duration), duration / count))
|
|
238
|
+
if clip_duration > duration:
|
|
239
|
+
clip_duration = duration
|
|
240
|
+
if count == 1:
|
|
241
|
+
starts = [0.0]
|
|
242
|
+
else:
|
|
243
|
+
starts = [(duration - clip_duration) * index / (count - 1) for index in range(count)]
|
|
244
|
+
return [
|
|
245
|
+
{"index": index + 1, "start": round(start, 3), "end": round(min(duration, start + clip_duration), 3), "duration": round(min(duration, start + clip_duration) - start, 3)}
|
|
246
|
+
for index, start in enumerate(starts)
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _video_filter(output_format: str) -> str:
|
|
251
|
+
dimensions = OUTPUT_FORMATS[normalize_output_format(output_format)]
|
|
252
|
+
width = dimensions["width"]
|
|
253
|
+
height = dimensions["height"]
|
|
254
|
+
return f"scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def build_clip_command(source: Path, output: Path, segment: dict[str, float], output_format: str, *, ffmpeg_path: str) -> list[str]:
|
|
258
|
+
start = max(0.0, float(segment["start"]))
|
|
259
|
+
duration = max(0.1, float(segment["duration"]))
|
|
260
|
+
return [
|
|
261
|
+
ffmpeg_path,
|
|
262
|
+
"-y",
|
|
263
|
+
"-hide_banner",
|
|
264
|
+
"-loglevel",
|
|
265
|
+
"error",
|
|
266
|
+
"-ss",
|
|
267
|
+
f"{start:.3f}",
|
|
268
|
+
"-t",
|
|
269
|
+
f"{duration:.3f}",
|
|
270
|
+
"-i",
|
|
271
|
+
str(source),
|
|
272
|
+
"-map",
|
|
273
|
+
"0:v:0",
|
|
274
|
+
"-map",
|
|
275
|
+
"0:a?",
|
|
276
|
+
"-vf",
|
|
277
|
+
_video_filter(output_format),
|
|
278
|
+
"-c:v",
|
|
279
|
+
"libx264",
|
|
280
|
+
"-preset",
|
|
281
|
+
"veryfast",
|
|
282
|
+
"-crf",
|
|
283
|
+
"22",
|
|
284
|
+
"-c:a",
|
|
285
|
+
"aac",
|
|
286
|
+
"-movflags",
|
|
287
|
+
"+faststart",
|
|
288
|
+
str(output),
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _run_ffmpeg(command: list[str], output: Path, *, source: Path, segment: dict[str, float]) -> dict[str, Any]:
|
|
293
|
+
try:
|
|
294
|
+
completed = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
295
|
+
except OSError as exc:
|
|
296
|
+
raise CutsError(f"Não foi possível iniciar FFmpeg: {exc}") from exc
|
|
297
|
+
if completed.returncode != 0 or not output.is_file() or output.stat().st_size == 0:
|
|
298
|
+
if output.exists():
|
|
299
|
+
output.unlink()
|
|
300
|
+
detail = (completed.stderr or completed.stdout or "erro desconhecido").strip()
|
|
301
|
+
raise CutsError(f"O clip não pôde ser gerado: {detail[-1200:]}")
|
|
302
|
+
return {"source": str(source), "output": str(output), "command": command, "segment": segment, "created_at": _now()}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _write_manifest(record: dict[str, Any], run_dir: Path) -> Path:
|
|
306
|
+
path = run_dir / "manifest.json"
|
|
307
|
+
path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
308
|
+
return path
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def generate_clips(
|
|
312
|
+
source: Path,
|
|
313
|
+
*,
|
|
314
|
+
output_format: str,
|
|
315
|
+
strategy: str,
|
|
316
|
+
max_clips: int,
|
|
317
|
+
min_duration: float,
|
|
318
|
+
max_duration: float,
|
|
319
|
+
rights_confirmed: bool,
|
|
320
|
+
ffmpeg_path: str | None = None,
|
|
321
|
+
manual_start: float = 0.0,
|
|
322
|
+
manual_end: float | None = None,
|
|
323
|
+
) -> dict[str, Any]:
|
|
324
|
+
request = validate_cut_request(
|
|
325
|
+
source=source,
|
|
326
|
+
output_format=output_format,
|
|
327
|
+
strategy=strategy,
|
|
328
|
+
max_clips=max_clips,
|
|
329
|
+
min_duration=min_duration,
|
|
330
|
+
max_duration=max_duration,
|
|
331
|
+
rights_confirmed=rights_confirmed,
|
|
332
|
+
manual_start=manual_start,
|
|
333
|
+
manual_end=manual_end,
|
|
334
|
+
)
|
|
335
|
+
duration = probe_duration(source)
|
|
336
|
+
segments = plan_segments(
|
|
337
|
+
duration,
|
|
338
|
+
strategy=strategy,
|
|
339
|
+
max_clips=max_clips,
|
|
340
|
+
min_duration=min_duration,
|
|
341
|
+
max_duration=max_duration,
|
|
342
|
+
manual_start=manual_start,
|
|
343
|
+
manual_end=manual_end,
|
|
344
|
+
)
|
|
345
|
+
run_id = f"cut_{datetime.now().strftime('%Y%m%d-%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
|
346
|
+
run_dir = _runs_root() / run_id
|
|
347
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
348
|
+
record: dict[str, Any] = {
|
|
349
|
+
"id": run_id,
|
|
350
|
+
"created_at": _now(),
|
|
351
|
+
"status": "processing",
|
|
352
|
+
"source": request["source"],
|
|
353
|
+
"source_name": request["source_name"],
|
|
354
|
+
"source_duration": duration,
|
|
355
|
+
"output_format": output_format,
|
|
356
|
+
"strategy": strategy,
|
|
357
|
+
"parameters": {key: value for key, value in request.items() if key not in {"source", "source_name"}},
|
|
358
|
+
"clips": [],
|
|
359
|
+
"error": "",
|
|
360
|
+
}
|
|
361
|
+
try:
|
|
362
|
+
binary = resolve_ffmpeg(ffmpeg_path)
|
|
363
|
+
for segment in segments:
|
|
364
|
+
output = run_dir / f"clip-{int(segment['index']):02d}-{_safe_filename(source.stem, 'video')}-{output_format.replace(':', 'x')}.mp4"
|
|
365
|
+
command = build_clip_command(source, output, segment, output_format, ffmpeg_path=binary)
|
|
366
|
+
run_info = _run_ffmpeg(command, output, source=source, segment=segment)
|
|
367
|
+
record["clips"].append({
|
|
368
|
+
"index": int(segment["index"]),
|
|
369
|
+
"path": str(output),
|
|
370
|
+
"name": output.name,
|
|
371
|
+
"start": segment["start"],
|
|
372
|
+
"end": segment["end"],
|
|
373
|
+
"duration": segment["duration"],
|
|
374
|
+
"size_bytes": output.stat().st_size,
|
|
375
|
+
"command": run_info["command"],
|
|
376
|
+
})
|
|
377
|
+
record["status"] = "complete"
|
|
378
|
+
except (CutsError, OSError, ValueError) as exc:
|
|
379
|
+
record["status"] = "error"
|
|
380
|
+
record["error"] = str(exc)
|
|
381
|
+
_write_manifest(record, run_dir)
|
|
382
|
+
storage.append_json("cuts_runs.json", record)
|
|
383
|
+
if record["status"] == "error":
|
|
384
|
+
raise CutsError(record["error"])
|
|
385
|
+
return record
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def list_runs() -> list[dict[str, Any]]:
|
|
389
|
+
records = storage.read_json("cuts_runs.json", [])
|
|
390
|
+
return list(reversed(records)) if isinstance(records, list) else []
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def manifest_bytes(record: dict[str, Any]) -> bytes:
|
|
394
|
+
return (json.dumps(record, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def zip_run(record: dict[str, Any]) -> tuple[Path, bytes]:
|
|
398
|
+
run_id = _safe_filename(str(record.get("id") or "run"), "run")
|
|
399
|
+
run_dir = _runs_root() / run_id
|
|
400
|
+
if not run_dir.is_dir():
|
|
401
|
+
raise CutsError("A execução seleccionada já não existe no storage local.")
|
|
402
|
+
archive = run_dir / f"{run_id}.zip"
|
|
403
|
+
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as handle:
|
|
404
|
+
for clip in record.get("clips", []):
|
|
405
|
+
path = Path(str(clip.get("path") or ""))
|
|
406
|
+
if path.is_file() and run_dir.resolve() in path.resolve().parents:
|
|
407
|
+
handle.write(path, arcname=path.name)
|
|
408
|
+
manifest = run_dir / "manifest.json"
|
|
409
|
+
if manifest.is_file():
|
|
410
|
+
handle.write(manifest, arcname=manifest.name)
|
|
411
|
+
return archive, archive.read_bytes()
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def download_direct_video_url(url: str, *, timeout: int = 30) -> Path:
|
|
415
|
+
parsed = urlparse(str(url).strip())
|
|
416
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
417
|
+
raise CutsError("Informe uma URL HTTP/HTTPS directa para um ficheiro de vídeo.")
|
|
418
|
+
try:
|
|
419
|
+
response = requests.get(url, stream=True, timeout=timeout, headers={"User-Agent": "Thunderbolt/0.2"})
|
|
420
|
+
response.raise_for_status()
|
|
421
|
+
except requests.RequestException as exc:
|
|
422
|
+
raise CutsError(f"Não foi possível descarregar a URL do vídeo: {exc}") from exc
|
|
423
|
+
content_length = _safe_float(response.headers.get("content-length"), 0.0)
|
|
424
|
+
if content_length > MAX_SOURCE_BYTES:
|
|
425
|
+
raise CutsError("A fonte excede o limite de 500 MB.")
|
|
426
|
+
suffix = Path(parsed.path).suffix.lower()
|
|
427
|
+
if suffix not in VIDEO_SUFFIXES:
|
|
428
|
+
content_type = str(response.headers.get("content-type") or "").lower()
|
|
429
|
+
suffix = ".mp4" if "mp4" in content_type or "video" in content_type else ""
|
|
430
|
+
if suffix not in VIDEO_SUFFIXES:
|
|
431
|
+
raise CutsError("A URL deve apontar para um ficheiro de vídeo MP4, MOV ou formato suportado.")
|
|
432
|
+
digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
|
|
433
|
+
target = _inputs() / f"{digest}-url{suffix}"
|
|
434
|
+
if target.exists():
|
|
435
|
+
return target.resolve()
|
|
436
|
+
total = 0
|
|
437
|
+
temporary = target.with_suffix(target.suffix + ".part")
|
|
438
|
+
try:
|
|
439
|
+
with temporary.open("wb") as handle:
|
|
440
|
+
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
|
441
|
+
if not chunk:
|
|
442
|
+
continue
|
|
443
|
+
total += len(chunk)
|
|
444
|
+
if total > MAX_SOURCE_BYTES:
|
|
445
|
+
raise CutsError("A fonte excede o limite de 500 MB.")
|
|
446
|
+
handle.write(chunk)
|
|
447
|
+
temporary.replace(target)
|
|
448
|
+
except CutsError:
|
|
449
|
+
temporary.unlink(missing_ok=True)
|
|
450
|
+
raise
|
|
451
|
+
except OSError as exc:
|
|
452
|
+
temporary.unlink(missing_ok=True)
|
|
453
|
+
raise CutsError(f"Não foi possível guardar o vídeo descarregado: {exc}") from exc
|
|
454
|
+
return target.resolve()
|
package/package.json
CHANGED