@danhachuel/thunderbolt 0.3.70 → 0.3.71
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 +8 -4
- package/README.md +7 -3
- package/app/influencers_ui.py +183 -8
- package/app/main.py +4 -2
- package/hermes_ui/creative_generation.py +39 -0
- package/hermes_ui/influencers.py +19 -0
- package/hermes_ui/media_generation.py +335 -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.3.
|
|
5
|
+
> **Versão deste manual:** 0.3.71
|
|
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)
|
|
@@ -35,9 +35,13 @@ O backend local predefinido é **SQLite**, pelo que Personagens e Geração de C
|
|
|
35
35
|
|
|
36
36
|
Se pretender trabalhar sem serviço externo, seleccione **SQLite** no selector da aba **Configuração API > AI Influencers**. O caminho `storage/state/ai_influencers.db` e a pasta `storage/influencers/` são geridos internamente, sem campos editáveis para o utilizador. Apenas um backend é usado de cada vez. A base fica na pasta persistente do Thunderbolt, fora da instalação temporária do pacote npm; numa actualização normal, personagens, assets e configurações são preservados. O instalador também procura uma base `ai_influencers.db` válida em instalações anteriores do cache npm e recupera-a sem substituir uma base persistente já existente.
|
|
37
37
|
|
|
38
|
-
Em **AI Influencers > Geração de Conteúdo IA**, utilize as subabas **Imagens
|
|
38
|
+
Em **AI Influencers > Geração de Conteúdo IA**, utilize as subabas **Imagens** e **Vídeos**. Seleccione um personagem, prompt e provider/modelo configurado no pool correspondente. A subaba **Imagens** usa o pool de imagem configurado em **Imagem e Video IA**. A subaba **Vídeos** requer uma imagem inicial e usa o pool de vídeo; KIE AI, Replicate e FAL AI podem usar tarefas assíncronas. O Thunderbolt consulta o estado e guarda o resultado local antes da revisão. Na Replicate, o campo Modelo deve ser o identificador do modelo ou da versão, e os inputs específicos dependem do modelo configurado.
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
Em **AI Influencers > Motion Control**, carregue um vídeo original `.mp4` ou `.mov` entre 3 e 30 segundos e uma imagem de referência `.jpg`, `.jpeg` ou `.png` até 10 MB. O prompt é opcional e limitado a 2500 caracteres. O Thunderbolt valida os limites, guarda os originais no storage local, envia-os ao upload temporário oficial KIE, cria `kling-2.6/motion-control`, consulta `/api/v1/jobs/recordInfo` e descarrega o MP4 final localmente. É necessário um cartão KIE AI activo no pool de vídeo. O workflow não utiliza callback público, Telegram, Postiz, Google Drive ou publicação social.
|
|
41
|
+
|
|
42
|
+
Em **AI Influencers > UGC Products**, carregue a imagem do produto e preencha **Roteiro de vídeo**. Se o roteiro contiver dois blocos separados por `---`, os blocos são usados directamente; nos restantes casos, o pool LLM cria dois prompts de oito segundos, mantendo o produto e proibindo alterações físicas, texto incorporado, legendas e watermark. O Thunderbolt usa o endpoint KIE VEO3.1 `/api/v1/veo/generate` com `veo3_fast`, uma `imageUrls`, `duration: 8` e `resolution: 720p`, consulta `/api/v1/veo/record-info`, descarrega os dois clips e junta-os localmente com FFmpeg. O resultado final e os IDs das tarefas são persistidos no backend AI Influencers; não há Telegram, Postiz, Drive, upload ou publicação social.
|
|
43
|
+
|
|
44
|
+
A geração standalone guarda prompt, provider, modelo, estado, IDs das tarefas, inputs e caminho do artefacto em conteúdos internos do backend. O proprietário técnico desses registos fica oculto da lista de personagens. Os destinos sociais não são apresentados nestes dois formulários e a publicação permanece fora do workflow.
|
|
41
45
|
|
|
42
46
|
O runtime do Thunderbolt adapta o comportamento dos workflows do episódio 35, mas não instala nem executa n8n. Formulários, Data Tables, Switch, Wait e subworkflows são substituídos por páginas Streamlit, repository Supabase/SQLite, adapters multimédia, worker, logs e notificações. Nunca coloque API keys nos JSONs dos workflows, em ficheiros versionados ou em mensagens de diagnóstico.
|
|
43
47
|
|
|
@@ -50,7 +54,7 @@ A execução segue sempre **Tema → Script → Título → Keywords (opcional)
|
|
|
50
54
|
| Opção visível | Rota executada | Resultado esperado |
|
|
51
55
|
|---|---|---|
|
|
52
56
|
| Pexels/Pixabay | MoneyPrinterTurbo stock com a fonte efectiva e as chaves correspondentes | MP4 composto localmente |
|
|
53
|
-
| Full IA | Pool de vídeo limitado
|
|
57
|
+
| Full IA | Pool de vídeo limitado pelos cartões activos que declaram capacidade | MP4 gerado pelo provider activo |
|
|
54
58
|
| Apenas Música | Áudio local/Suno já preparado | Ficheiro musical pronto para um upload de música; não gera MP4, thumbnail nem upload YouTube |
|
|
55
59
|
|
|
56
60
|
Antes de iniciar a rota stock, confirme em **Configurações > Configuração API > Fontes de materiais** que existe pelo menos uma API key para a fonte efectiva. Sem uma key Pexels/Pixabay, o erro é apresentado como configuração da fonte, em vez de uma falha genérica de vídeo. Se o MoneyPrinterTurbo devolver `MPT_NEEDS_INPUT`, o Thunderbolt lê `LLM_PROVIDER`, `MISSING` e `INVALID` e mostra no erro todas as APIs afectadas, como **OpenAI / NVIDIA NIM API + Pexels API**, incluindo os campos que faltam. **Google Lyria não é apresentado como implementado nesta release**; a rota Apenas Música aceita áudio local e a integração Suno existente.
|
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ A primeira versão implementa a camada UI independente com:
|
|
|
20
20
|
| Criação de Vídeos / Criação de Músicas | Subabas Criar vídeo e Vídeos; idiomas históricos preservados e dez códigos MoneyPrinterTurbo com bandeiras; Pexels/Pixabay, full IA com Estilo IA e Apenas Música com agente musical; a segunda página reutiliza o mesmo fluxo com título próprio |
|
|
21
21
|
| Upload Música | Subabas **JewelMusic**, **Pushtunes** e **ytmusicapi** para upload de ficheiros, sincronização de bibliotecas e envio autenticado para YouTube Music; credenciais locais e histórico em `uploads.json` |
|
|
22
22
|
| 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 |
|
|
23
|
-
| AI Influencers | Menu expansível com Personagens, Geração de Conteúdo IA,
|
|
23
|
+
| AI Influencers | Menu expansível com Personagens, Geração de Conteúdo IA, Motion Control, UGC Products e Redes Sociais; Personagens aceita múltiplas imagens e documentos `.md`/`.json`, e os dois workflows standalone guardam os resultados localmente sem Telegram ou publicação social |
|
|
24
24
|
| Niche Finder | Menu expansível com duas alternativas independentes: Niche Finder Kaggle e Niche Finder Apify, com parâmetros, execução e resultados separados |
|
|
25
25
|
| 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 |
|
|
26
26
|
| Upload | YouTube via `youtube-automation-agent` adaptado internamente, fallback ordenado API Oficial → Upload directo → Postiz, upload de MP4 para Postiz via API key/MCP configurável, TikTok, Instagram e Facebook Pages no front end |
|
|
@@ -34,7 +34,11 @@ A área **AI Influencers > Personagens** permite guardar um personagem com nome,
|
|
|
34
34
|
|
|
35
35
|
O backend local predefinido é **SQLite**, por isso Personagens e Geração de Conteúdo IA funcionam imediatamente sem credenciais externas. Em **Configurações > Configuração API > AI Influencers**, o selector **Backend da base de dados de AI Influencers** aparece logo abaixo do estado do backend. O card **Supabase** contém apenas **Supabase Project URL** e **Supabase API key**. Se **Supabase** estiver seleccionado mas faltar qualquer uma dessas credenciais, o **Backend activo** muda automaticamente para **SQLite**; quando ambas estão preenchidas, passa para Supabase. O SQLite usa internamente `storage/state/ai_influencers.db` e `storage/influencers/`, sem campos editáveis de caminho ou Storage bucket. O botão **Testar ligação do backend** é read-only e não bloqueia a utilização local. A base SQLite fica na pasta persistente do Thunderbolt, fora da instalação temporária do pacote npm; durante uma actualização normal, personagens, assets e configurações são preservados. Se uma versão anterior tiver guardado `ai_influencers.db` no cache npm, o instalador procura uma cópia válida e recupera-a sem substituir uma base persistente já existente.
|
|
36
36
|
|
|
37
|
-
A página **AI Influencers > Geração de Conteúdo IA** contém as subabas **Imagens
|
|
37
|
+
A página **AI Influencers > Geração de Conteúdo IA** contém as subabas **Imagens** e **Vídeos**. Imagens usam os cartões activos do **Pool Imagem**; Vídeos usam os cartões activos do **Pool Vídeo** e exigem uma imagem inicial image-to-video. O selector de modelo não está preso ao Veo 3.1: pode utilizar KIE AI, Replicate, FAL AI, Pollinations ou outro cartão que declare suporte para vídeo. Para Replicate, o campo Modelo deve conter o identificador aceito pela API, como `owner/model` ou `owner/model:version`; a tarefa usa `POST /v1/predictions`, consulta o estado assíncrono e guarda o resultado localmente. A publicação para Instagram, TikTok, YouTube Shorts ou Facebook não é automática.
|
|
38
|
+
|
|
39
|
+
A página **AI Influencers > Motion Control** adapta apenas a criação do workflow Kling 2.6 Motion Control. O formulário recebe um vídeo original `.mp4`/`.mov` de 3–30 segundos, uma imagem `.jpg`/`.jpeg`/`.png` até 10 MB e um prompt opcional até 2500 caracteres. Os ficheiros são preservados no storage local, enviados temporariamente para o upload oficial KIE e usados em `POST /api/v1/jobs/createTask`; o estado é consultado em `/api/v1/jobs/recordInfo`, o MP4 é descarregado imediatamente e a tarefa é guardada no backend AI Influencers. Não existe callback público, Telegram, Postiz, Google Drive, upload ou publicação social.
|
|
40
|
+
|
|
41
|
+
A página **AI Influencers > UGC Products** adapta somente a criação do workflow Gemini/VEO3. Recebe uma imagem local do produto e o campo **Roteiro de vídeo**. Um roteiro dividido por `---` é respeitado directamente; caso contrário, o pool LLM estrutura dois prompts com regras de continuidade, preservação do produto, acções fisicamente possíveis e sem texto incorporado, legendas ou watermark. Cada segmento usa `POST /api/v1/veo/generate` com `veo3_fast`, `imageUrls`, `duration: 8` e `resolution: 720p`; o polling usa o endpoint oficial `/api/v1/veo/record-info`. O Thunderbolt descarrega os dois MP4, junta-os localmente com FFmpeg e persiste o artefacto final, sem Telegram, Postiz, Drive ou redes sociais.
|
|
38
42
|
|
|
39
43
|
A migração é baseada no workflow público [AI Agents A-Z — episódio 35](https://github.com/gyoridavid/ai_agents_az/tree/main/episode_35), mas o runtime do Thunderbolt não instala nem executa n8n. Os formulários, Data Tables, waits e subworkflows são substituídos pela UI Streamlit, pelo repositório dual Supabase/SQLite, pelos adapters multimédia e pelos logs/notificações locais. As API keys são sempre configuradas localmente e não devem ser colocadas nos JSONs do workflow ou no GitHub.
|
|
40
44
|
|
|
@@ -97,7 +101,7 @@ A UI suporta os temas **Dark** e **Light** através do menu nativo de três pont
|
|
|
97
101
|
|
|
98
102
|
## Navegação da UI 0.3.66
|
|
99
103
|
|
|
100
|
-
A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. **Pipeline Vídeos** é expansível e contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. **Pipeline Música** é expansível e contém **Criação de Músicas** e **Upload Música**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes**, **Editor Python** e **Download Mídia**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Geração de Conteúdo IA**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. A
|
|
104
|
+
A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. **Pipeline Vídeos** é expansível e contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. **Pipeline Música** é expansível e contém **Criação de Músicas** e **Upload Música**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes**, **Editor Python** e **Download Mídia**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Geração de Conteúdo IA**, **Motion Control**, **UGC Products**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. A criação de Motion Control e UGC Products ocorre nas páginas standalone e guarda os artefactos localmente; não existem tabs duplicadas dentro de Geração de Conteúdo IA.
|
|
101
105
|
**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**, **Contas Google**, **Notificações**, **Logs** e **Configuração API**, nessa ordem. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
|
|
102
106
|
|
|
103
107
|
A subaba **Tutorial Supabase** apresenta o guia de criação das tabelas `plans` e `posts` e do bucket `instagram-images`, com ligação para a fonte original no GitHub. As duas frases promocionais da comunidade foram omitidas. Todas as subabas internas também são localizadas pelo helper nativo de tabs:
|
package/app/influencers_ui.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
|
+
import hashlib
|
|
6
7
|
import io
|
|
7
8
|
import json
|
|
8
9
|
from pathlib import Path
|
|
@@ -13,14 +14,26 @@ import streamlit as st
|
|
|
13
14
|
from hermes_ui.influencers import (
|
|
14
15
|
BACKEND_OPTIONS,
|
|
15
16
|
InfluencerBackendError,
|
|
17
|
+
STANDALONE_CONTENT_INFLUENCER_ID,
|
|
16
18
|
backend_name,
|
|
17
19
|
backend_status,
|
|
20
|
+
ensure_standalone_content_owner,
|
|
18
21
|
get_repository,
|
|
19
22
|
test_backend,
|
|
20
23
|
)
|
|
21
|
-
from hermes_ui.media_generation import
|
|
24
|
+
from hermes_ui.media_generation import (
|
|
25
|
+
MediaGenerationError,
|
|
26
|
+
generate_image_for_card,
|
|
27
|
+
generate_motion_control_video,
|
|
28
|
+
generate_ugc_product_video,
|
|
29
|
+
generate_video_for_card,
|
|
30
|
+
upload_kie_file,
|
|
31
|
+
validate_motion_control_file,
|
|
32
|
+
)
|
|
33
|
+
from hermes_ui.creative_generation import generate_ugc_segment_prompts
|
|
22
34
|
from hermes_ui.media_providers import media_cards_for_pool, media_provider_definition
|
|
23
35
|
from hermes_ui.notifications import record_notification
|
|
36
|
+
from hermes_ui.storage import STORAGE, ensure_storage
|
|
24
37
|
|
|
25
38
|
|
|
26
39
|
CONTENT_STATES = {
|
|
@@ -195,7 +208,7 @@ def render_ai_influencer_characters(
|
|
|
195
208
|
|
|
196
209
|
with created_characters_tab:
|
|
197
210
|
try:
|
|
198
|
-
influencers = repository.list_influencers()
|
|
211
|
+
influencers = [item for item in repository.list_influencers() if str(item.get("id") or "") != STANDALONE_CONTENT_INFLUENCER_ID]
|
|
199
212
|
except Exception:
|
|
200
213
|
st.error("Não foi possível consultar os personagens no backend seleccionado.")
|
|
201
214
|
return
|
|
@@ -301,6 +314,170 @@ def _render_content_history(repository: Any, influencer_id: str = "") -> None:
|
|
|
301
314
|
st.video(str(artifact))
|
|
302
315
|
|
|
303
316
|
|
|
317
|
+
def _store_uploaded_file(uploaded: Any, folder: str) -> Path:
|
|
318
|
+
"""Persist an uploaded Streamlit file under storage without trusting its filename."""
|
|
319
|
+
ensure_storage()
|
|
320
|
+
original = Path(str(getattr(uploaded, "name", "upload.bin") or "upload.bin")).name
|
|
321
|
+
safe_name = "".join(char if char.isalnum() or char in ".-_" else "_" for char in original).strip("._") or "upload.bin"
|
|
322
|
+
content = uploaded.getvalue()
|
|
323
|
+
if not content:
|
|
324
|
+
raise MediaGenerationError(f"O ficheiro {safe_name} está vazio.")
|
|
325
|
+
digest = hashlib.sha256(content).hexdigest()[:16]
|
|
326
|
+
destination = STORAGE / "influencer_workflows" / folder / f"{digest}-{safe_name}"
|
|
327
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
328
|
+
if not destination.exists():
|
|
329
|
+
destination.write_bytes(content)
|
|
330
|
+
return destination
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _workflow_owner(repository: Any) -> str:
|
|
334
|
+
return ensure_standalone_content_owner(repository)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _workflow_provider_cards(settings: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
338
|
+
return [card for card in media_cards_for_pool(settings, "video") if str(card.get("provider") or "").strip().lower() == "kie_ai"]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def render_motion_control(settings: dict[str, Any]) -> None:
|
|
342
|
+
"""Render Kling 2.6 Motion Control creation only; no social delivery is exposed."""
|
|
343
|
+
st.title("Motion Control")
|
|
344
|
+
st.caption("Crie um vídeo Kling 2.6 a partir de um vídeo de movimento e de uma imagem de referência. O resultado é descarregado para o storage local; não há Telegram, Postiz, Drive ou publicação social.")
|
|
345
|
+
repository = _repository(settings)
|
|
346
|
+
if repository is None:
|
|
347
|
+
return
|
|
348
|
+
cards = _workflow_provider_cards(settings)
|
|
349
|
+
if not cards:
|
|
350
|
+
st.warning("Active e configure pelo menos um cartão KIE AI no pool de vídeo em Configuração API > API Keys > Imagem e Video IA.")
|
|
351
|
+
return
|
|
352
|
+
card_options = [str(card.get("id") or "") for card in cards]
|
|
353
|
+
with st.form("influencer_motion_control_form"):
|
|
354
|
+
video_upload = st.file_uploader("Vídeo original de movimento", type=["mp4", "mov"], key="motion_control_video")
|
|
355
|
+
image_upload = st.file_uploader("Imagem de referência", type=["jpg", "jpeg", "png"], key="motion_control_image")
|
|
356
|
+
prompt = st.text_area("Prompt (opcional)", height=120, max_chars=2500, placeholder="Descreva como preservar a identidade da imagem e aplicar o movimento…", key="motion_control_prompt")
|
|
357
|
+
provider_id = st.selectbox("Provider / modelo", card_options, format_func=lambda value: _provider_label(next(card for card in cards if str(card.get("id")) == value)), key="motion_control_provider")
|
|
358
|
+
generate = st.form_submit_button("Gerar Motion Control", type="primary", use_container_width=True)
|
|
359
|
+
if generate:
|
|
360
|
+
if video_upload is None or image_upload is None:
|
|
361
|
+
st.error("Seleccione o vídeo original e a imagem de referência.")
|
|
362
|
+
return
|
|
363
|
+
card = next(card for card in cards if str(card.get("id")) == provider_id)
|
|
364
|
+
try:
|
|
365
|
+
video_path = _store_uploaded_file(video_upload, "motion-control-inputs")
|
|
366
|
+
image_path = _store_uploaded_file(image_upload, "motion-control-inputs")
|
|
367
|
+
video_info = validate_motion_control_file(video_path, kind="vídeo")
|
|
368
|
+
image_info = validate_motion_control_file(image_path, kind="imagem")
|
|
369
|
+
owner_id = _workflow_owner(repository)
|
|
370
|
+
record = repository.create_content(
|
|
371
|
+
{
|
|
372
|
+
"influencer_id": owner_id,
|
|
373
|
+
"content_type": "video",
|
|
374
|
+
"prompt": prompt,
|
|
375
|
+
"caption": "",
|
|
376
|
+
"provider": card.get("provider"),
|
|
377
|
+
"model": "kling-2.6/motion-control",
|
|
378
|
+
"platform": "",
|
|
379
|
+
"state": "running",
|
|
380
|
+
"metadata": {
|
|
381
|
+
"workflow": "motion_control",
|
|
382
|
+
"input_video_path": str(video_path),
|
|
383
|
+
"reference_image_path": str(image_path),
|
|
384
|
+
"input_video_duration_seconds": video_info.get("duration_seconds"),
|
|
385
|
+
"input_video_size_bytes": video_info.get("size_bytes"),
|
|
386
|
+
"reference_image_size_bytes": image_info.get("size_bytes"),
|
|
387
|
+
},
|
|
388
|
+
}
|
|
389
|
+
)
|
|
390
|
+
with st.spinner("A enviar os inputs para KIE e a aguardar o Kling Motion Control…"):
|
|
391
|
+
try:
|
|
392
|
+
image_url = upload_kie_file(image_path, card, upload_path="thunderbolt/motion-control")
|
|
393
|
+
video_url = upload_kie_file(video_path, card, upload_path="thunderbolt/motion-control")
|
|
394
|
+
output, task_id = generate_motion_control_video(settings, card, image_url=image_url, video_url=video_url, prompt=prompt)
|
|
395
|
+
metadata = {"workflow": "motion_control", "input_video_path": str(video_path), "reference_image_path": str(image_path), "kie_input_urls_temporary": True}
|
|
396
|
+
repository.update_content(record["id"], {"state": "completed", "artifact_path": str(output), "provider_request_id": task_id, "metadata": metadata})
|
|
397
|
+
record_notification("influencer_content_completed", "Motion Control concluído", "O vídeo Motion Control foi gerado e guardado localmente.", metadata={"content_id": record["id"], "workflow": "motion_control", "provider": card.get("provider")}, dedupe_key=f"influencer-workflow:{record['id']}:completed")
|
|
398
|
+
st.success("Motion Control concluído. O vídeo está disponível no histórico local abaixo.")
|
|
399
|
+
except Exception as exc:
|
|
400
|
+
repository.update_content(record["id"], {"state": "failed", "error": str(exc)[:1000]})
|
|
401
|
+
record_notification("influencer_content_failed", "Motion Control falhou", f"A criação Motion Control falhou: {str(exc)[:240]}", metadata={"content_id": record["id"], "workflow": "motion_control", "provider": card.get("provider")}, dedupe_key=f"influencer-workflow:{record['id']}:failed")
|
|
402
|
+
st.error(f"Não foi possível gerar o Motion Control: {exc}")
|
|
403
|
+
except (MediaGenerationError, ValueError, OSError) as exc:
|
|
404
|
+
st.error(str(exc))
|
|
405
|
+
_render_content_history(repository, STANDALONE_CONTENT_INFLUENCER_ID)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def render_ugc_products(settings: dict[str, Any]) -> None:
|
|
409
|
+
"""Render the local product-image to VEO3 workflow without Telegram or social publishing."""
|
|
410
|
+
st.title("UGC Products")
|
|
411
|
+
st.caption("Crie um anúncio UGC a partir de uma imagem de produto e de um roteiro local. O workflow gera dois clips VEO3 de 8 segundos e junta-os com FFmpeg no storage local.")
|
|
412
|
+
repository = _repository(settings)
|
|
413
|
+
if repository is None:
|
|
414
|
+
return
|
|
415
|
+
video_cards = [card for card in _workflow_provider_cards(settings) if str(card.get("model") or "").strip().lower() in {"", "veo3", "veo3_fast", "veo3_lite"}]
|
|
416
|
+
if not video_cards:
|
|
417
|
+
st.warning("Active e configure um cartão KIE AI com modelo VEO3 compatível no pool de vídeo em Configuração API > API Keys > Imagem e Video IA.")
|
|
418
|
+
return
|
|
419
|
+
image_cards, _ = _provider_options(settings, "image")
|
|
420
|
+
image_cards = [card for card in image_cards if str(card.get("provider") or "").strip().lower() == "nano_banana"]
|
|
421
|
+
with st.form("influencer_ugc_products_form"):
|
|
422
|
+
product_upload = st.file_uploader("Imagem do produto", type=["jpg", "jpeg", "png", "webp"], key="ugc_products_image")
|
|
423
|
+
script = st.text_area("Roteiro de vídeo", height=180, placeholder="Escreva a demonstração, falas e acções. Para controlar os dois clips manualmente, separe-os com uma linha contendo ---.", key="ugc_products_script")
|
|
424
|
+
improve_image = st.checkbox("Melhorar a imagem com o provider de imagem activo (opcional)", value=False, key="ugc_products_improve_image")
|
|
425
|
+
image_provider_id = ""
|
|
426
|
+
if improve_image and image_cards:
|
|
427
|
+
image_provider_options = [str(card.get("id") or "") for card in image_cards]
|
|
428
|
+
image_provider_id = st.selectbox("Provider de melhoria da imagem", image_provider_options, format_func=lambda value: _provider_label(next(card for card in image_cards if str(card.get("id")) == value)), key="ugc_products_image_provider")
|
|
429
|
+
elif improve_image:
|
|
430
|
+
st.info("Não existe um provider activo no pool de imagem; será usada a imagem original.")
|
|
431
|
+
card_options = [str(card.get("id") or "") for card in video_cards]
|
|
432
|
+
provider_id = st.selectbox("Provider VEO3", card_options, format_func=lambda value: _provider_label(next(card for card in video_cards if str(card.get("id")) == value)), key="ugc_products_video_provider")
|
|
433
|
+
generate = st.form_submit_button("Gerar UGC Product", type="primary", use_container_width=True)
|
|
434
|
+
if generate:
|
|
435
|
+
if product_upload is None:
|
|
436
|
+
st.error("Seleccione a imagem do produto.")
|
|
437
|
+
return
|
|
438
|
+
card = next(card for card in video_cards if str(card.get("id")) == provider_id)
|
|
439
|
+
try:
|
|
440
|
+
product_path = _store_uploaded_file(product_upload, "ugc-products-inputs")
|
|
441
|
+
image_path = product_path
|
|
442
|
+
if improve_image and image_provider_id:
|
|
443
|
+
image_card = next(card for card in image_cards if str(card.get("id")) == image_provider_id)
|
|
444
|
+
with st.spinner("A melhorar a imagem do produto…"):
|
|
445
|
+
image_path = generate_image_for_card(settings, image_card, "Melhorar a apresentação fotográfica do mesmo produto sem alterar a embalagem, logótipo, cores, forma ou objectos visíveis. Fundo comercial limpo e luz natural.", topic="UGC Product", reference_image=product_path)
|
|
446
|
+
prompts = generate_ugc_segment_prompts(settings, script)
|
|
447
|
+
owner_id = _workflow_owner(repository)
|
|
448
|
+
record = repository.create_content(
|
|
449
|
+
{
|
|
450
|
+
"influencer_id": owner_id,
|
|
451
|
+
"content_type": "video",
|
|
452
|
+
"prompt": script,
|
|
453
|
+
"caption": "",
|
|
454
|
+
"provider": card.get("provider"),
|
|
455
|
+
"model": str(card.get("model") or "veo3_fast"),
|
|
456
|
+
"platform": "",
|
|
457
|
+
"state": "running",
|
|
458
|
+
"metadata": {"workflow": "ugc_products", "product_image_path": str(product_path), "effective_image_path": str(image_path), "segment_prompts": prompts, "telegram": False, "social_publish": False},
|
|
459
|
+
}
|
|
460
|
+
)
|
|
461
|
+
with st.spinner("A enviar a imagem para KIE, a gerar os dois clips VEO3 e a juntá-los localmente…"):
|
|
462
|
+
try:
|
|
463
|
+
image_url = upload_kie_file(image_path, card, upload_path="thunderbolt/ugc-products")
|
|
464
|
+
output, task_ids = generate_ugc_product_video(settings, card, image_url=image_url, prompts=prompts)
|
|
465
|
+
repository.update_content(record["id"], {"state": "completed", "artifact_path": str(output), "provider_request_id": ",".join(task_ids), "metadata": {"workflow": "ugc_products", "product_image_path": str(product_path), "effective_image_path": str(image_path), "segment_prompts": prompts, "task_ids": task_ids, "telegram": False, "social_publish": False}})
|
|
466
|
+
record_notification("influencer_content_completed", "UGC Products concluído", "O vídeo UGC Product foi gerado, unido e guardado localmente.", metadata={"content_id": record["id"], "workflow": "ugc_products", "provider": card.get("provider")}, dedupe_key=f"influencer-workflow:{record['id']}:completed")
|
|
467
|
+
st.success("UGC Product concluído. O vídeo final está disponível no histórico local abaixo.")
|
|
468
|
+
with st.expander("Prompts dos dois segmentos", expanded=False):
|
|
469
|
+
for index, prompt_item in enumerate(prompts, start=1):
|
|
470
|
+
st.markdown(f"**Segmento {index}**")
|
|
471
|
+
st.code(prompt_item)
|
|
472
|
+
except Exception as exc:
|
|
473
|
+
repository.update_content(record["id"], {"state": "failed", "error": str(exc)[:1000]})
|
|
474
|
+
record_notification("influencer_content_failed", "UGC Products falhou", f"A criação UGC Products falhou: {str(exc)[:240]}", metadata={"content_id": record["id"], "workflow": "ugc_products", "provider": card.get("provider")}, dedupe_key=f"influencer-workflow:{record['id']}:failed")
|
|
475
|
+
st.error(f"Não foi possível gerar o UGC Product: {exc}")
|
|
476
|
+
except (MediaGenerationError, ValueError, OSError) as exc:
|
|
477
|
+
st.error(str(exc))
|
|
478
|
+
_render_content_history(repository, STANDALONE_CONTENT_INFLUENCER_ID)
|
|
479
|
+
|
|
480
|
+
|
|
304
481
|
def render_ai_influencer_content(settings: dict[str, Any]) -> None:
|
|
305
482
|
st.title("Geração de Conteúdo IA")
|
|
306
483
|
st.caption("Gere conteúdos para redes sociais a partir de um personagem e dos seus assets. A publicação permanece separada e exige uma acção explícita.")
|
|
@@ -308,11 +485,11 @@ def render_ai_influencer_content(settings: dict[str, Any]) -> None:
|
|
|
308
485
|
if repository is None:
|
|
309
486
|
return
|
|
310
487
|
try:
|
|
311
|
-
influencers = repository.list_influencers()
|
|
488
|
+
influencers = [item for item in repository.list_influencers() if str(item.get("id") or "") != STANDALONE_CONTENT_INFLUENCER_ID]
|
|
312
489
|
except Exception:
|
|
313
490
|
st.error("Não foi possível consultar os personagens.")
|
|
314
491
|
return
|
|
315
|
-
images_tab, videos_tab
|
|
492
|
+
images_tab, videos_tab = st.tabs(["Imagens", "Vídeos"])
|
|
316
493
|
|
|
317
494
|
with images_tab:
|
|
318
495
|
st.subheader("Gerar imagem para redes sociais")
|
|
@@ -415,13 +592,11 @@ def render_ai_influencer_content(settings: dict[str, Any]) -> None:
|
|
|
415
592
|
selected_id = str(st.session_state.get("content_video_influencer") or options[0])
|
|
416
593
|
_render_content_history(repository, selected_id)
|
|
417
594
|
|
|
418
|
-
with motion_tab:
|
|
419
|
-
st.subheader("Motion Control")
|
|
420
|
-
st.info("A subaba Motion Control foi preparada para a próxima evolução. Nenhuma operação é executada nesta versão.")
|
|
421
|
-
|
|
422
595
|
|
|
423
596
|
__all__ = [
|
|
424
597
|
"render_ai_influencer_characters",
|
|
425
598
|
"render_ai_influencer_content",
|
|
599
|
+
"render_motion_control",
|
|
600
|
+
"render_ugc_products",
|
|
426
601
|
"render_ai_influencers_api_status",
|
|
427
602
|
]
|
package/app/main.py
CHANGED
|
@@ -31,7 +31,7 @@ from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_a
|
|
|
31
31
|
from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
|
|
32
32
|
from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset
|
|
33
33
|
from app.modules.niche_finder.summarizer import summarize_items
|
|
34
|
-
from app.influencers_ui import render_ai_influencer_characters, render_ai_influencer_content, render_ai_influencers_api_status
|
|
34
|
+
from app.influencers_ui import render_ai_influencer_characters, render_ai_influencer_content, render_ai_influencers_api_status, render_motion_control, render_ugc_products
|
|
35
35
|
from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
|
|
36
36
|
from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
|
|
37
37
|
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
|
|
@@ -6397,6 +6397,7 @@ def main():
|
|
|
6397
6397
|
models_ai_items = [
|
|
6398
6398
|
("Personagens", ":material/person:", "Personagens"),
|
|
6399
6399
|
("Geração de Conteúdo IA", ":material/auto_awesome:", "Geração de Conteúdo IA"),
|
|
6400
|
+
("Motion Control", ":material/motion_photos_on:", "Motion Control"),
|
|
6400
6401
|
("UGC Products", ":material/shopping_bag:", "UGC Products"),
|
|
6401
6402
|
("Redes Sociais", ":material/share:", "Redes Sociais"),
|
|
6402
6403
|
]
|
|
@@ -6538,7 +6539,8 @@ def main():
|
|
|
6538
6539
|
language_normalizer=normalize_video_language,
|
|
6539
6540
|
),
|
|
6540
6541
|
"Geração de Conteúdo IA": lambda: render_ai_influencer_content(read_json("settings.json", {})),
|
|
6541
|
-
"
|
|
6542
|
+
"Motion Control": lambda: render_motion_control(read_json("settings.json", {})),
|
|
6543
|
+
"UGC Products": lambda: render_ugc_products(read_json("settings.json", {})),
|
|
6542
6544
|
"Redes Sociais": lambda: render_edit_placeholder("Redes Sociais", "Área reservada para a futura funcionalidade de redes sociais."),
|
|
6543
6545
|
"Analista Growth Youtube": lambda: render_edit_placeholder("Analista Growth Youtube", ""),
|
|
6544
6546
|
"Analista Growth Tiktok": lambda: render_edit_placeholder("Analista Growth Tiktok", ""),
|
|
@@ -481,3 +481,42 @@ def generate_creative_package(
|
|
|
481
481
|
"topic_source": "llm",
|
|
482
482
|
"generated_by": "configured_llm",
|
|
483
483
|
}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
UGC_SEGMENT_SYSTEM_PROMPT = (
|
|
487
|
+
"És um realizador de anúncios UGC de produto. Responde apenas com JSON válido no formato "
|
|
488
|
+
"{\"segments\":[{\"prompt\":\"...\"},{\"prompt\":\"...\"}]}. "
|
|
489
|
+
"Cria exactamente dois prompts para clips consecutivos de 8 segundos cada. "
|
|
490
|
+
"Preserva exactamente o produto, embalagem, cores, logótipo e objectos visíveis na imagem; não inventes, removas ou alteres características. "
|
|
491
|
+
"Descreve apenas acções fisicamente possíveis, continuidade visual entre segmentos, diálogo natural quando solicitado e nenhuma legenda, texto incorporado, watermark ou interface."
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _ugc_fallback_prompts(instructions: str) -> list[str]:
|
|
496
|
+
base = str(instructions or "Apresentar o produto de forma clara, natural e convincente.").strip()
|
|
497
|
+
return [
|
|
498
|
+
f"Clip UGC 1 de 8 segundos. {base} Mostrar o produto exactamente como aparece na imagem, com enquadramento estável, luz natural e uma demonstração inicial fisicamente possível. Sem texto no vídeo, sem legendas e sem watermark.",
|
|
499
|
+
f"Clip UGC 2 de 8 segundos, continuidade directa do clip anterior. {base} Completar a demonstração com uma reacção humana natural e uma apresentação final do produto, sem alterar os objectos visíveis. Sem texto no vídeo, sem legendas e sem watermark.",
|
|
500
|
+
]
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def generate_ugc_segment_prompts(settings: dict[str, Any], instructions: str) -> list[str]:
|
|
504
|
+
"""Return exactly two safe VEO prompts, accepting an explicit `---` split without LLM."""
|
|
505
|
+
clean = str(instructions or "").strip()
|
|
506
|
+
explicit = [part.strip() for part in re.split(r"\n\s*(?:---|===|SEGMENTO\s*2)\s*\n", clean, flags=re.IGNORECASE) if part.strip()]
|
|
507
|
+
if len(explicit) == 2:
|
|
508
|
+
return [
|
|
509
|
+
f"Clip UGC 1 de 8 segundos. {explicit[0]} Preserve a imagem do produto. Sem texto, legendas ou watermark.",
|
|
510
|
+
f"Clip UGC 2 de 8 segundos, em continuidade. {explicit[1]} Preserve a imagem do produto. Sem texto, legendas ou watermark.",
|
|
511
|
+
]
|
|
512
|
+
try:
|
|
513
|
+
result = _chat_json(settings, UGC_SEGMENT_SYSTEM_PROMPT, clean or "Cria uma demonstração UGC curta e natural do produto.")
|
|
514
|
+
raw_segments = result.get("segments")
|
|
515
|
+
if isinstance(raw_segments, list):
|
|
516
|
+
prompts = [str(item.get("prompt") or "").strip() for item in raw_segments if isinstance(item, dict)]
|
|
517
|
+
prompts = [item for item in prompts if item]
|
|
518
|
+
if len(prompts) >= 2:
|
|
519
|
+
return [f"{item} Sem texto incorporado, sem legendas e sem watermark." for item in prompts[:2]]
|
|
520
|
+
except CreativeGenerationError:
|
|
521
|
+
pass
|
|
522
|
+
return _ugc_fallback_prompts(clean)
|
package/hermes_ui/influencers.py
CHANGED
|
@@ -20,6 +20,7 @@ from typing import Any, Iterable, Mapping
|
|
|
20
20
|
from .storage import ROOT, STORAGE
|
|
21
21
|
|
|
22
22
|
BACKEND_OPTIONS = ("Supabase", "SQLite")
|
|
23
|
+
STANDALONE_CONTENT_INFLUENCER_ID = "__standalone_ai_content__"
|
|
23
24
|
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
|
24
25
|
DOCUMENT_EXTENSIONS = {".md", ".json"}
|
|
25
26
|
ASSET_EXTENSIONS = IMAGE_EXTENSIONS | DOCUMENT_EXTENSIONS
|
|
@@ -516,6 +517,22 @@ class SupabaseInfluencerRepository:
|
|
|
516
517
|
return self._data(response)
|
|
517
518
|
|
|
518
519
|
|
|
520
|
+
def ensure_standalone_content_owner(repository: Any) -> str:
|
|
521
|
+
"""Return a hidden owner row for AI content that has no character dependency."""
|
|
522
|
+
existing = repository.get_influencer(STANDALONE_CONTENT_INFLUENCER_ID)
|
|
523
|
+
if existing:
|
|
524
|
+
return STANDALONE_CONTENT_INFLUENCER_ID
|
|
525
|
+
repository.create_influencer(
|
|
526
|
+
{
|
|
527
|
+
"id": STANDALONE_CONTENT_INFLUENCER_ID,
|
|
528
|
+
"name": "__Thunderbolt AI content__",
|
|
529
|
+
"bio": "Internal owner for standalone Motion Control and UGC Products content.",
|
|
530
|
+
"language": "",
|
|
531
|
+
}
|
|
532
|
+
)
|
|
533
|
+
return STANDALONE_CONTENT_INFLUENCER_ID
|
|
534
|
+
|
|
535
|
+
|
|
519
536
|
def sqlite_path_from_settings(settings: Mapping[str, Any]) -> Path:
|
|
520
537
|
raw = str(settings.get("influencer_sqlite_path") or "storage/state/ai_influencers.db").strip()
|
|
521
538
|
return _resolve_sqlite_path(raw)
|
|
@@ -567,12 +584,14 @@ __all__ = [
|
|
|
567
584
|
"DOCUMENT_EXTENSIONS",
|
|
568
585
|
"IMAGE_EXTENSIONS",
|
|
569
586
|
"InfluencerBackendError",
|
|
587
|
+
"STANDALONE_CONTENT_INFLUENCER_ID",
|
|
570
588
|
"SQLiteInfluencerRepository",
|
|
571
589
|
"SupabaseInfluencerRepository",
|
|
572
590
|
"SQLITE_SCHEMA",
|
|
573
591
|
"backend_name",
|
|
574
592
|
"backend_status",
|
|
575
593
|
"get_repository",
|
|
594
|
+
"ensure_standalone_content_owner",
|
|
576
595
|
"parse_document",
|
|
577
596
|
"sqlite_path_from_settings",
|
|
578
597
|
"test_backend",
|
|
@@ -9,6 +9,12 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import base64
|
|
11
11
|
import binascii
|
|
12
|
+
import json
|
|
13
|
+
import mimetypes
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import tempfile
|
|
12
18
|
import time
|
|
13
19
|
from pathlib import Path
|
|
14
20
|
from typing import Any, Mapping
|
|
@@ -544,3 +550,332 @@ def generate_video_from_pool(
|
|
|
544
550
|
if not _is_retryable_media_error(exc):
|
|
545
551
|
raise
|
|
546
552
|
raise MediaGenerationError("Todos os providers do pool de vídeo falharam: " + " | ".join(errors))
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
KIE_FILE_UPLOAD_ENDPOINT = "https://kieai.redpandaai.co/api/file-stream-upload"
|
|
556
|
+
KIE_MOTION_MODEL = "kling-2.6/motion-control"
|
|
557
|
+
KIE_MOTION_IMAGE_MAX_BYTES = 10 * 1024 * 1024
|
|
558
|
+
KIE_MOTION_VIDEO_MAX_BYTES = 100 * 1024 * 1024
|
|
559
|
+
KIE_MOTION_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png"}
|
|
560
|
+
KIE_MOTION_VIDEO_EXTENSIONS = {".mp4", ".mov"}
|
|
561
|
+
KIE_VEO_MODELS = {"veo3", "veo3_fast", "veo3_lite"}
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _require_kie_card(card: Mapping[str, Any]) -> dict[str, Any]:
|
|
565
|
+
current = dict(card)
|
|
566
|
+
if str(current.get("provider") or "").strip().lower() != "kie_ai":
|
|
567
|
+
raise MediaGenerationError("Este workflow requer um cartão KIE AI activo no pool de vídeo.")
|
|
568
|
+
if not _api_key(current):
|
|
569
|
+
raise MediaGenerationError("Configure a API key do KIE AI em Configuração API > API Keys > Imagem e Video IA.")
|
|
570
|
+
return current
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _kie_json_error(payload: Any, *, fallback: str) -> ProviderCallError | None:
|
|
574
|
+
if not isinstance(payload, Mapping):
|
|
575
|
+
return ProviderCallError(fallback, category="payload", retryable=False)
|
|
576
|
+
try:
|
|
577
|
+
code = int(payload.get("code", 200))
|
|
578
|
+
except (TypeError, ValueError):
|
|
579
|
+
code = 200
|
|
580
|
+
if code == 200:
|
|
581
|
+
return None
|
|
582
|
+
category = "quota" if code in {402, 429, 433} else "credential" if code == 401 else "endpoint_or_model" if code in {404, 455, 505} else "transient" if code >= 500 else "payload"
|
|
583
|
+
return ProviderCallError(str(payload.get("msg") or fallback)[:500], status_code=code, category=category, retryable=category in {"quota", "transient"})
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def validate_motion_control_file(path: str | Path, *, kind: str) -> dict[str, Any]:
|
|
587
|
+
"""Validate KIE Motion Control local input limits before uploading it."""
|
|
588
|
+
candidate = Path(path).expanduser()
|
|
589
|
+
if not candidate.is_file():
|
|
590
|
+
raise MediaGenerationError(f"O ficheiro de {kind} não está disponível.")
|
|
591
|
+
suffix = candidate.suffix.lower()
|
|
592
|
+
if kind == "imagem":
|
|
593
|
+
allowed, maximum = KIE_MOTION_IMAGE_EXTENSIONS, KIE_MOTION_IMAGE_MAX_BYTES
|
|
594
|
+
elif kind == "vídeo":
|
|
595
|
+
allowed, maximum = KIE_MOTION_VIDEO_EXTENSIONS, KIE_MOTION_VIDEO_MAX_BYTES
|
|
596
|
+
else:
|
|
597
|
+
raise MediaGenerationError("Tipo de input Motion Control inválido.")
|
|
598
|
+
if suffix not in allowed:
|
|
599
|
+
raise MediaGenerationError(f"O {kind} Motion Control deve estar em {', '.join(sorted(allowed))}.")
|
|
600
|
+
size = candidate.stat().st_size
|
|
601
|
+
if size <= 0:
|
|
602
|
+
raise MediaGenerationError(f"O ficheiro de {kind} está vazio.")
|
|
603
|
+
if size > maximum:
|
|
604
|
+
raise MediaGenerationError(f"O ficheiro de {kind} excede o limite KIE de {maximum // (1024 * 1024)} MB.")
|
|
605
|
+
duration = None
|
|
606
|
+
if kind == "vídeo":
|
|
607
|
+
ffprobe = shutil.which("ffprobe")
|
|
608
|
+
if not ffprobe:
|
|
609
|
+
raise MediaGenerationError("Não foi possível verificar a duração do vídeo Motion Control: instale FFmpeg/ffprobe e tente novamente.")
|
|
610
|
+
try:
|
|
611
|
+
probe = subprocess.run(
|
|
612
|
+
[ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(candidate)],
|
|
613
|
+
capture_output=True,
|
|
614
|
+
text=True,
|
|
615
|
+
timeout=30,
|
|
616
|
+
check=False,
|
|
617
|
+
)
|
|
618
|
+
duration = float(probe.stdout.strip())
|
|
619
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as exc:
|
|
620
|
+
raise MediaGenerationError("Não foi possível verificar a duração do vídeo Motion Control com ffprobe.") from exc
|
|
621
|
+
if duration < 3 or duration > 30:
|
|
622
|
+
raise MediaGenerationError(f"O vídeo Motion Control deve ter entre 3 e 30 segundos; o ficheiro tem {duration:.1f}s.")
|
|
623
|
+
return {"path": str(candidate), "name": candidate.name, "size_bytes": size, "extension": suffix, "duration_seconds": duration}
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def upload_kie_file(path: str | Path, card: Mapping[str, Any], *, upload_path: str = "thunderbolt/influencers") -> str:
|
|
627
|
+
"""Upload one local file to KIE's temporary public file store using the selected card."""
|
|
628
|
+
current = _require_kie_card(card)
|
|
629
|
+
candidate = Path(path).expanduser()
|
|
630
|
+
if not candidate.is_file():
|
|
631
|
+
raise MediaGenerationError("O ficheiro seleccionado não está disponível para upload KIE.")
|
|
632
|
+
mime = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
|
|
633
|
+
try:
|
|
634
|
+
with candidate.open("rb") as handle:
|
|
635
|
+
response = requests.post(
|
|
636
|
+
KIE_FILE_UPLOAD_ENDPOINT,
|
|
637
|
+
headers={"Authorization": f"Bearer {_api_key(current)}"},
|
|
638
|
+
files={"file": (candidate.name, handle, mime)},
|
|
639
|
+
data={"uploadPath": upload_path, "fileName": candidate.name},
|
|
640
|
+
timeout=300,
|
|
641
|
+
)
|
|
642
|
+
except requests.RequestException as exc:
|
|
643
|
+
raise MediaGenerationError(f"Falha no upload temporário do ficheiro para KIE: {str(exc)[:220]}") from exc
|
|
644
|
+
if response.status_code >= 400:
|
|
645
|
+
raise MediaGenerationError(f"O upload temporário KIE devolveu HTTP {response.status_code}.")
|
|
646
|
+
try:
|
|
647
|
+
payload = response.json()
|
|
648
|
+
except ValueError as exc:
|
|
649
|
+
raise MediaGenerationError("O upload temporário KIE devolveu uma resposta inválida.") from exc
|
|
650
|
+
error = _kie_json_error(payload, fallback="O upload temporário KIE falhou.")
|
|
651
|
+
if error:
|
|
652
|
+
raise MediaGenerationError(str(error))
|
|
653
|
+
data = payload.get("data") if isinstance(payload, Mapping) else None
|
|
654
|
+
url = str((data or {}).get("downloadUrl") or (data or {}).get("fileUrl") or payload.get("downloadUrl") or payload.get("fileUrl") or "").strip() if isinstance(payload, Mapping) else ""
|
|
655
|
+
if not url.startswith(("http://", "https://")):
|
|
656
|
+
raise MediaGenerationError("O upload KIE terminou sem devolver um URL público temporário.")
|
|
657
|
+
return url
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _download_video_url(url: str, destination: Path, *, card: Mapping[str, Any] | None = None) -> Path:
|
|
661
|
+
try:
|
|
662
|
+
response = requests.get(url, headers=_headers(card or {}) if card else {}, timeout=300)
|
|
663
|
+
response.raise_for_status()
|
|
664
|
+
except requests.RequestException as exc:
|
|
665
|
+
raise MediaGenerationError(f"Não foi possível descarregar o vídeo gerado: {str(exc)[:220]}") from exc
|
|
666
|
+
if not response.content:
|
|
667
|
+
raise MediaGenerationError("O provider devolveu um vídeo vazio.")
|
|
668
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
669
|
+
destination.write_bytes(response.content)
|
|
670
|
+
return destination
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _kie_result_urls(value: Any) -> list[str]:
|
|
674
|
+
if isinstance(value, str):
|
|
675
|
+
raw = value.strip()
|
|
676
|
+
try:
|
|
677
|
+
value = json.loads(raw)
|
|
678
|
+
except json.JSONDecodeError:
|
|
679
|
+
value = [raw] if raw.startswith(("http://", "https://")) else []
|
|
680
|
+
if isinstance(value, Mapping):
|
|
681
|
+
value = value.get("resultUrls") or value.get("result_urls") or value.get("urls") or []
|
|
682
|
+
if not isinstance(value, list):
|
|
683
|
+
return []
|
|
684
|
+
return [str(item).strip() for item in value if str(item).strip().startswith(("http://", "https://"))]
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _poll_kie_task(card: Mapping[str, Any], task_id: str, *, endpoint: str, attempts: int = 40, interval_seconds: float = 5.0, veo: bool = False) -> list[str]:
|
|
688
|
+
base = _base_url(card) or "https://api.kie.ai/api/v1"
|
|
689
|
+
url = f"{base}{endpoint}"
|
|
690
|
+
for index in range(max(1, attempts)):
|
|
691
|
+
try:
|
|
692
|
+
response = requests.get(url, headers=_headers(card), params={"taskId": task_id}, timeout=60)
|
|
693
|
+
if response.status_code >= 400:
|
|
694
|
+
category = "quota" if response.status_code == 429 else "transient" if response.status_code >= 500 else "endpoint_or_model"
|
|
695
|
+
raise ProviderCallError(f"Consulta KIE devolveu HTTP {response.status_code}.", status_code=response.status_code, category=category, retryable=category in {"quota", "transient"})
|
|
696
|
+
payload = response.json()
|
|
697
|
+
except requests.RequestException as exc:
|
|
698
|
+
raise ProviderCallError(f"Falha ao consultar a tarefa KIE: {str(exc)[:220]}", category="transient", retryable=True) from exc
|
|
699
|
+
error = _kie_json_error(payload, fallback="A consulta KIE devolveu um erro.")
|
|
700
|
+
if error:
|
|
701
|
+
raise error
|
|
702
|
+
data = payload.get("data") if isinstance(payload, Mapping) and isinstance(payload.get("data"), Mapping) else {}
|
|
703
|
+
if veo:
|
|
704
|
+
flag = data.get("successFlag")
|
|
705
|
+
if str(flag) in {"2", "3"}:
|
|
706
|
+
raise ProviderCallError(str(data.get("errorMessage") or payload.get("msg") or "A tarefa VEO falhou."), category="provider", retryable=False)
|
|
707
|
+
response_data = data.get("response") if isinstance(data.get("response"), Mapping) else {}
|
|
708
|
+
urls = _kie_result_urls(response_data.get("resultUrls") or data.get("resultUrls"))
|
|
709
|
+
if str(flag) == "1" and urls:
|
|
710
|
+
return urls
|
|
711
|
+
else:
|
|
712
|
+
state = str(data.get("state") or payload.get("state") or "").lower()
|
|
713
|
+
if state in {"fail", "failed", "error", "cancelled", "canceled"}:
|
|
714
|
+
raise ProviderCallError(str(data.get("failMsg") or payload.get("msg") or "A tarefa KIE falhou."), category="provider", retryable=False)
|
|
715
|
+
urls = _kie_result_urls(data.get("resultJson"))
|
|
716
|
+
if state == "success" and urls:
|
|
717
|
+
return urls
|
|
718
|
+
if index + 1 < attempts:
|
|
719
|
+
time.sleep(max(0.2, interval_seconds))
|
|
720
|
+
raise ProviderCallError("A tarefa KIE não concluiu dentro do limite de polling local.", category="transient", retryable=True)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def generate_motion_control_video(
|
|
724
|
+
settings: Mapping[str, Any],
|
|
725
|
+
card: Mapping[str, Any],
|
|
726
|
+
*,
|
|
727
|
+
image_url: str,
|
|
728
|
+
video_url: str,
|
|
729
|
+
prompt: str = "",
|
|
730
|
+
output_path: Path | None = None,
|
|
731
|
+
) -> tuple[Path, str]:
|
|
732
|
+
"""Create a Kling 2.6 Motion Control video and download it locally."""
|
|
733
|
+
current = _require_kie_card(card)
|
|
734
|
+
if not image_url.startswith(("http://", "https://")) or not video_url.startswith(("http://", "https://")):
|
|
735
|
+
raise MediaGenerationError("Motion Control requer URLs KIE acessíveis para a imagem e o vídeo enviados.")
|
|
736
|
+
clean_prompt = str(prompt or "").strip()
|
|
737
|
+
if len(clean_prompt) > 2500:
|
|
738
|
+
raise MediaGenerationError("O prompt Motion Control não pode exceder 2500 caracteres.")
|
|
739
|
+
base = _base_url(current) or "https://api.kie.ai/api/v1"
|
|
740
|
+
body: dict[str, Any] = {
|
|
741
|
+
"model": KIE_MOTION_MODEL,
|
|
742
|
+
"input": {
|
|
743
|
+
"prompt": clean_prompt or "Preserve a identidade visual da imagem de referência e aplique os movimentos do vídeo de forma natural, estável e fisicamente plausível.",
|
|
744
|
+
"input_urls": [image_url],
|
|
745
|
+
"video_urls": [video_url],
|
|
746
|
+
"character_orientation": "video",
|
|
747
|
+
"mode": "720p",
|
|
748
|
+
},
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
def request(_: dict[str, Any]) -> Any:
|
|
752
|
+
response = requests.post(f"{base}/jobs/createTask", headers=_headers(current), json=body, timeout=180)
|
|
753
|
+
if response.status_code < 400:
|
|
754
|
+
try:
|
|
755
|
+
payload = response.json()
|
|
756
|
+
except ValueError:
|
|
757
|
+
payload = {}
|
|
758
|
+
error = _kie_json_error(payload, fallback="A criação Motion Control KIE falhou.")
|
|
759
|
+
if error:
|
|
760
|
+
raise error
|
|
761
|
+
return response
|
|
762
|
+
|
|
763
|
+
try:
|
|
764
|
+
routed = route_json_request(settings, pool=POOL_VIDEO, cards=[current], request=request)
|
|
765
|
+
data = routed.payload.get("data") if isinstance(routed.payload.get("data"), Mapping) else {}
|
|
766
|
+
task_id = str(data.get("taskId") or routed.payload.get("taskId") or "").strip()
|
|
767
|
+
if not task_id:
|
|
768
|
+
raise MediaGenerationError("KIE aceitou Motion Control mas não devolveu taskId.")
|
|
769
|
+
urls = _poll_kie_task(routed.card, task_id, endpoint="/jobs/recordInfo", veo=False)
|
|
770
|
+
except (ProviderRoutingError, ProviderCallError) as exc:
|
|
771
|
+
raise MediaGenerationError(str(exc)) from exc
|
|
772
|
+
destination = output_path or (STORAGE / "influencer_workflows" / f"motion-control-{task_id}.mp4")
|
|
773
|
+
return _download_video_url(urls[0], destination, card=routed.card), task_id
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def generate_ugc_segment(
|
|
777
|
+
settings: Mapping[str, Any],
|
|
778
|
+
card: Mapping[str, Any],
|
|
779
|
+
*,
|
|
780
|
+
image_url: str,
|
|
781
|
+
prompt: str,
|
|
782
|
+
output_path: Path,
|
|
783
|
+
duration: int = 8,
|
|
784
|
+
) -> tuple[Path, str]:
|
|
785
|
+
"""Create one VEO3.1 image-to-video segment through the official KIE endpoint."""
|
|
786
|
+
current = _require_kie_card(card)
|
|
787
|
+
if not image_url.startswith(("http://", "https://")):
|
|
788
|
+
raise MediaGenerationError("UGC Products requer um URL KIE acessível para a imagem do produto.")
|
|
789
|
+
model = _model(current).lower() or "veo3_fast"
|
|
790
|
+
if model not in KIE_VEO_MODELS:
|
|
791
|
+
model = "veo3_fast"
|
|
792
|
+
if duration not in {4, 6, 8}:
|
|
793
|
+
raise MediaGenerationError("A duração de um segmento VEO3 deve ser 4, 6 ou 8 segundos.")
|
|
794
|
+
base = _base_url(current) or "https://api.kie.ai/api/v1"
|
|
795
|
+
body = {
|
|
796
|
+
"prompt": str(prompt or "").strip(),
|
|
797
|
+
"imageUrls": [image_url],
|
|
798
|
+
"model": model,
|
|
799
|
+
"aspect_ratio": "16:9",
|
|
800
|
+
"resolution": "720p",
|
|
801
|
+
"duration": duration,
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
def request(_: dict[str, Any]) -> Any:
|
|
805
|
+
response = requests.post(f"{base}/veo/generate", headers=_headers(current), json=body, timeout=180)
|
|
806
|
+
if response.status_code < 400:
|
|
807
|
+
try:
|
|
808
|
+
payload = response.json()
|
|
809
|
+
except ValueError:
|
|
810
|
+
payload = {}
|
|
811
|
+
error = _kie_json_error(payload, fallback="A criação de segmento VEO KIE falhou.")
|
|
812
|
+
if error:
|
|
813
|
+
raise error
|
|
814
|
+
return response
|
|
815
|
+
|
|
816
|
+
try:
|
|
817
|
+
routed = route_json_request(settings, pool=POOL_VIDEO, cards=[current], request=request)
|
|
818
|
+
data = routed.payload.get("data") if isinstance(routed.payload.get("data"), Mapping) else {}
|
|
819
|
+
task_id = str(data.get("taskId") or routed.payload.get("taskId") or "").strip()
|
|
820
|
+
if not task_id:
|
|
821
|
+
raise MediaGenerationError("KIE aceitou VEO3 mas não devolveu taskId.")
|
|
822
|
+
urls = _poll_kie_task(routed.card, task_id, endpoint="/veo/record-info", veo=True)
|
|
823
|
+
except (ProviderRoutingError, ProviderCallError) as exc:
|
|
824
|
+
raise MediaGenerationError(str(exc)) from exc
|
|
825
|
+
return _download_video_url(urls[0], output_path, card=routed.card), task_id
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def concatenate_video_files(paths: list[Path], output_path: Path) -> Path:
|
|
829
|
+
"""Join generated clips locally with FFmpeg, without uploading the result anywhere."""
|
|
830
|
+
valid = [Path(path) for path in paths if Path(path).is_file()]
|
|
831
|
+
if not valid:
|
|
832
|
+
raise MediaGenerationError("Não existem segmentos locais para concatenar.")
|
|
833
|
+
if len(valid) == 1:
|
|
834
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
835
|
+
if valid[0] != output_path:
|
|
836
|
+
output_path.write_bytes(valid[0].read_bytes())
|
|
837
|
+
return output_path
|
|
838
|
+
try:
|
|
839
|
+
from .metadata_cleaner import _resolve_ffmpeg
|
|
840
|
+
|
|
841
|
+
ffmpeg = _resolve_ffmpeg()
|
|
842
|
+
except Exception as exc:
|
|
843
|
+
raise MediaGenerationError(str(exc)) from exc
|
|
844
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
845
|
+
descriptor, manifest_name = tempfile.mkstemp(prefix="ugc-concat-", suffix=".txt", dir=str(output_path.parent))
|
|
846
|
+
os.close(descriptor)
|
|
847
|
+
manifest = Path(manifest_name)
|
|
848
|
+
try:
|
|
849
|
+
manifest.write_text("\n".join(f"file '{str(path).replace(chr(39), chr(39) + chr(92) + chr(39) + chr(39))}'" for path in valid) + "\n", encoding="utf-8")
|
|
850
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
851
|
+
command = [ffmpeg, "-y", "-f", "concat", "-safe", "0", "-i", str(manifest), "-c", "copy", str(output_path)]
|
|
852
|
+
completed = subprocess.run(command, capture_output=True, text=True, timeout=600, check=False)
|
|
853
|
+
if completed.returncode != 0:
|
|
854
|
+
raise MediaGenerationError(f"FFmpeg não conseguiu juntar os segmentos UGC: {completed.stderr[-500:]}")
|
|
855
|
+
except OSError as exc:
|
|
856
|
+
raise MediaGenerationError(f"Não foi possível executar FFmpeg para juntar os segmentos UGC: {exc}") from exc
|
|
857
|
+
finally:
|
|
858
|
+
manifest.unlink(missing_ok=True)
|
|
859
|
+
return output_path
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def generate_ugc_product_video(
|
|
863
|
+
settings: Mapping[str, Any],
|
|
864
|
+
card: Mapping[str, Any],
|
|
865
|
+
*,
|
|
866
|
+
image_url: str,
|
|
867
|
+
prompts: list[str],
|
|
868
|
+
output_path: Path | None = None,
|
|
869
|
+
) -> tuple[Path, list[str]]:
|
|
870
|
+
"""Generate two 8-second KIE VEO3 clips and concatenate them locally."""
|
|
871
|
+
clean_prompts = [str(item or "").strip() for item in prompts if str(item or "").strip()]
|
|
872
|
+
if len(clean_prompts) != 2:
|
|
873
|
+
raise MediaGenerationError("UGC Products requer exactamente dois prompts de segmento.")
|
|
874
|
+
ensure_storage()
|
|
875
|
+
destination = output_path or (STORAGE / "influencer_workflows" / f"ugc-products-{abs(hash((image_url, *clean_prompts))) & 0xffffffffffffffff:x}.mp4")
|
|
876
|
+
segment_paths = [destination.with_name(f"{destination.stem}-segment-{index + 1}.mp4") for index in range(2)]
|
|
877
|
+
task_ids: list[str] = []
|
|
878
|
+
for prompt, segment_path in zip(clean_prompts, segment_paths):
|
|
879
|
+
_, task_id = generate_ugc_segment(settings, card, image_url=image_url, prompt=prompt, output_path=segment_path, duration=8)
|
|
880
|
+
task_ids.append(task_id)
|
|
881
|
+
return concatenate_video_files(segment_paths, destination), task_ids
|
package/package.json
CHANGED