@danhachuel/thunderbolt 0.4.92 → 0.4.94

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/README.md CHANGED
@@ -451,3 +451,11 @@ O Thunderbolt integra o [jusTokenMax](https://github.com/Kalmantic/jusTokenMax)
451
451
  A configuração encontra-se em **Configuração API > API Keys > Optimização de tokens — jusTokenMax**. É possível activar ou desactivar o optimizador, controlar os levers por tipo, verificar a versão instalada, consultar métricas e limpar apenas os artefactos derivados. Os originais permanecem em `storage/state/token_optimizer_originals/` e podem ser recuperados pelo hash através do módulo interno.
452
452
 
453
453
  A instalação do pacote inclui `justokenmax` a partir do repositório oficial. Se o CLI não estiver disponível ou ocorrer um erro, o Thunderbolt usa o conteúdo original e regista um fallback local; a geração não fica bloqueada. API keys, passwords, tokens e blobs base64 são redigidos antes de serem guardados em artefactos derivados.
454
+
455
+ ## Canva Skills — automação criativa
456
+
457
+ O Thunderbolt inclui as seis Canva Skills activas do pacote oficial `canva-sdks/canva-skills`, adaptadas para chamadas REST directas: `resize-for-social-media`, `bulk-create`, `implement-feedback`, `edit-design`, `get-design-feedback` e `brand-check`. A página `Canva Skills` permite executar as operações sobre um card Canva autorizado em **Configuração API > API Keys > Imagem e Video IA**.
458
+
459
+ A criação em lote usa o endpoint oficial de Autofill e requer Canva Enterprise (ou quota de trial durante desenvolvimento). A criação de variantes sociais copia o design e exporta cada variante; a Connect REST pública não oferece um endpoint geral de resize responsivo, por isso a dimensão alvo é registada e o link de edição Canva é disponibilizado para ajustes finais. A API REST também não expõe as transacções de edição usadas pelo MCP; `edit-design` e `implement-feedback` funcionam como preparação aprovada e identificam claramente as alterações que exigem edição manual, sem efectuar commits falsos.
460
+
461
+ O fluxo automático de thumbnails continua a usar Canva exclusivamente no Pool de Imagem para thumbnails. Canva nunca é seleccionado para geração de vídeo. Consulte `app/modules/canva_skills/` para os módulos e `app/pages/canva_skills.py` para a interface.
package/app/main.py CHANGED
@@ -7,6 +7,8 @@ os.environ["PYTHONUTF8"] = "1"
7
7
  os.environ["CLICK_NO_WIN_CONSOLE"] = "1"
8
8
 
9
9
  def _force_utf8_stream(stream: object) -> object:
10
+ if os.name != "nt":
11
+ return stream
10
12
  buffer = getattr(stream, "buffer", None)
11
13
  if buffer is not None and not getattr(stream, "_thunderbolt_utf8", False):
12
14
  wrapped = io.TextIOWrapper(buffer, encoding="utf-8", errors="replace", line_buffering=True)
@@ -104,6 +106,7 @@ from hermes_ui.thumbnails import (
104
106
  from hermes_ui.draft_video import DRAFT_SETTING_SECTIONS, missing_content_fields, missing_setting_sections, normalise_saved_script, setting_widget_suffixes
105
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
106
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
107
110
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
108
111
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
109
112
  from integrations.youtube_update import YOUTUBE_UPDATE_VIDEO, YouTubeVideoUpdater
@@ -7062,7 +7065,7 @@ def _media_card_config_status(card: dict[str, Any]) -> tuple[str, str]:
7062
7065
  return "missing", "Missing key"
7063
7066
  if not str(card.get("base_url") or "").strip():
7064
7067
  return "missing", "Missing Base URL"
7065
- 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"}:
7066
7069
  return "missing", "Missing model"
7067
7070
  return "ready", "Configured"
7068
7071
 
@@ -7098,6 +7101,26 @@ def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str,
7098
7101
  cards[index] = card
7099
7102
  card_id = str(card["id"])
7100
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]}")
7101
7124
  with st.container(border=True):
