@danhachuel/thunderbolt 0.4.91 → 0.4.93
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/app/main.py +46 -6
- package/hermes_ui/canva_auth.py +95 -0
- package/hermes_ui/canva_client.py +114 -0
- package/hermes_ui/media_generation.py +25 -2
- package/hermes_ui/media_providers.py +17 -1
- package/hermes_ui/thumbnails.py +2 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +3 -2
- package/scripts/install.mjs +1 -1
package/app/main.py
CHANGED
|
@@ -4,12 +4,16 @@ import io
|
|
|
4
4
|
|
|
5
5
|
os.environ["PYTHONIOENCODING"] = "utf-8"
|
|
6
6
|
os.environ["PYTHONUTF8"] = "1"
|
|
7
|
+
os.environ["CLICK_NO_WIN_CONSOLE"] = "1"
|
|
7
8
|
|
|
8
9
|
def _force_utf8_stream(stream: object) -> object:
|
|
10
|
+
if os.name != "nt":
|
|
11
|
+
return stream
|
|
9
12
|
buffer = getattr(stream, "buffer", None)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
if buffer is not None and not getattr(stream, "_thunderbolt_utf8", False):
|
|
14
|
+
wrapped = io.TextIOWrapper(buffer, encoding="utf-8", errors="replace", line_buffering=True)
|
|
15
|
+
setattr(wrapped, "_thunderbolt_utf8", True)
|
|
16
|
+
return wrapped
|
|
13
17
|
return stream
|
|
14
18
|
|
|
15
19
|
_original_stdout, _original_stderr = sys.stdout, sys.stderr
|
|
@@ -102,6 +106,7 @@ from hermes_ui.thumbnails import (
|
|
|
102
106
|
from hermes_ui.draft_video import DRAFT_SETTING_SECTIONS, missing_content_fields, missing_setting_sections, normalise_saved_script, setting_widget_suffixes
|
|
103
107
|
from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_thumbnail_prompt, generate_title_and_keywords, generate_topic_for_channel, generate_video_description, generate_video_keywords, generate_video_update_metadata
|
|
104
108
|
from hermes_ui.media_generation import MediaGenerationError, format_media_generation_error, generate_image_for_card, generate_video_for_card
|
|
109
|
+
from hermes_ui.canva_auth import authorization_url, create_pkce_pair, create_state, exchange_code
|
|
105
110
|
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
|
|
106
111
|
from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
|
|
107
112
|
from integrations.youtube_update import YOUTUBE_UPDATE_VIDEO, YouTubeVideoUpdater
|
|
@@ -7060,7 +7065,7 @@ def _media_card_config_status(card: dict[str, Any]) -> tuple[str, str]:
|
|
|
7060
7065
|
return "missing", "Missing key"
|
|
7061
7066
|
if not str(card.get("base_url") or "").strip():
|
|
7062
7067
|
return "missing", "Missing Base URL"
|
|
7063
|
-
if not str(card.get("model") or "").strip() and card.get("supports_image") and card.get("provider") not in {"cloudflare_workers_ai"}:
|
|
7068
|
+
if not str(card.get("model") or "").strip() and card.get("supports_image") and card.get("provider") not in {"cloudflare_workers_ai", "canva"}:
|
|
7064
7069
|
return "missing", "Missing model"
|
|
7065
7070
|
return "ready", "Configured"
|
|
7066
7071
|
|
|
@@ -7096,6 +7101,26 @@ def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str,
|
|
|
7096
7101
|
cards[index] = card
|
|
7097
7102
|
card_id = str(card["id"])
|
|
7098
7103
|
definition = media_provider_definition(card.get("provider"))
|
|
7104
|
+
if card.get("provider") == "canva":
|
|
7105
|
+
callback_code = str(st.query_params.get("code") or "").strip()
|
|
7106
|
+
callback_state = str(st.query_params.get("state") or "").strip()
|
|
7107
|
+
pending_state = str(st.session_state.get(f"canva_state_{card_id}") or "")
|
|
7108
|
+
if callback_code:
|
|
7109
|
+
if callback_state != pending_state:
|
|
7110
|
+
st.error("Canva OAuth rejeitado: state inválido. Inicie a autorização novamente.")
|
|
7111
|
+
else:
|
|
7112
|
+
try:
|
|
7113
|
+
token = exchange_code(str(card.get("client_id") or ""), str(card.get("client_secret") or ""), callback_code, str(card.get("redirect_uri") or ""), str(st.session_state.get(f"canva_verifier_{card_id}") or ""))
|
|
7114
|
+
card["oauth_token"] = token
|
|
7115
|
+
cards[index] = card
|
|
7116
|
+
_persist_media_cards(settings, cards, str(settings.get(MEDIA_IMAGE_ACTIVE_CARD_KEY) or ""), str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or ""))
|
|
7117
|
+
st.session_state.pop(f"canva_state_{card_id}", None)
|
|
7118
|
+
st.session_state.pop(f"canva_verifier_{card_id}", None)
|
|
7119
|
+
st.query_params.clear()
|
|
7120
|
+
st.success("Canva autorizada com sucesso.")
|
|
7121
|
+
st.rerun()
|
|
7122
|
+
except Exception as exc:
|
|
7123
|
+
st.error(f"Não foi possível autorizar a Canva: {str(exc)[:240]}")
|
|
7099
7124
|
with st.container(border=True):
|
|
7100
7125
|
header_cols = st.columns([3.2, 1.2])
|
|
7101
7126
|
with header_cols[0]:
|
|
@@ -7139,14 +7164,29 @@ def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str,
|
|
|
7139
7164
|
extra_cols = st.columns(len(definition.extra_fields))
|
|
7140
7165
|
for extra_col, field_name in zip(extra_cols, definition.extra_fields):
|
|
7141
7166
|
with extra_col:
|
|
7142
|
-
extra_values[field_name] = st.text_input(field_name.replace("_", " ").title(), value=str(card.get(field_name) or ""), key=f"media_card_{card_id}_{field_name}")
|
|
7167
|
+
extra_values[field_name] = st.text_input(field_name.replace("_", " ").title(), value=str(card.get(field_name) or ""), type="password" if field_name == "client_secret" else "default", key=f"media_card_{card_id}_{field_name}")
|
|
7168
|
+
if definition.code == "canva":
|
|
7169
|
+
supports_image = True
|
|
7170
|
+
supports_video = False
|
|
7171
|
+
st.caption("Canva Connect é usado exclusivamente para thumbnails no Pool de Imagem. Não é provider de vídeo.")
|
|
7172
|
+
redirect_uri = str(card.get("redirect_uri") or "").strip()
|
|
7173
|
+
if not redirect_uri:
|
|
7174
|
+
st.info("Defina o Redirect URI no Developer Portal Canva e repita-o neste card antes de autorizar.")
|
|
7175
|
+
elif str(card.get("client_id") or "").strip() and str(card.get("client_secret") or "").strip():
|
|
7176
|
+
if st.button("Autorizar Canva", key=f"media_card_{card_id}_authorize"):
|
|
7177
|
+
verifier, challenge = create_pkce_pair()
|
|
7178
|
+
state = create_state()
|
|
7179
|
+
st.session_state[f"canva_verifier_{card_id}"] = verifier
|
|
7180
|
+
st.session_state[f"canva_state_{card_id}"] = state
|
|
7181
|
+
st.link_button("Abrir autorização Canva", authorization_url(str(card.get("client_id")), redirect_uri, "design:content:read design:content:write", state, challenge), use_container_width=True)
|
|
7182
|
+
st.caption("Estado OAuth: autorizado" if (card.get("oauth_token") or {}).get("access_token") else "Estado OAuth: não autorizado")
|
|
7143
7183
|
status_cols = st.columns(4)
|
|
7144
7184
|
with status_cols[0]:
|
|
7145
7185
|
enabled = st.checkbox("Provider activo", value=bool(card.get("enabled", True)), key=f"media_card_{card_id}_enabled")
|
|
7146
7186
|
with status_cols[1]:
|
|
7147
7187
|
supports_image = st.checkbox("Pool Imagem", value=bool(card.get("supports_image", definition.supports_image)), key=f"media_card_{card_id}_image")
|
|
7148
7188
|
with status_cols[2]:
|
|
7149
|
-
supports_video = st.checkbox("Pool Vídeo", value=bool(card.get("supports_video", definition.supports_video)), key=f"media_card_{card_id}_video")
|
|
7189
|
+
supports_video = False if definition.code == "canva" else st.checkbox("Pool Vídeo", value=bool(card.get("supports_video", definition.supports_video)), key=f"media_card_{card_id}_video")
|
|
7150
7190
|
with status_cols[3]:
|
|
7151
7191
|
priority = st.number_input("Prioridade", min_value=0, max_value=999, value=int(card.get("priority", index)), step=1, key=f"media_card_{card_id}_priority")
|
|
7152
7192
|
action_cols = st.columns(4)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
AUTHORIZE_URL = "https://www.canva.com/api/oauth/authorize"
|
|
13
|
+
TOKEN_URL = "https://api.canva.com/rest/v1/oauth/token"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _b64(value: bytes) -> str:
|
|
17
|
+
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_pkce_pair() -> tuple[str, str]:
|
|
21
|
+
verifier = _b64(secrets.token_bytes(64))
|
|
22
|
+
challenge = _b64(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
23
|
+
return verifier, challenge
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_state() -> str:
|
|
27
|
+
return _b64(secrets.token_bytes(48))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def authorization_url(client_id: str, redirect_uri: str, scope: str, state: str, code_challenge: str) -> str:
|
|
31
|
+
params = {
|
|
32
|
+
"code_challenge": code_challenge,
|
|
33
|
+
"code_challenge_method": "S256",
|
|
34
|
+
"scope": " ".join(str(scope or "").split()),
|
|
35
|
+
"response_type": "code",
|
|
36
|
+
"client_id": str(client_id).strip(),
|
|
37
|
+
"state": str(state).strip(),
|
|
38
|
+
"redirect_uri": str(redirect_uri).strip(),
|
|
39
|
+
}
|
|
40
|
+
return f"{AUTHORIZE_URL}?{urlencode(params)}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def exchange_code(client_id: str, client_secret: str, code: str, redirect_uri: str, code_verifier: str, *, timeout: int = 30) -> dict[str, Any]:
|
|
44
|
+
response = requests.post(
|
|
45
|
+
TOKEN_URL,
|
|
46
|
+
data={
|
|
47
|
+
"grant_type": "authorization_code",
|
|
48
|
+
"code": code,
|
|
49
|
+
"code_verifier": code_verifier,
|
|
50
|
+
"client_id": client_id,
|
|
51
|
+
"client_secret": client_secret,
|
|
52
|
+
"redirect_uri": redirect_uri,
|
|
53
|
+
},
|
|
54
|
+
headers={"Accept": "application/json"},
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
)
|
|
57
|
+
if response.status_code >= 400:
|
|
58
|
+
raise RuntimeError(f"Canva OAuth devolveu HTTP {response.status_code}: {response.text[:240]}")
|
|
59
|
+
payload = response.json()
|
|
60
|
+
if not payload.get("access_token"):
|
|
61
|
+
raise RuntimeError("Canva OAuth não devolveu access_token.")
|
|
62
|
+
return _with_expiry(payload)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def refresh_access_token(client_id: str, client_secret: str, refresh_token: str, *, timeout: int = 30) -> dict[str, Any]:
|
|
66
|
+
response = requests.post(
|
|
67
|
+
TOKEN_URL,
|
|
68
|
+
data={
|
|
69
|
+
"grant_type": "refresh_token",
|
|
70
|
+
"refresh_token": refresh_token,
|
|
71
|
+
"client_id": client_id,
|
|
72
|
+
"client_secret": client_secret,
|
|
73
|
+
},
|
|
74
|
+
headers={"Accept": "application/json"},
|
|
75
|
+
timeout=timeout,
|
|
76
|
+
)
|
|
77
|
+
if response.status_code >= 400:
|
|
78
|
+
raise RuntimeError(f"Refresh Canva devolveu HTTP {response.status_code}: {response.text[:240]}")
|
|
79
|
+
return _with_expiry(response.json())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _with_expiry(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
83
|
+
result = dict(payload)
|
|
84
|
+
try:
|
|
85
|
+
result["expires_at"] = int(time.time()) + max(0, int(payload.get("expires_in", 0)))
|
|
86
|
+
except (TypeError, ValueError):
|
|
87
|
+
result["expires_at"] = 0
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def token_is_expiring(token: Mapping[str, Any], margin_seconds: int = 90) -> bool:
|
|
92
|
+
try:
|
|
93
|
+
return int(token.get("expires_at", 0)) <= int(time.time()) + margin_seconds
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
return True
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from .canva_auth import refresh_access_token, token_is_expiring
|
|
10
|
+
|
|
11
|
+
API_ROOT = "https://api.canva.com/rest/v1"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CanvaClient:
|
|
15
|
+
def __init__(self, card: Mapping[str, Any], *, token_saver=None) -> None:
|
|
16
|
+
self.card = dict(card)
|
|
17
|
+
self.token_saver = token_saver
|
|
18
|
+
self.client_id = str(card.get("client_id") or "").strip()
|
|
19
|
+
self.client_secret = str(card.get("client_secret") or "").strip()
|
|
20
|
+
self.token = dict(card.get("oauth_token") or {})
|
|
21
|
+
self.base_url = str(card.get("base_url") or API_ROOT).rstrip("/")
|
|
22
|
+
|
|
23
|
+
def _save_token(self, token: Mapping[str, Any]) -> None:
|
|
24
|
+
self.token = dict(token)
|
|
25
|
+
if self.token_saver:
|
|
26
|
+
self.token_saver(self.token)
|
|
27
|
+
|
|
28
|
+
def _access_token(self) -> str:
|
|
29
|
+
if not self.token.get("access_token"):
|
|
30
|
+
raise RuntimeError("Canva não está autorizada. Autorize a integração em Configuração API > Imagem e Video IA.")
|
|
31
|
+
if token_is_expiring(self.token):
|
|
32
|
+
refresh = str(self.token.get("refresh_token") or "").strip()
|
|
33
|
+
if not refresh:
|
|
34
|
+
raise RuntimeError("O token Canva expirou e não existe refresh token. Autorize novamente.")
|
|
35
|
+
self._save_token(refresh_access_token(self.client_id, self.client_secret, refresh))
|
|
36
|
+
return str(self.token["access_token"])
|
|
37
|
+
|
|
38
|
+
def request(self, method: str, path: str, *, json: Mapping[str, Any] | None = None, timeout: int = 60) -> dict[str, Any]:
|
|
39
|
+
refreshed = False
|
|
40
|
+
for attempt in range(4):
|
|
41
|
+
try:
|
|
42
|
+
token = self._access_token()
|
|
43
|
+
response = requests.request(method, f"{self.base_url}/{path.lstrip('/')}", headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Accept": "application/json"}, json=json, timeout=timeout)
|
|
44
|
+
except requests.RequestException as exc:
|
|
45
|
+
if attempt == 3:
|
|
46
|
+
raise RuntimeError(f"Falha de rede na Canva: {str(exc)[:200]}") from exc
|
|
47
|
+
time.sleep(2 ** attempt)
|
|
48
|
+
continue
|
|
49
|
+
if response.status_code == 401 and not refreshed and self.token.get("refresh_token"):
|
|
50
|
+
self._save_token(refresh_access_token(self.client_id, self.client_secret, str(self.token["refresh_token"])))
|
|
51
|
+
refreshed = True
|
|
52
|
+
continue
|
|
53
|
+
if response.status_code == 429:
|
|
54
|
+
if attempt == 3:
|
|
55
|
+
raise RuntimeError("Canva devolveu HTTP 429: limite de pedidos atingido.")
|
|
56
|
+
time.sleep(2 ** attempt)
|
|
57
|
+
continue
|
|
58
|
+
if response.status_code >= 400:
|
|
59
|
+
raise RuntimeError(f"Canva devolveu HTTP {response.status_code}: {response.text[:240]}")
|
|
60
|
+
return response.json()
|
|
61
|
+
raise RuntimeError("Canva não respondeu após as tentativas disponíveis.")
|
|
62
|
+
|
|
63
|
+
def create_design(self, width: int = 1280, height: int = 720, *, title: str = "Thunderbolt thumbnail") -> dict[str, Any]:
|
|
64
|
+
if not (40 <= width <= 8000 and 40 <= height <= 8000 and width * height <= 25_000_000):
|
|
65
|
+
raise ValueError("Dimensões Canva inválidas: cada dimensão deve estar entre 40 e 8000 e a área não pode exceder 25.000.000 px².")
|
|
66
|
+
return self.request("POST", "designs", json={"type": "type_and_asset", "design_type": {"type": "custom", "width": width, "height": height}, "title": title})
|
|
67
|
+
|
|
68
|
+
def export_design(self, design_id: str, *, file_type: str = "png", quality: str = "regular") -> str:
|
|
69
|
+
if file_type not in {"png", "jpg"}:
|
|
70
|
+
raise ValueError("A Canva thumbnail só suporta PNG ou JPG.")
|
|
71
|
+
if quality not in {"regular", "pro"}:
|
|
72
|
+
raise ValueError("A qualidade Canva deve ser regular ou pro.")
|
|
73
|
+
payload = self.request("POST", "exports", json={"design_id": design_id, "format": {"type": file_type, "export_quality": quality}})
|
|
74
|
+
job = payload.get("job") or payload
|
|
75
|
+
job_id = str(job.get("id") or "").strip()
|
|
76
|
+
if not job_id:
|
|
77
|
+
raise RuntimeError("Canva não devolveu o ID do job de exportação.")
|
|
78
|
+
return job_id
|
|
79
|
+
|
|
80
|
+
def get_export_job_status(self, job_id: str) -> dict[str, Any]:
|
|
81
|
+
return self.request("GET", f"exports/{job_id}", json=None)
|
|
82
|
+
|
|
83
|
+
def wait_export(self, job_id: str, *, attempts: int = 30, interval: float = 2.0) -> str:
|
|
84
|
+
for _ in range(attempts):
|
|
85
|
+
payload = self.get_export_job_status(job_id)
|
|
86
|
+
job = payload.get("job") or payload
|
|
87
|
+
status = str(job.get("status") or "").lower()
|
|
88
|
+
if status == "success" and job.get("urls"):
|
|
89
|
+
return str(job["urls"][0])
|
|
90
|
+
if status == "failed":
|
|
91
|
+
error = job.get("error") or {}
|
|
92
|
+
raise RuntimeError(f"Exportação Canva falhou: {error.get('code') or error}")
|
|
93
|
+
time.sleep(interval)
|
|
94
|
+
raise RuntimeError("A exportação Canva excedeu o tempo de espera.")
|
|
95
|
+
|
|
96
|
+
def download_export(self, url: str, destination: Path) -> Path:
|
|
97
|
+
try:
|
|
98
|
+
response = requests.get(url, timeout=90)
|
|
99
|
+
response.raise_for_status()
|
|
100
|
+
except requests.RequestException as exc:
|
|
101
|
+
raise RuntimeError(f"Não foi possível descarregar a thumbnail Canva: {str(exc)[:200]}") from exc
|
|
102
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
destination.write_bytes(response.content)
|
|
104
|
+
return destination
|
|
105
|
+
|
|
106
|
+
def create_and_export_thumbnail(self, *, title: str, width: int = 1280, height: int = 720) -> Path:
|
|
107
|
+
design = self.create_design(width, height, title=title)
|
|
108
|
+
design_id = str((design.get("design") or design).get("id") or "")
|
|
109
|
+
if not design_id:
|
|
110
|
+
raise RuntimeError("Canva não devolveu o ID do design.")
|
|
111
|
+
job_id = self.export_design(design_id, file_type=str(self.card.get("export_format") or "png"), quality=str(self.card.get("export_quality") or "regular"))
|
|
112
|
+
url = self.wait_export(job_id)
|
|
113
|
+
destination = Path(str(self.card.get("output_path") or "thumbnail-canva.png"))
|
|
114
|
+
return self.download_export(url, destination)
|
|
@@ -38,8 +38,9 @@ from .provider_routing import (
|
|
|
38
38
|
ProviderRoutingError,
|
|
39
39
|
route_json_request,
|
|
40
40
|
)
|
|
41
|
-
from .storage import STORAGE, ensure_storage
|
|
41
|
+
from .storage import STORAGE, ensure_storage, write_json
|
|
42
42
|
from .thumbnail_generation import _compose_thumbnail_prompt, generate_thumbnail_image, normalize_thumbnail_bytes
|
|
43
|
+
from .canva_client import CanvaClient
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
class MediaGenerationError(RuntimeError):
|
|
@@ -327,6 +328,27 @@ def generate_image_for_card(
|
|
|
327
328
|
"""Generate one image with the selected media card."""
|
|
328
329
|
card = dict(card)
|
|
329
330
|
provider = str(card.get("provider") or "").strip().lower()
|
|
331
|
+
if provider == "canva":
|
|
332
|
+
ensure_storage()
|
|
333
|
+
export_format = str(card.get("export_format") or "png").lower()
|
|
334
|
+
destination = STORAGE / "thumbnails" / f"canva-{abs(hash((topic, prompt, variant_index))) & 0xffffffffffffffff:x}.{export_format if export_format in {'png', 'jpg'} else 'png'}"
|
|
335
|
+
card["output_path"] = str(destination)
|
|
336
|
+
def save_token(token: Mapping[str, Any]) -> None:
|
|
337
|
+
updated = dict(settings)
|
|
338
|
+
saved_cards = [dict(item) for item in updated.get("media_provider_cards", []) if isinstance(item, Mapping)]
|
|
339
|
+
for saved_card in saved_cards:
|
|
340
|
+
if str(saved_card.get("id")) == str(card.get("id")):
|
|
341
|
+
saved_card["oauth_token"] = dict(token)
|
|
342
|
+
updated["media_provider_cards"] = saved_cards
|
|
343
|
+
write_json("settings.json", updated)
|
|
344
|
+
try:
|
|
345
|
+
return CanvaClient(card, token_saver=save_token).create_and_export_thumbnail(
|
|
346
|
+
title=str(topic or "Thunderbolt thumbnail")[:255],
|
|
347
|
+
width=int(card.get("thumbnail_width") or 1280),
|
|
348
|
+
height=int(card.get("thumbnail_height") or 720),
|
|
349
|
+
)
|
|
350
|
+
except Exception as exc:
|
|
351
|
+
raise MediaGenerationError(str(exc)) from exc
|
|
330
352
|
if provider == "nano_banana":
|
|
331
353
|
merged = dict(settings)
|
|
332
354
|
merged["gemini_image_api_key"] = _api_key(card)
|
|
@@ -394,9 +416,10 @@ def generate_image_from_pool(
|
|
|
394
416
|
lettering_text: str = "",
|
|
395
417
|
lettering_prompt: str = "",
|
|
396
418
|
reference_image: Path | None = None,
|
|
419
|
+
thumbnail_only: bool = False,
|
|
397
420
|
) -> Path:
|
|
398
421
|
"""Try eligible image cards in priority order, without cross-pool fallback."""
|
|
399
|
-
cards = media_cards_for_pool(settings, "image")
|
|
422
|
+
cards = media_cards_for_pool(settings, "image", thumbnail_only=True) if thumbnail_only else media_cards_for_pool(settings, "image")
|
|
400
423
|
if not cards:
|
|
401
424
|
raise MediaGenerationError("Não existem providers activos no pool de imagem.")
|
|
402
425
|
errors: list[str] = []
|
|
@@ -55,6 +55,17 @@ MEDIA_PROVIDER_CATALOG: tuple[MediaProviderDefinition, ...] = (
|
|
|
55
55
|
api_style="gemini_interactions",
|
|
56
56
|
description="Geração de imagem nativa Gemini para thumbnails e artes.",
|
|
57
57
|
),
|
|
58
|
+
MediaProviderDefinition(
|
|
59
|
+
"canva",
|
|
60
|
+
"Canva Connect",
|
|
61
|
+
default_base_url="https://api.canva.com/rest/v1",
|
|
62
|
+
requires_api_key=False,
|
|
63
|
+
supports_image=True,
|
|
64
|
+
supports_video=False,
|
|
65
|
+
api_style="canva_connect",
|
|
66
|
+
extra_fields=("client_id", "client_secret", "redirect_uri", "export_quality", "export_format", "thumbnail_width", "thumbnail_height"),
|
|
67
|
+
description="Canva Connect para criação e exportação de thumbnails; não é usado no pool de vídeo.",
|
|
68
|
+
),
|
|
58
69
|
MediaProviderDefinition(
|
|
59
70
|
"pollinations",
|
|
60
71
|
"Pollinations.ai",
|
|
@@ -203,9 +214,12 @@ def normalize_media_card(card: Any, index: int = 0) -> dict[str, Any]:
|
|
|
203
214
|
"supports_text": bool(source.get("supports_text", definition.supports_text)),
|
|
204
215
|
"api_style": definition.api_style,
|
|
205
216
|
"local": definition.local,
|
|
217
|
+
"thumbnail_only": provider == "canva",
|
|
206
218
|
}
|
|
207
219
|
for field in definition.extra_fields:
|
|
208
220
|
result[field] = str(source.get(field) or "").strip()
|
|
221
|
+
if provider == "canva":
|
|
222
|
+
result["oauth_token"] = dict(source.get("oauth_token") or {}) if isinstance(source.get("oauth_token"), Mapping) else {}
|
|
209
223
|
if provider == "nano_banana":
|
|
210
224
|
result["aspect_ratio"] = str(source.get("aspect_ratio") or INTERNAL_IMAGE_ASPECT_RATIO).strip()
|
|
211
225
|
result["image_size"] = str(source.get("image_size") or INTERNAL_IMAGE_SIZE).strip()
|
|
@@ -311,12 +325,14 @@ def apply_media_provider_cards_to_settings(settings: Mapping[str, Any], cards: l
|
|
|
311
325
|
return result
|
|
312
326
|
|
|
313
327
|
|
|
314
|
-
def media_cards_for_pool(settings: Mapping[str, Any], pool: str) -> list[dict[str, Any]]:
|
|
328
|
+
def media_cards_for_pool(settings: Mapping[str, Any], pool: str, *, thumbnail_only: bool = False) -> list[dict[str, Any]]:
|
|
315
329
|
migrated, _ = ensure_media_provider_cards(settings)
|
|
316
330
|
capability = "supports_image" if pool == "image" else "supports_video" if pool == "video" else "supports_text"
|
|
317
331
|
active_key = MEDIA_IMAGE_ACTIVE_CARD_KEY if pool == "image" else MEDIA_VIDEO_ACTIVE_CARD_KEY if pool == "video" else ""
|
|
318
332
|
active_id = str(migrated.get(active_key) or "")
|
|
319
333
|
cards = [dict(item) for item in migrated.get(MEDIA_CARDS_KEY, []) if item.get("enabled", True) and item.get(capability)]
|
|
334
|
+
if pool == "image" and not thumbnail_only:
|
|
335
|
+
cards = [card for card in cards if not card.get("thumbnail_only")]
|
|
320
336
|
# A prioridade é o contrato principal do failover. O cartão activo legado
|
|
321
337
|
# só serve de desempate entre cartões com a mesma prioridade, para não
|
|
322
338
|
# ignorar uma escolha explícita do utilizador por causa de estado antigo.
|
package/hermes_ui/thumbnails.py
CHANGED
|
@@ -10,7 +10,7 @@ from typing import Any
|
|
|
10
10
|
from .creative_generation import _language_instruction, generate_thumbnail_prompt
|
|
11
11
|
from .media_generation import generate_image_from_pool
|
|
12
12
|
from .media_providers import media_cards_for_pool
|
|
13
|
-
from .storage import STORAGE, ensure_storage, now, read_json, update_json
|
|
13
|
+
from .storage import STORAGE, ensure_storage, now, read_json, update_json, write_json
|
|
14
14
|
from .thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
|
|
15
15
|
from .thumbnail_blueprints import thumbnail_blueprint_for_channel
|
|
16
16
|
|
|
@@ -49,6 +49,7 @@ def _generate_image_with_pool(
|
|
|
49
49
|
reference_image=reference_image,
|
|
50
50
|
lettering_text=lettering_text,
|
|
51
51
|
lettering_prompt=lettering_prompt,
|
|
52
|
+
thumbnail_only=True,
|
|
52
53
|
)
|
|
53
54
|
|
|
54
55
|
|
package/package.json
CHANGED
package/scripts/cli.mjs
CHANGED
|
@@ -26,7 +26,7 @@ const venvPython = platform() === "win32" ? join(venvDir, "Scripts", "python.exe
|
|
|
26
26
|
const python = process.env.THUNDERBOLT_PYTHON || process.env.HERMES_PYTHON || (existsSync(venvPython) ? venvPython : (platform() === "win32" ? "python" : "python3"));
|
|
27
27
|
const main = resolve(root, "app", "main.py");
|
|
28
28
|
const settingsPath = join(thunderboltHome, "storage", "state", "settings.json");
|
|
29
|
-
const pythonEnvironment = { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" };
|
|
29
|
+
const pythonEnvironment = { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", CLICK_NO_WIN_CONSOLE: "1" };
|
|
30
30
|
|
|
31
31
|
function run(command, commandArgs, label = "comando", environment = process.env) {
|
|
32
32
|
console.log(`Thunderbolt: a iniciar ${label}...`);
|
|
@@ -155,6 +155,7 @@ const runtimeEnv = {
|
|
|
155
155
|
...process.env,
|
|
156
156
|
PYTHONIOENCODING: "utf-8",
|
|
157
157
|
PYTHONUTF8: "1",
|
|
158
|
+
CLICK_NO_WIN_CONSOLE: "1",
|
|
158
159
|
THUNDERBOLT_STORAGE_DIR: storageDir,
|
|
159
160
|
STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false",
|
|
160
161
|
STREAMLIT_SERVER_HEADLESS: "true",
|
|
@@ -257,7 +258,7 @@ function startStreamlit() {
|
|
|
257
258
|
child = spawn(python, ["-m", "streamlit", "run", main, "--server.port", String(backendPort), "--server.address", "127.0.0.1"], {
|
|
258
259
|
cwd: root,
|
|
259
260
|
stdio: "inherit",
|
|
260
|
-
env: { ...runtimeEnv, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", THUNDERBOLT_LAUNCHER_RESTART: "1" },
|
|
261
|
+
env: { ...runtimeEnv, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", CLICK_NO_WIN_CONSOLE: "1", THUNDERBOLT_LAUNCHER_RESTART: "1" },
|
|
261
262
|
windowsHide: false,
|
|
262
263
|
});
|
|
263
264
|
child.on("error", (error) => {
|
package/scripts/install.mjs
CHANGED
|
@@ -42,7 +42,7 @@ const defaultMpt = process.env.MONEYPRINTER_PATH || join(thunderboltHome, "Money
|
|
|
42
42
|
const dependencyStatePath = join(thunderboltHome, "storage", "state", "install-state.json");
|
|
43
43
|
const forceDeps = args.includes("--force-deps");
|
|
44
44
|
const refreshMoneyPrinter = args.includes("--refresh-moneyprinter");
|
|
45
|
-
const pythonEnvironment = { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" };
|
|
45
|
+
const pythonEnvironment = { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", CLICK_NO_WIN_CONSOLE: "1" };
|
|
46
46
|
|
|
47
47
|
function legacyRoots() {
|
|
48
48
|
const userProfile = process.env.USERPROFILE || home;
|