@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 +8 -0
- package/app/main.py +41 -3
- package/app/modules/canva_skills/__init__.py +14 -0
- package/app/modules/canva_skills/brand.py +19 -0
- package/app/modules/canva_skills/bulk.py +37 -0
- package/app/modules/canva_skills/client.py +22 -0
- package/app/modules/canva_skills/config.py +14 -0
- package/app/modules/canva_skills/edit.py +12 -0
- package/app/modules/canva_skills/feedback.py +24 -0
- package/app/modules/canva_skills/models.py +29 -0
- package/app/modules/canva_skills/resize.py +32 -0
- 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/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.
|
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