7102
7125
  header_cols = st.columns([3.2, 1.2])
7103
7126
  with header_cols[0]:
@@ -7141,14 +7164,29 @@ def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str,
7141
7164
  extra_cols = st.columns(len(definition.extra_fields))
7142
7165
  for extra_col, field_name in zip(extra_cols, definition.extra_fields):
7143
7166
  with extra_col:
7144
- 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")
7145
7183
  status_cols = st.columns(4)
7146
7184
  with status_cols[0]:
7147
7185
  enabled = st.checkbox("Provider activo", value=bool(card.get("enabled", True)), key=f"media_card_{card_id}_enabled")
7148
7186
  with status_cols[1]:
7149
7187
  supports_image = st.checkbox("Pool Imagem", value=bool(card.get("supports_image", definition.supports_image)), key=f"media_card_{card_id}_image")
7150
7188
  with status_cols[2]:
7151
- 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")
7152
7190
  with status_cols[3]:
7153
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")
7154
7192
  action_cols = st.columns(4)
@@ -0,0 +1,14 @@
1
+ """Deterministic REST adaptations of the active Canva agent skills."""
2
+
3
+ from .config import CANVA_SKILL_SCOPES, PLATFORM_DIMENSIONS
4
+ from .resize import resize_for_social_media
5
+ from .bulk import bulk_create_thumbnails
6
+ from .feedback import get_design_feedback, implement_feedback
7
+ from .edit import edit_design
8
+ from .brand import list_brand_kits, check_brand_compliance
9
+
10
+ __all__ = [
11
+ "CANVA_SKILL_SCOPES", "PLATFORM_DIMENSIONS", "resize_for_social_media",
12
+ "bulk_create_thumbnails", "get_design_feedback", "implement_feedback",
13
+ "edit_design", "list_brand_kits", "check_brand_compliance",
14
+ ]
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .client import CanvaSkillsClient
6
+
7
+
8
+ def list_brand_kits(*, client: CanvaSkillsClient) -> list[dict[str, Any]]:
9
+ payload = client.get("brand-templates", {"limit": 100})
10
+ return list(payload.get("items") or payload.get("brand_templates") or [])
11
+
12
+
13
+ def check_brand_compliance(design_id: str, brand_kit_id: str, *, client: CanvaSkillsClient) -> dict[str, Any]:
14
+ design = client.get(f"designs/{design_id}").get("design", {})
15
+ templates = list_brand_kits(client=client)
16
+ selected = next((item for item in templates if str(item.get("id")) == brand_kit_id), None)
17
+ if selected is None:
18
+ raise ValueError("Brand Template/kit não encontrado ou sem permissão.")
19
+ return {"design_id": design_id, "brand_kit_id": brand_kit_id, "on_brand": [], "off_brand": [], "cannot_verify": [{"category": "colors", "reason": "A metadata REST não expõe paleta completa."}, {"category": "typography", "reason": "A metadata REST não expõe fontes completas."}, {"category": "logo", "reason": "É necessária inspecção visual no Canva."}], "design_title": design.get("title", ""), "brand_template_title": selected.get("title", "")}
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping
4
+
5
+ from .client import CanvaSkillsClient
6
+
7
+
8
+ def _autofill(client: CanvaSkillsClient, template_id: str, row: Mapping[str, Any], title: str) -> str:
9
+ data = {key: value if isinstance(value, Mapping) else {"type": "text", "text": str(value)} for key, value in row.items()}
10
+ payload = client.post("autofills", {"type": "create_from_brand_template", "brand_template_id": template_id, "data": data, "title": title[:255] or "Thunderbolt thumbnail"})
11
+ job = payload.get("job") or payload
12
+ job_id = str(job.get("id") or "")
13
+ if not job_id:
14
+ raise RuntimeError("A Canva não devolveu o job de autofill.")
15
+ for _ in range(60):
16
+ status_payload = client.get(f"autofills/{job_id}")
17
+ status_job = status_payload.get("job") or status_payload
18
+ status = str(status_job.get("status") or "").lower()
19
+ if status == "success":
20
+ design = status_job.get("design") or {}
21
+ design_id = str(design.get("id") or status_job.get("design_id") or "")
22
+ if design_id:
23
+ return design_id
24
+ if status == "failed":
25
+ raise RuntimeError(f"Autofill Canva falhou: {status_job.get('error') or status_job}")
26
+ raise RuntimeError("Autofill Canva excedeu o tempo de espera.")
27
+
28
+
29
+ def bulk_create_thumbnails(template_id: str, data: list[Mapping[str, Any]], export_format: str = "png", *, client: CanvaSkillsClient) -> list[dict[str, Any]]:
30
+ if not template_id.strip():
31
+ raise ValueError("É necessário indicar um Brand Template Canva.")
32
+ results = []
33
+ for row in data:
34
+ design_id = _autofill(client, template_id, row, str(row.get("titulo") or row.get("title") or "Thunderbolt thumbnail"))
35
+ job_id = client.export_design(design_id, file_type=export_format.lower())
36
+ results.append({"design_id": design_id, "export_url": client.wait_export(job_id), "metadata": dict(row)})
37
+ return results
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping
4
+
5
+ from hermes_ui.canva_client import CanvaClient
6
+
7
+
8
+ class CanvaSkillsClient(CanvaClient):
9
+ """REST client shared by the adapted Canva Skills."""
10
+
11
+ def get(self, endpoint: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]:
12
+ return self.request("GET", endpoint, params=params)
13
+
14
+ def post(self, endpoint: str, data: Mapping[str, Any] | None = None) -> dict[str, Any]:
15
+ return self.request("POST", endpoint, json=data or {})
16
+
17
+ def delete(self, endpoint: str) -> dict[str, Any]:
18
+ return self.request("DELETE", endpoint)
19
+
20
+
21
+ def client_from_card(card: Mapping[str, Any], token_saver=None) -> CanvaSkillsClient:
22
+ return CanvaSkillsClient(card, token_saver=token_saver)
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ PLATFORM_DIMENSIONS = {
4
+ "youtube_thumbnail": (1280, 720),
5
+ "tiktok": (1080, 1920),
6
+ "instagram_post": (1080, 1080),
7
+ "instagram_story": (1080, 1920),
8
+ "facebook_post": (1200, 630),
9
+ "facebook_story": (1080, 1920),
10
+ "linkedin_post": (1200, 627),
11
+ }
12
+
13
+ CANVA_SKILL_SCOPES = "design:content:read design:content:write design:meta:read brandtemplate:meta:read brandtemplate:content:read comment:read comment:write"
14
+ SUPPORTED_EXPORT_FORMATS = {"png", "jpg"}
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping
4
+
5
+ from .client import CanvaSkillsClient
6
+
7
+
8
+ def edit_design(design_id: str, changes: Mapping[str, Any], *, client: CanvaSkillsClient, auto_commit: bool = False) -> dict[str, Any]:
9
+ """Prepare a safe edit request; REST Connect has no public edit transaction endpoint."""
10
+ if not auto_commit:
11
+ return {"status": "pending_approval", "design_id": design_id, "changes": dict(changes)}
12
+ return {"status": "manual_action_required", "design_id": design_id, "changes": dict(changes), "message": "A Canva Connect REST API não expõe start/perform/commit editing transactions; abra o edit_url no Canva para aplicar estas alterações manualmente."}
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping
4
+
5
+ from .client import CanvaSkillsClient
6
+
7
+
8
+ def get_design_feedback(design_id: str, *, client: CanvaSkillsClient) -> dict[str, Any]:
9
+ design_payload = client.get(f"designs/{design_id}")
10
+ design = design_payload.get("design") or design_payload
11
+ findings = []
12
+ title = str(design.get("title") or "").strip()
13
+ if not title:
14
+ findings.append({"category": "readability", "severity": "medium", "message": "O design não tem título identificável na metadata."})
15
+ thumb = design.get("thumbnail") or {}
16
+ if thumb.get("width") and thumb.get("height") and thumb["width"] / thumb["height"] < 1.5:
17
+ findings.append({"category": "layout", "severity": "high", "message": "A proporção da thumbnail parece inadequada para YouTube 16:9."})
18
+ return {"design_id": design_id, "visual_hierarchy": findings, "layout": [], "readability": [], "consistency": [], "accessibility": [], "limitations": ["A Connect REST não expõe uma análise visual completa nem listagem de threads de comentários; os itens não verificáveis ficam explícitos."]}
19
+
20
+
21
+ def implement_feedback(design_id: str, feedback_items: list[Mapping[str, Any]], *, client: CanvaSkillsClient, approved: bool = False) -> dict[str, Any]:
22
+ if not approved:
23
+ return {"status": "pending_approval", "design_id": design_id, "items": [dict(item) for item in feedback_items]}
24
+ return {"status": "manual_action_required", "design_id": design_id, "applied": [], "manual": [dict(item) for item in feedback_items], "message": "A Connect REST pública não fornece transacções de edição; nenhuma alteração foi cometida."}
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class PlatformAsset:
9
+ platform: str
10
+ width: int
11
+ height: int
12
+ design_id: str = ""
13
+ export_url: str = ""
14
+ edit_url: str = ""
15
+
16
+
17
+ @dataclass
18
+ class SkillResult:
19
+ status: str
20
+ message: str = ""
21
+ data: dict[str, Any] = field(default_factory=dict)
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class FeedbackItem:
26
+ category: str
27
+ text: str
28
+ actionable: bool = False
29
+ source_id: str = ""
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping
4
+
5
+ from .client import CanvaSkillsClient
6
+ from .config import PLATFORM_DIMENSIONS, SUPPORTED_EXPORT_FORMATS
7
+
8
+
9
+ def resize_for_social_media(design_id: str, platforms: list[str], export_format: str = "png", *, client: CanvaSkillsClient) -> dict[str, dict[str, Any]]:
10
+ """Create platform variants and export them.
11
+
12
+ Connect REST currently has no general resize endpoint. The skill therefore
13
+ creates a copy for each platform and records target dimensions; users can
14
+ refine the copy through Canva's edit URL when exact responsive layout is
15
+ required.
16
+ """
17
+ if export_format.lower() not in SUPPORTED_EXPORT_FORMATS:
18
+ raise ValueError("O formato deve ser png ou jpg.")
19
+ results: dict[str, dict[str, Any]] = {}
20
+ for platform in platforms:
21
+ if platform not in PLATFORM_DIMENSIONS:
22
+ continue
23
+ copied = client.post("designs", {"type": "design", "design_id": design_id, "page_numbers": [1]})
24
+ design = copied.get("design") or copied
25
+ variant_id = str(design.get("id") or "")
26
+ if not variant_id:
27
+ raise RuntimeError(f"A Canva não devolveu o design da variante {platform}.")
28
+ job_id = client.export_design(variant_id, file_type=export_format.lower())
29
+ url = client.wait_export(job_id)
30
+ urls = design.get("urls") or {}
31
+ results[platform] = {"design_id": variant_id, "export_url": url, "edit_url": urls.get("edit_url", ""), "width": PLATFORM_DIMENSIONS[platform][0], "height": PLATFORM_DIMENSIONS[platform][1], "resize_note": "A Connect REST não aplica resize responsivo; a dimensão é metadado para edição/exportação."}
32
+ return results
@@ -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, params: 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, params=params, 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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.4.92",
3
+ "version": "0.4.94",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",