@danhachuel/thunderbolt 0.3.52 → 0.3.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.3.52
5
+ > **Versão deste manual:** 0.3.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)
@@ -27,6 +27,20 @@ A aplicação não instala drivers de GPU, Docker, modelos Whisper ou credenciai
27
27
 
28
28
  O MoneyPrinterTurbo declara Python 3.11 ou superior como requisito e documenta a instalação com `uv` ou com `venv + pip`. A aplicação segue o mesmo princípio e adiciona um instalador assistido próprio.[1]
29
29
 
30
+ ## AI Influencers — configuração e utilização
31
+
32
+ A área **AI Influencers > Personagens** permite criar personagens com nome, biografia, idioma, Instagram Business ID opcional, várias imagens de referência e ficheiros `.md`/`.json`. No uploader, seleccione vários ficheiros de uma vez; cada imagem pode ser pré-visualizada e cada documento é validado como UTF-8, Markdown ou JSON válido. Os assets são deduplicados por SHA-256 e não são gravados como base64 dentro das tabelas.
33
+
34
+ O backend local predefinido é **SQLite**, pelo que Personagens e Geração de Conteúdo IA podem ser usados sem credenciais externas. Abra **Configurações > Configuração API > API Keys > Banco de Dados Influencers** para confirmar **SQLite** ou seleccionar **Supabase**. Se seleccionar Supabase, preencha **Supabase Project URL**, **Supabase API key** e **Supabase Storage bucket**, clique em **Testar ligação do backend**, aplique primeiro `seed/references/ai_influencers_schema.sql` no SQL Editor do projecto, exponha as quatro tabelas na Data API e configure permissões/RLS adequadas. O bucket deve existir com o nome configurado, por defeito `ai-influencers`.
35
+
36
+ Se pretender trabalhar sem serviço externo, seleccione **SQLite** no mesmo expander e indique o caminho do ficheiro. O padrão é `storage/state/ai_influencers.db`; o SQLite cria o schema e guarda os assets em `storage/influencers/`. Apenas um backend é usado de cada vez. A subaba **Configuração API > AI Influencers** mostra o estado do backend, mas as credenciais são editadas exclusivamente no expander **Banco de Dados Influencers**.
37
+
38
+ Em **AI Influencers > Geração de Conteúdo IA**, utilize as subabas **Imagens**, **Vídeos** e **Motion Control**. Seleccione um personagem, prompt e destinos sociais. A subaba **Imagens** usa o pool de imagem configurado em **Imagem e Video**. A subaba **Vídeos** requer uma imagem inicial e usa o pool de vídeo; escolha o provider/modelo no cartão configurado, sem dependência obrigatória do Veo 3.1. 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
+
40
+ A geração guarda prompt, caption, provider, modelo, plataforma, estado e caminho do artefacto no backend. **Instagram**, **TikTok**, **YouTube Shorts** e **Facebook** são destinos de revisão; não existe publicação automática nesta primeira adaptação. A publicação só deve ser executada depois de configurar e confirmar a integração social correspondente numa acção separada.
41
+
42
+ 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
+
30
44
  ## 2. Pré-requisitos
31
45
 
32
46
  Instale os seguintes componentes antes de executar o instalador:
@@ -100,13 +114,13 @@ Execute:
100
114
  Windows PowerShell ou MobaXterm:
101
115
 
102
116
  ```powershell
103
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.52 install
117
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.54 install
104
118
  ```
105
119
 
106
120
  Linux/macOS:
107
121
 
108
122
  ```bash
109
- npx --yes --prefer-online @danhachuel/thunderbolt@0.3.52 install
123
+ npx --yes --prefer-online @danhachuel/thunderbolt@0.3.54 install
110
124
  ```
111
125
 
112
126
  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.
@@ -114,7 +128,7 @@ A instalação normal é **segura para actualizações**: preserva `storage`, Bl
114
128
  Se quiser apagar absolutamente tudo de forma intencional, use o comando destrutivo separado:
115
129
 
116
130
  ```powershell
117
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.52 install --purge-data
131
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.54 install --purge-data
118
132
  ```
119
133
 
