@danhachuel/thunderbolt 0.4.93 → 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.
@@ -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
@@ -35,12 +35,12 @@ class CanvaClient:
35
35
  self._save_token(refresh_access_token(self.client_id, self.client_secret, refresh))
36
36
  return str(self.token["access_token"])
37
37
 
38
- def request(self, method: str, path: str, *, json: Mapping[str, Any] | None = None, timeout: int = 60) -> dict[str, Any]:
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
39
  refreshed = False
40
40
  for attempt in range(4):
41
41
  try:
42
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)
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
44
  except requests.RequestException as exc:
45
45
  if attempt == 3:
46
46
  raise RuntimeError(f"Falha de rede na Canva: {str(exc)[:200]}") from exc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.4.93",
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",