@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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
4
4
 
5
- > **Versão deste manual:** 0.2.53
5
+ > **Versão deste manual:** 0.2.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.53 install
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.53 install
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.53 install`; o instalador detecta e reutiliza o que já estiver válido.
431
+ As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.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** permanece reservada e não executa operações. A aba **Editor Python** é funcional e permite escolher vídeos gerados, indicar uma pasta local ou fazer upload manual; as operações são manuais e criam cópias sem alterar os originais.
443
+ A aba **Limpador de Metadados** continua funcional e foi movida para **Edição**. A aba **Cortes** é um Clip Generator local inspirado no [OpenShorts](https://github.com/mutonby/openshorts): permite upload de vídeo, URL directa, vídeos gerados ou pasta local, formatos 9:16/1:1/16:9, opções avançadas, modo manual ou automático por segmentos locais e confirmação de direitos antes da geração. Os clips são processados com FFmpeg, guardados em `storage/cuts/runs/<id>/`, apresentados com preview e downloads individual/ZIP, acompanhados de manifesto JSON e histórico. A aba **Editor Python** é funcional e permite escolher vídeos gerados, indicar uma pasta local ou fazer upload manual; as operações são manuais e criam cópias sem alterar os originais.
444
444
 
445
445
  ## Editor Python baseado no PYEdit
446
446
 
@@ -448,6 +448,8 @@ O **Editor Python** adapta o recorte do [PYEdit](https://github.com/Congren/PYEd
448
448
 
449
449
  Cada operação usa FFmpeg local, preserva o original, cria a saída em `storage/python_editor/outputs/`, guarda o histórico em `storage/state/python_editor_edits.json` e permite descarregar o resultado e um manifesto JSON. Na subaba **Código Python**, pode criar e guardar scripts em `storage/python_editor/scripts/` ou carregar scripts existentes. Por segurança, a UI não executa código Python e não executa scripts automaticamente.
450
450
 
451
+ No modo automático de **Cortes**, a aplicação não inventa uma selecção viral: sem transcrição ou provider de IA configurado, são gerados segmentos locais distribuídos pelo vídeo; o modo manual permite definir início e fim exactos. Abrir a página não inicia download, análise ou processamento.
452
+
451
453
  ## Criação de Vídeos — IA editorial e pacotes criativos
452
454
 
453
455
  O formulário permite escrever manualmente o **Tópico ou briefing** ou clicar em **Gerar tópico/briefing com IA**. O botão usa o provider LLM, Base URL, API key e modelo guardados em **Configurações Técnicas > API Keys**, incorpora a descrição, o nicho e o Blueprint do canal e coloca o resultado no campo para revisão. Abrir a página não inicia chamadas externas.
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 funcional, Cortes reservado e Editor Python inspirado no PYEdit para vídeos e scripts locais |
22
+ | Edição | Menu expansível abaixo de Automação com Limpador de Metadados, Clip Generator local em Cortes e Editor Python inspirado no PYEdit para vídeos e scripts locais |
23
23
  | Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo via sessão Frontend API com cookies/sessionInfo por conta e DELEGATED_SESSION_ID por canal, TikTok, Instagram e Facebook Pages no front end |
24
24
  | MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
25
25
  | Configurações Técnicas | Provedores LLM, OpenAI/NVIDIA NIM com API key, Base URL e selector de modelos, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, contas Google/YouTube para canais em lote com e-mail, Client ID, Client Secret e sessionInfo por conta, cartões expansíveis por nome/e-mail, documento credentials.json criado automaticamente e eliminação individual, cookies SID/SSID/HSID/APISID/SAPISID no documento JSON, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, TikTok Client ID/Secret e Upload-Post |
@@ -38,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.53
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.50 install
165
- npx.cmd --yes @danhachuel/thunderbolt@0.2.50 doctor
166
- npx.cmd --yes @danhachuel/thunderbolt@0.2.50
164
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.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.50 install`; o instalador detecta e reutiliza componentes já válidos.
190
+ **Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em Configurações Técnicas. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.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": lambda: render_edit_placeholder("Cortes", "Área reservada para a futura funcionalidade de cortes de vídeo."),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.53",
3
+ "version": "0.2.54",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",