120
134
  O parâmetro `--purge-data` apaga Blueprints, Brandings, configurações, storage e artefactos locais. Não o use numa actualização normal.
@@ -528,7 +542,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
528
542
 
529
543
  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.
530
544
 
531
- 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 --prefer-online @danhachuel/thunderbolt@0.3.52 install`; o instalador detecta e reutiliza o que já estiver válido.
545
+ 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 --prefer-online @danhachuel/thunderbolt@0.3.54 install`; o instalador detecta e reutiliza o que já estiver válido.
532
546
 
533
547
  ### Niche Finder Apify
534
548
 
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, Redes Sociais, Tutorial Meta e Tutorial Supabase; conteúdo interno localizado |
23
+ | AI Influencers | Menu expansível com Personagens, Geração de Conteúdo IA, Motion Control, Redes Sociais, Tutorial Meta e Tutorial Supabase; Personagens aceita múltiplas imagens e documentos `.md`/`.json`, e Geração de Conteúdo IA separa Imagens e Vídeos |
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 |
@@ -28,6 +28,16 @@ A primeira versão implementa a camada UI independente com:
28
28
  | Configurações | **Contas Google**, **Configuração API** e **Notificações**; Notificações contém as subabas **Geral** e **Telegram Gateway**, com envio das mesmas operações locais para um Chat ID; API Keys divididas entre **Serviços e modelos** e **Fontes de materiais**, com várias chaves independentes por fonte, além de contas Google/YouTube por cartão, `INNERTUBE_API_KEY` global para todas as contas, providers, TTS, Nano Banana, Postiz, TikTok e Upload-Post |
29
29
  | Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
30
30
 
31
+ ## AI Influencers — personagens e geração de conteúdo
32
+
33
+ A área **AI Influencers > Personagens** permite guardar um personagem com nome, biografia, idioma, Instagram Business ID opcional, várias imagens de referência e documentos `.md` ou `.json`. Cada asset é validado, sanitizado, deduplicado por SHA-256 e guardado separado do perfil; imagens têm pré-visualização e documentos são mostrados como Markdown ou JSON estruturado.
34
+
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 > API Keys > Banco de Dados Influencers**, pode manter **SQLite** ou seleccionar **Supabase** quando quiser uma base remota; nesse caso, configure o **Supabase Project URL**, a **Supabase API key** e o bucket de Storage, aplique `seed/references/ai_influencers_schema.sql` no SQL Editor e confirme Data API/RLS. SQLite cria localmente `storage/state/ai_influencers.db` e `storage/influencers/`; apenas um backend fica activo de cada vez. O botão **Testar ligação do backend** é read-only e não bloqueia a utilização local.
36
+
37
+ A página **AI Influencers > Geração de Conteúdo IA** contém as subabas **Imagens**, **Vídeos** e **Motion Control**. 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: os destinos são registados para revisão e o envio exige uma acção posterior explícita.
38
+
39
+ 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
+
31
41
  ## Upload-Post — publicação para múltiplas plataformas
32
42
 
33
43
  A página **Upload** contém quatro subabas: **Upload convencional**, **Upload directo**, **Postiz** e **Upload-Post**. A quarta alternativa usa a API oficial do [Upload-Post](https://docs.upload-post.com/) para enviar um vídeo local para uma ou mais plataformas ligadas ao perfil configurado. Seleccione as plataformas na própria subaba, confirme o título e a descrição e clique em **Enviar vídeo pelo Upload-Post**.
@@ -69,9 +79,10 @@ No topo da área principal da aplicação existe o menu nativo de idioma no padr
69
79
 
70
80
  A UI suporta os temas **Dark** e **Light** através do menu nativo de três pontos do Streamlit, no local original do toolbar. Não existe um selector Theme adicional dentro da página. A configuração distribuída em `.streamlit/config.toml` disponibiliza as variantes nomeadas **Dark** e **Light**, e o menu nativo continua responsável por alternar entre os modos, seguindo o padrão do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O CSS próprio do Thunderbolt usa cores semânticas, `currentColor` e `color-mix` para acompanhar o tema activo, sem alterar a posição nem a funcionalidade do toolbar, do botão Deploy e do menu principal.
71
81
 
72
- ## Navegação da UI 0.3.52
82
+ ## Navegação da UI 0.3.54
73
83
 
74
- 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**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, 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**, **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.
84
+ 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**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. A página Geração de Conteúdo IA divide-se em **Imagens**, **Vídeos** e **Motion Control**; a subaba Motion Control permanece vazia nesta release.
85
+ **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.
75
86
 
76
87
  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:
77
88
 
@@ -25,3 +25,7 @@ Thunderbolt depends on [Psy-Q/pushtunes](https://pypi.org/project/pushtunes/) fo
25
25
  ## JewelMusic SDK
26
26
 
27
27
  The JewelMusic upload adapter follows the documented HTTP contract and API examples from [jewelmusic/sdk](https://github.com/jewelmusic/sdk), which is published under the MIT License. The Python package `jewelmusic-sdk` was not available on PyPI at implementation time, so Thunderbolt does not bundle that unavailable package; the adapter uses the documented `POST /v1/tracks/upload` request directly and retains attribution to the upstream SDK repository.
28
+
29
+ ## supabase-py
30
+
31
+ Thunderbolt optionally depends on [supabase-py](https://github.com/supabase/supabase-py) for the AI Influencers Supabase backend. The package is distributed under the MIT License. The SQLite backend uses Python's standard library and does not require a remote service.
@@ -0,0 +1,400 @@
1
+ """Streamlit UI for the AI Influencers domain."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import io
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any, Mapping
10
+
11
+ import streamlit as st
12
+
13
+ from hermes_ui.influencers import (
14
+ BACKEND_OPTIONS,
15
+ InfluencerBackendError,
16
+ backend_name,
17
+ backend_status,
18
+ get_repository,
19
+ test_backend,
20
+ )
21
+ from hermes_ui.media_generation import MediaGenerationError, generate_image_for_card, generate_video_for_card
22
+ from hermes_ui.media_providers import media_cards_for_pool, media_provider_definition
23
+ from hermes_ui.notifications import record_notification
24
+
25
+
26
+ CONTENT_STATES = {
27
+ "queued": "Na fila",
28
+ "running": "Em execução",
29
+ "completed": "Concluído",
30
+ "failed": "Falha",
31
+ "cancelled": "Cancelado",
32
+ "blocked": "Bloqueado",
33
+ }
34
+ PLATFORM_OPTIONS = ["Instagram", "TikTok", "YouTube Shorts", "Facebook"]
35
+
36
+
37
+ def _repository(settings: Mapping[str, Any]):
38
+ try:
39
+ return get_repository(settings)
40
+ except InfluencerBackendError as exc:
41
+ st.warning(str(exc))
42
+ return None
43
+
44
+
45
+ def _influencer_options(items: list[dict[str, Any]]) -> list[str]:
46
+ return [str(item.get("id") or "") for item in items if str(item.get("id") or "").strip()]
47
+
48
+
49
+ def _influencer_label(items: list[dict[str, Any]], value: str) -> str:
50
+ item = next((item for item in items if str(item.get("id") or "") == str(value)), None)
51
+ return str(item.get("name") or value) if item else value
52
+
53
+
54
+ def _asset_label(asset: Mapping[str, Any]) -> str:
55
+ kind = "Imagem" if str(asset.get("asset_type") or "") == "image" else "Documento"
56
+ return f"{kind} · {asset.get('original_name') or 'asset'}"
57
+
58
+
59
+ def _local_asset_path(asset: Mapping[str, Any]) -> Path | None:
60
+ raw = str(asset.get("stored_path") or "").strip()
61
+ path = Path(raw).expanduser() if raw else None
62
+ return path if path and path.is_file() else None
63
+
64
+
65
+ def _image_input(asset: Mapping[str, Any]) -> str:
66
+ """Create a provider-safe URL/data URL for local image-to-video inputs."""
67
+ public_url = str(asset.get("public_url") or "").strip()
68
+ if public_url.startswith(("http://", "https://")):
69
+ return public_url
70
+ path = _local_asset_path(asset)
71
+ if not path:
72
+ return ""
73
+ data = path.read_bytes()
74
+ mime = str(asset.get("mime_type") or "image/jpeg")
75
+ if len(data) > 220 * 1024:
76
+ try:
77
+ from PIL import Image
78
+ image = Image.open(io.BytesIO(data)).convert("RGB")
79
+ for quality in (78, 68, 58, 48):
80
+ buffer = io.BytesIO()
81
+ image.thumbnail((1600, 1600))
82
+ image.save(buffer, format="JPEG", quality=quality, optimize=True)
83
+ if buffer.tell() <= 220 * 1024:
84
+ data = buffer.getvalue()
85
+ mime = "image/jpeg"
86
+ break
87
+ except Exception:
88
+ return ""
89
+ if len(data) > 256 * 1024:
90
+ return ""
91
+ return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}"
92
+
93
+
94
+ def _provider_options(settings: Mapping[str, Any], pool: str) -> tuple[list[dict[str, Any]], list[str]]:
95
+ cards = media_cards_for_pool(settings, pool)
96
+ return cards, [str(card.get("id") or "") for card in cards]
97
+
98
+
99
+ def _provider_label(card: Mapping[str, Any]) -> str:
100
+ definition = media_provider_definition(card.get("provider"))
101
+ model = str(card.get("model") or "modelo não configurado").strip()
102
+ return f"{definition.label} · {model}"
103
+
104
+
105
+ def render_ai_influencers_api_status(settings: dict[str, Any]) -> None:
106
+ """Read-only status tab; credentials are edited in the API Keys expander."""
107
+ st.subheader("AI Influencers")
108
+ st.caption("Estado do backend usado por Personagens e Geração de Conteúdo IA. As credenciais são editadas apenas em API Keys > Banco de Dados Influencers.")
109
+ status = backend_status(settings)
110
+ cols = st.columns(3)
111
+ with cols[0]:
112
+ st.metric("Backend activo", status["backend"])
113
+ with cols[1]:
114
+ st.metric("Configurado", "Sim" if status["configured"] else "Não")
115
+ with cols[2]:
116
+ st.metric("Schema", "Verificar" if status["backend"] == "Supabase" else "Automático")
117
+ if status["configured"]:
118
+ st.info(f"{status['message']} Destino: `{status['target']}`")
119
+ else:
120
+ st.warning(status["message"])
121
+ st.markdown("A migração SQL idempotente está disponível em `seed/references/ai_influencers_schema.sql`. No Supabase, aplique-a no SQL Editor e confirme as políticas RLS e o bucket configurado.")
122
+ if st.button("Testar backend AI Influencers", key="influencers_api_status_test"):
123
+ result = test_backend(settings)
124
+ if result.get("ok"):
125
+ st.success(result.get("message") or "Backend disponível.")
126
+ else:
127
+ st.error(result.get("message") or "O backend não está disponível.")
128
+
129
+
130
+ def render_ai_influencer_characters(settings: dict[str, Any]) -> None:
131
+ st.title("Personagens")
132
+ st.caption("Crie personagens virtuais com várias imagens de referência e documentos Markdown/JSON. Os assets ficam associados ao personagem e não são enviados para IA sem uma acção de geração.")
133
+ repository = _repository(settings)
134
+ if repository is None:
135
+ return
136
+
137
+ with st.form("influencer_create_form", clear_on_submit=False):
138
+ st.subheader("Novo personagem")
139
+ cols = st.columns(3)
140
+ with cols[0]:
141
+ name = st.text_input("Nome do personagem", key="influencer_new_name")
142
+ with cols[1]:
143
+ language = st.text_input("Idioma", placeholder="pt-BR, en-US…", key="influencer_new_language")
144
+ with cols[2]:
145
+ instagram_id = st.text_input("Instagram Business ID (opcional)", key="influencer_new_instagram_id")
146
+ bio = st.text_area("Biografia e instruções", height=130, key="influencer_new_bio")
147
+ files = st.file_uploader(
148
+ "Imagens e documentos de referência",
149
+ type=[item.lstrip(".") for item in sorted({".png", ".jpg", ".jpeg", ".webp", ".gif", ".md", ".json"})],
150
+ accept_multiple_files=True,
151
+ key="influencer_new_assets",
152
+ help="Pode seleccionar várias imagens e ficheiros .md/.json no mesmo upload.",
153
+ )
154
+ create = st.form_submit_button("Guardar personagem", type="primary", use_container_width=True)
155
+ if create:
156
+ try:
157
+ record = repository.create_influencer({"name": name, "bio": bio, "language": language, "instagram_business_id": instagram_id})
158
+ saved_assets = 0
159
+ for uploaded in files or []:
160
+ repository.save_asset(record["id"], uploaded.name, uploaded.getvalue())
161
+ saved_assets += 1
162
+ st.session_state["influencer_selected_id"] = record["id"]
163
+ st.success(f"Personagem guardado com {saved_assets} asset(s) de referência.")
164
+ st.rerun()
165
+ except (ValueError, InfluencerBackendError) as exc:
166
+ st.error(str(exc))
167
+ except Exception:
168
+ st.error("Não foi possível guardar o personagem ou os assets seleccionados.")
169
+
170
+ try:
171
+ influencers = repository.list_influencers()
172
+ except Exception:
173
+ st.error("Não foi possível consultar os personagens no backend seleccionado.")
174
+ return
175
+ if not influencers:
176
+ st.info("Ainda não existem personagens. Crie o primeiro acima.")
177
+ return
178
+
179
+ options = _influencer_options(influencers)
180
+ current = str(st.session_state.get("influencer_selected_id") or options[0])
181
+ selected_id = st.selectbox("Personagem seleccionado", options, index=options.index(current) if current in options else 0, format_func=lambda value: _influencer_label(influencers, value), key="influencer_selected_id")
182
+ selected = next(item for item in influencers if str(item.get("id")) == selected_id)
183
+
184
+ with st.expander("Editar perfil", expanded=False):
185
+ with st.form(f"influencer_edit_form_{selected_id}"):
186
+ edit_name = st.text_input("Nome", value=str(selected.get("name") or ""), key=f"influencer_edit_name_{selected_id}")
187
+ edit_language = st.text_input("Idioma", value=str(selected.get("language") or ""), key=f"influencer_edit_language_{selected_id}")
188
+ edit_instagram = st.text_input("Instagram Business ID", value=str(selected.get("instagram_business_id") or ""), key=f"influencer_edit_instagram_{selected_id}")
189
+ edit_bio = st.text_area("Biografia e instruções", value=str(selected.get("bio") or ""), height=130, key=f"influencer_edit_bio_{selected_id}")
190
+ save_edit = st.form_submit_button("Guardar alterações", type="primary")
191
+ if save_edit:
192
+ try:
193
+ repository.update_influencer(selected_id, {"name": edit_name, "language": edit_language, "instagram_business_id": edit_instagram, "bio": edit_bio})
194
+ st.success("Perfil do personagem actualizado.")
195
+ st.rerun()
196
+ except Exception as exc:
197
+ st.error(str(exc))
198
+
199
+ with st.form(f"influencer_append_assets_form_{selected_id}"):
200
+ more_files = st.file_uploader(
201
+ "Adicionar imagens/documentos de referência",
202
+ type=[item.lstrip(".") for item in sorted({".png", ".jpg", ".jpeg", ".webp", ".gif", ".md", ".json"})],
203
+ accept_multiple_files=True,
204
+ key=f"influencer_append_assets_{selected_id}",
205
+ )
206
+ append_assets = st.form_submit_button("Adicionar assets ao personagem", use_container_width=True)
207
+ if append_assets:
208
+ try:
209
+ saved_assets = 0
210
+ for uploaded in more_files or []:
211
+ repository.save_asset(selected_id, uploaded.name, uploaded.getvalue())
212
+ saved_assets += 1
213
+ st.success(f"{saved_assets} asset(s) adicionado(s).")
214
+ st.rerun()
215
+ except Exception as exc:
216
+ st.error(str(exc))
217
+
218
+ try:
219
+ assets = repository.list_assets(selected_id)
220
+ except Exception:
221
+ assets = []
222
+ st.subheader(f"Assets de referência · {selected.get('name') or selected_id}")
223
+ if not assets:
224
+ st.info("Este personagem ainda não tem imagens ou documentos de referência.")
225
+ else:
226
+ cols = st.columns(min(4, max(1, len(assets))))
227
+ for index, asset in enumerate(assets):
228
+ with cols[index % len(cols)]:
229
+ st.caption(_asset_label(asset))
230
+ path = _local_asset_path(asset)
231
+ if str(asset.get("asset_type") or "") == "image" and path:
232
+ st.image(str(path), use_container_width=True)
233
+ elif str(asset.get("asset_type") or "") == "document":
234
+ raw = str(asset.get("document_json") or "")
235
+ try:
236
+ preview = json.loads(raw) if raw else {}
237
+ st.json(preview, expanded=False)
238
+ except (TypeError, json.JSONDecodeError):
239
+ st.code(raw[:1600], language="markdown")
240
+ st.caption(f"{asset.get('size_bytes', 0)} bytes · {asset.get('mime_type') or 'unknown'}")
241
+
242
+
243
+ def _render_content_history(repository: Any, influencer_id: str = "") -> None:
244
+ try:
245
+ records = repository.list_content(influencer_id, limit=50)
246
+ except Exception:
247
+ records = []
248
+ if not records:
249
+ return
250
+ st.subheader("Conteúdos gerados")
251
+ rows = []
252
+ for item in records:
253
+ rows.append({
254
+ "Tipo": str(item.get("content_type") or "").capitalize(),
255
+ "Estado": CONTENT_STATES.get(str(item.get("state") or ""), str(item.get("state") or "—")),
256
+ "Provider": f"{item.get('provider') or '—'} · {item.get('model') or '—'}",
257
+ "Plataforma": item.get("platform") or "—",
258
+ "Artefacto": item.get("artifact_path") or item.get("error") or "—",
259
+ "Criado": item.get("created_at") or "—",
260
+ })
261
+ st.dataframe(rows, use_container_width=True, hide_index=True)
262
+ latest = records[0]
263
+ artifact = Path(str(latest.get("artifact_path") or ""))
264
+ if artifact.is_file() and latest.get("state") == "completed":
265
+ if latest.get("content_type") == "image":
266
+ st.image(str(artifact), caption="Última imagem gerada", use_container_width=True)
267
+ elif latest.get("content_type") == "video":
268
+ st.video(str(artifact))
269
+
270
+
271
+ def render_ai_influencer_content(settings: dict[str, Any]) -> None:
272
+ st.title("Geração de Conteúdo IA")
273
+ 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.")
274
+ repository = _repository(settings)
275
+ if repository is None:
276
+ return
277
+ try:
278
+ influencers = repository.list_influencers()
279
+ except Exception:
280
+ st.error("Não foi possível consultar os personagens.")
281
+ return
282
+ images_tab, videos_tab, motion_tab = st.tabs(["Imagens", "Vídeos", "Motion Control"])
283
+
284
+ with images_tab:
285
+ st.subheader("Gerar imagem para redes sociais")
286
+ if not influencers:
287
+ st.info("Crie um personagem em AI Influencers > Personagens antes de gerar conteúdo.")
288
+ else:
289
+ options = _influencer_options(influencers)
290
+ current = str(st.session_state.get("influencer_selected_id") or options[0])
291
+ cards, provider_options = _provider_options(settings, "image")
292
+ if not cards:
293
+ st.warning("Não existem providers activos no pool de imagem. Configure um provider em Configuração API > API Keys > Imagem e Video.")
294
+ with st.form("influencer_image_content_form"):
295
+ influencer_id = st.selectbox("Personagem", options, index=options.index(current) if current in options else 0, format_func=lambda value: _influencer_label(influencers, value), key="content_image_influencer")
296
+ assets = repository.list_assets(influencer_id)
297
+ image_assets = [item for item in assets if str(item.get("asset_type") or "") == "image"]
298
+ asset_options = [""] + [str(item.get("id") or "") for item in image_assets]
299
+ reference_id = st.selectbox("Imagem de referência (opcional)", asset_options, format_func=lambda value: "Sem imagem base" if not value else next((_asset_label(item) for item in image_assets if str(item.get("id")) == value), value), key="content_image_reference")
300
+ prompt = st.text_area("Prompt da imagem", height=130, placeholder="Descreva a cena, pose, ambiente e estilo do personagem…", key="content_image_prompt")
301
+ caption = st.text_area("Legenda/caption (opcional)", height=90, key="content_image_caption")
302
+ platforms = st.multiselect("Redes sociais de destino", PLATFORM_OPTIONS, default=["Instagram"], key="content_image_platforms")
303
+ provider_id = st.selectbox("Provider / modelo", provider_options, format_func=lambda value: _provider_label(next(card for card in cards if str(card.get("id")) == value)), key="content_image_provider") if cards else ""
304
+ generate = st.form_submit_button("Gerar imagem", type="primary", use_container_width=True)
305
+ if generate:
306
+ if not prompt.strip():
307
+ st.error("Informe o prompt da imagem.")
308
+ elif not platforms:
309
+ st.error("Seleccione pelo menos uma rede social de destino.")
310
+ elif not provider_id:
311
+ st.error("Configure e active um provider de imagem antes de gerar.")
312
+ else:
313
+ card = next(card for card in cards if str(card.get("id")) == provider_id)
314
+ reference = next((item for item in image_assets if str(item.get("id")) == reference_id), None)
315
+ reference_path = _local_asset_path(reference) if reference else None
316
+ record = repository.create_content({"influencer_id": influencer_id, "content_type": "image", "prompt": prompt, "caption": caption, "provider": card.get("provider"), "model": card.get("model"), "platform": ", ".join(platforms), "state": "running", "metadata": {"reference_asset_id": reference_id}})
317
+ with st.spinner("A gerar a imagem…"):
318
+ try:
319
+ output = generate_image_for_card(settings, card, prompt, topic=_influencer_label(influencers, influencer_id), reference_image=reference_path)
320
+ repository.update_content(record["id"], {"state": "completed", "artifact_path": str(output)})
321
+ record_notification("influencer_content_completed", "Conteúdo de Influencer concluído", f"A imagem de {_influencer_label(influencers, influencer_id)} foi gerada e guardada.", metadata={"content_id": record["id"], "content_type": "image", "provider": card.get("provider")}, dedupe_key=f"influencer-content:{record['id']}:completed")
322
+ st.success(f"Imagem gerada por {media_provider_definition(card.get('provider')).label}.")
323
+ except Exception as exc:
324
+ repository.update_content(record["id"], {"state": "failed", "error": str(exc)[:1000]})
325
+ record_notification("influencer_content_failed", "Conteúdo de Influencer falhou", f"A imagem de {_influencer_label(influencers, influencer_id)} falhou. Consulte o histórico para ver o erro.", metadata={"content_id": record["id"], "content_type": "image", "provider": card.get("provider")}, dedupe_key=f"influencer-content:{record['id']}:failed")
326
+ st.error(f"Não foi possível gerar a imagem: {exc}")
327
+ _render_content_history(repository, influencer_id)
328
+ else:
329
+ selected_id = str(st.session_state.get("content_image_influencer") or options[0])
330
+ _render_content_history(repository, selected_id)
331
+
332
+ with videos_tab:
333
+ st.subheader("Gerar vídeo para redes sociais")
334
+ if not influencers:
335
+ st.info("Crie um personagem em AI Influencers > Personagens antes de gerar conteúdo.")
336
+ else:
337
+ options = _influencer_options(influencers)
338
+ current = str(st.session_state.get("influencer_selected_id") or options[0])
339
+ cards, provider_options = _provider_options(settings, "video")
340
+ if not cards:
341
+ st.warning("Não existem providers activos no pool de vídeo. Configure KIE AI, Replicate, FAL AI ou outro provider compatível em Configuração API > API Keys > Imagem e Video.")
342
+ with st.form("influencer_video_content_form"):
343
+ influencer_id = st.selectbox("Personagem", options, index=options.index(current) if current in options else 0, format_func=lambda value: _influencer_label(influencers, value), key="content_video_influencer")
344
+ assets = repository.list_assets(influencer_id)
345
+ image_assets = [item for item in assets if str(item.get("asset_type") or "") == "image"]
346
+ asset_options = [str(item.get("id") or "") for item in image_assets]
347
+ reference_id = st.selectbox("Imagem inicial", asset_options, format_func=lambda value: next((_asset_label(item) for item in image_assets if str(item.get("id")) == value), value), key="content_video_reference") if image_assets else ""
348
+ prompt = st.text_area("Prompt de movimento", height=130, placeholder="Descreva movimento de câmara, expressão, gesto e ambiente…", key="content_video_prompt")
349
+ caption = st.text_area("Legenda/caption (opcional)", height=90, key="content_video_caption")
350
+ platforms = st.multiselect("Redes sociais de destino", PLATFORM_OPTIONS, default=["Instagram", "TikTok"], key="content_video_platforms")
351
+ provider_id = st.selectbox("Provider / modelo de vídeo", provider_options, format_func=lambda value: _provider_label(next(card for card in cards if str(card.get("id")) == value)), key="content_video_provider") if cards else ""
352
+ generate = st.form_submit_button("Gerar vídeo", type="primary", use_container_width=True)
353
+ if generate:
354
+ if not prompt.strip():
355
+ st.error("Informe o prompt de movimento.")
356
+ elif not reference_id:
357
+ st.error("Seleccione uma imagem inicial para a conversão image-to-video.")
358
+ elif not platforms:
359
+ st.error("Seleccione pelo menos uma rede social de destino.")
360
+ elif not provider_id:
361
+ st.error("Configure e active um provider de vídeo antes de gerar.")
362
+ else:
363
+ card = next(card for card in cards if str(card.get("id")) == provider_id)
364
+ reference = next((item for item in image_assets if str(item.get("id")) == reference_id), None)
365
+ image_url = _image_input(reference or {})
366
+ if not image_url:
367
+ st.error("A imagem inicial local deve ter até 256 KB ou ter um URL público no backend para ser enviada ao provider de vídeo.")
368
+ else:
369
+ record = repository.create_content({"influencer_id": influencer_id, "content_type": "video", "prompt": prompt, "caption": caption, "provider": card.get("provider"), "model": card.get("model"), "platform": ", ".join(platforms), "state": "running", "metadata": {"reference_asset_id": reference_id}})
370
+ with st.spinner("A gerar e descarregar o vídeo…"):
371
+ try:
372
+ output = generate_video_for_card(settings, card, prompt, image_url=image_url)
373
+ repository.update_content(record["id"], {"state": "completed", "artifact_path": str(output)})
374
+ record_notification("influencer_content_completed", "Conteúdo de Influencer concluído", f"O vídeo de {_influencer_label(influencers, influencer_id)} foi gerado e guardado.", metadata={"content_id": record["id"], "content_type": "video", "provider": card.get("provider")}, dedupe_key=f"influencer-content:{record['id']}:completed")
375
+ st.success(f"Vídeo gerado por {media_provider_definition(card.get('provider')).label}.")
376
+ except Exception as exc:
377
+ repository.update_content(record["id"], {"state": "failed", "error": str(exc)[:1000]})
378
+ record_notification("influencer_content_failed", "Conteúdo de Influencer falhou", f"O vídeo de {_influencer_label(influencers, influencer_id)} falhou. Consulte o histórico para ver o erro.", metadata={"content_id": record["id"], "content_type": "video", "provider": card.get("provider")}, dedupe_key=f"influencer-content:{record['id']}:failed")
379
+ st.error(f"Não foi possível gerar o vídeo: {exc}")
380
+ _render_content_history(repository, influencer_id)
381
+ else:
382
+ selected_id = str(st.session_state.get("content_video_influencer") or options[0])
383
+ _render_content_history(repository, selected_id)
384
+
385
+ with motion_tab:
386
+ st.subheader("Motion Control")
387
+ st.info("A subaba Motion Control foi preparada para a próxima evolução. Nenhuma operação é executada nesta versão.")
388
+
389
+
390
+ def render_ai_influencer_motion_control() -> None:
391
+ st.title("Motion Control")
392
+ st.info("A área Motion Control está reservada para desenvolvimento futuro e permanece vazia nesta versão.")
393
+
394
+
395
+ __all__ = [
396
+ "render_ai_influencer_characters",
397
+ "render_ai_influencer_content",
398
+ "render_ai_influencer_motion_control",
399
+ "render_ai_influencers_api_status",
400
+ ]