@danhachuel/thunderbolt 0.5.8 → 0.5.11
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 +1 -0
- package/app/modules/canva_skills/thumbnail_workflow.py +106 -0
- package/hermes_ui/api_key_tests.py +16 -0
- package/hermes_ui/automation_worker.py +0 -2
- package/hermes_ui/canva_mcp_client.py +149 -0
- package/hermes_ui/canva_mcp_workflow.py +172 -0
- package/hermes_ui/media_generation.py +21 -0
- package/hermes_ui/pipeline_worker.py +3 -2
- package/hermes_ui/thumbnails.py +23 -1
- package/package.json +1 -2
- package/scripts/cli.mjs +0 -6
- package/CHANGELOG.md +0 -5
package/app/main.py
CHANGED
|
@@ -7169,6 +7169,7 @@ def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str,
|
|
|
7169
7169
|
if definition.code == "canva":
|
|
7170
7170
|
base_url = definition.default_base_url
|
|
7171
7171
|
st.text_input("Base URL", value=base_url, disabled=True, key=f"media_card_{card_id}_base_url_display")
|
|
7172
|
+
st.caption("Canva MCP directo: https://mcp.canva.com/mcp — a autenticação MCP é aberta pelo Thunderbolt na primeira utilização.")
|
|
7172
7173
|
else:
|
|
7173
7174
|
base_url = st.text_input("Base URL", value=str(card.get("base_url") or definition.default_base_url), key=f"media_card_{card_id}_base_url")
|
|
7174
7175
|
extra_values: dict[str, str] = {}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Callable, Mapping
|
|
6
|
+
|
|
7
|
+
from hermes_ui.thumbnail_blueprints import thumbnail_blueprint_for_channel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CanvaThumbnailWorkflowError(RuntimeError):
|
|
11
|
+
"""Raised when a Canva thumbnail workflow cannot complete safely."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _keywords(text: str) -> list[str]:
|
|
15
|
+
words = re.findall(r"[\wÀ-ÿ]{3,}", str(text or "").lower(), flags=re.UNICODE)
|
|
16
|
+
stopwords = {"para", "com", "uma", "the", "and", "from", "this", "that"}
|
|
17
|
+
return list(dict.fromkeys(word for word in words if word not in stopwords))[:8]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_local_thumbnail_blueprint(channel: Mapping[str, Any] | None = None, blueprint_id: str = "") -> dict[str, Any]:
|
|
21
|
+
"""Resolve the Thunderbolt-local Thumbnail Blueprint; Canva is never queried for it."""
|
|
22
|
+
context = dict(channel or {})
|
|
23
|
+
if blueprint_id:
|
|
24
|
+
context["thumbnail_blueprint_id"] = blueprint_id
|
|
25
|
+
blueprint = thumbnail_blueprint_for_channel(context)
|
|
26
|
+
content = str(blueprint.get("content") or "").strip()
|
|
27
|
+
if not content:
|
|
28
|
+
raise CanvaThumbnailWorkflowError(
|
|
29
|
+
"Nenhum Thumbnail Blueprint local foi encontrado no Thunderbolt."
|
|
30
|
+
)
|
|
31
|
+
return blueprint
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_search_query(title: str, topic: str = "", blueprint: Mapping[str, Any] | None = None) -> str:
|
|
35
|
+
"""Build Canva search terms from the video context, not from a Canva template search."""
|
|
36
|
+
base = _keywords(f"{title} {topic}")
|
|
37
|
+
niche = _keywords(str((blueprint or {}).get("niche") or ""))
|
|
38
|
+
return " ".join((base + niche)[:10])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def select_design(candidates: list[Mapping[str, Any]], width: int, height: int) -> dict[str, Any]:
|
|
42
|
+
"""Choose an existing Canva design with the requested thumbnail dimensions."""
|
|
43
|
+
if not candidates:
|
|
44
|
+
raise CanvaThumbnailWorkflowError("A pesquisa Canva não encontrou designs para a thumbnail.")
|
|
45
|
+
|
|
46
|
+
def score(item: Mapping[str, Any]) -> tuple[int, int]:
|
|
47
|
+
thumbnail = item.get("thumbnail") if isinstance(item.get("thumbnail"), Mapping) else {}
|
|
48
|
+
item_width = int(thumbnail.get("width") or 0)
|
|
49
|
+
item_height = int(thumbnail.get("height") or 0)
|
|
50
|
+
exact = int(item_width == width and item_height == height)
|
|
51
|
+
ratio_delta = abs((item_width / item_height) - (width / height)) if item_width and item_height else 99
|
|
52
|
+
return exact, -int(ratio_delta * 1000)
|
|
53
|
+
|
|
54
|
+
return dict(max(candidates, key=score))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_thumbnail_workflow(
|
|
58
|
+
*,
|
|
59
|
+
title: str,
|
|
60
|
+
topic: str,
|
|
61
|
+
channel: Mapping[str, Any] | None,
|
|
62
|
+
blueprint_id: str = "",
|
|
63
|
+
width: int = 1280,
|
|
64
|
+
height: int = 720,
|
|
65
|
+
search_designs: Callable[[str], list[Mapping[str, Any]]],
|
|
66
|
+
edit_design: Callable[[str, Mapping[str, Any], Mapping[str, Any]], Mapping[str, Any]],
|
|
67
|
+
export_design: Callable[[str, str, str, int, int], Path],
|
|
68
|
+
) -> Path:
|
|
69
|
+
"""Run local Blueprint -> search -> edit -> export with injected Canva operations.
|
|
70
|
+
|
|
71
|
+
The injected operations are the official Canva connector boundary. This keeps
|
|
72
|
+
local Blueprint files private and prevents the REST-only blank-design fallback.
|
|
73
|
+
"""
|
|
74
|
+
blueprint = load_local_thumbnail_blueprint(channel, blueprint_id)
|
|
75
|
+
query = build_search_query(title, topic, blueprint)
|
|
76
|
+
candidates = search_designs(query)
|
|
77
|
+
selected = select_design(candidates, width, height)
|
|
78
|
+
design_id = str(selected.get("id") or "").strip()
|
|
79
|
+
if not design_id:
|
|
80
|
+
raise CanvaThumbnailWorkflowError("A pesquisa Canva devolveu um design sem design_id.")
|
|
81
|
+
changes = {
|
|
82
|
+
"title": title,
|
|
83
|
+
"topic": topic,
|
|
84
|
+
"blueprint_id": str(blueprint.get("id") or ""),
|
|
85
|
+
"blueprint_content": str(blueprint.get("content") or ""),
|
|
86
|
+
"search_query": query,
|
|
87
|
+
"lettering": title,
|
|
88
|
+
"width": width,
|
|
89
|
+
"height": height,
|
|
90
|
+
}
|
|
91
|
+
edited = dict(edit_design(design_id, changes, blueprint))
|
|
92
|
+
edited_id = str(edited.get("design_id") or design_id).strip()
|
|
93
|
+
if not edited_id or edited.get("status") in {"pending_approval", "manual_action_required"}:
|
|
94
|
+
raise CanvaThumbnailWorkflowError(
|
|
95
|
+
"A edição Canva não foi confirmada; a thumbnail não será exportada."
|
|
96
|
+
)
|
|
97
|
+
return export_design(edited_id, "png", "medium", width, height)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"CanvaThumbnailWorkflowError",
|
|
102
|
+
"build_search_query",
|
|
103
|
+
"load_local_thumbnail_blueprint",
|
|
104
|
+
"run_thumbnail_workflow",
|
|
105
|
+
"select_design",
|
|
106
|
+
]
|
|
@@ -128,6 +128,20 @@ def test_nano_banana_credentials(api_key: str, model: str) -> dict[str, Any]:
|
|
|
128
128
|
)
|
|
129
129
|
|
|
130
130
|
|
|
131
|
+
def test_canva_credentials(card: Mapping[str, Any]) -> dict[str, Any]:
|
|
132
|
+
"""Validate Canva with the configured API key or OAuth access token."""
|
|
133
|
+
source = dict(card) if isinstance(card, Mapping) else {}
|
|
134
|
+
api_key = str(source.get("api_key") or "").strip()
|
|
135
|
+
oauth_token = source.get("oauth_token") if isinstance(source.get("oauth_token"), Mapping) else {}
|
|
136
|
+
token = api_key or str(oauth_token.get("access_token") or "").strip()
|
|
137
|
+
if not token:
|
|
138
|
+
return _missing("Introduza a API key ou autorize o Canva antes de testar este provider.")
|
|
139
|
+
return _get(
|
|
140
|
+
"https://api.canva.com/rest/v1/users/me",
|
|
141
|
+
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
131
145
|
def test_google_lyria_credentials(api_key: str, model: str) -> dict[str, Any]:
|
|
132
146
|
"""Validate access to the selected Lyria model without requesting audio generation."""
|
|
133
147
|
api_key = str(api_key or "").strip()
|
|
@@ -146,6 +160,8 @@ def test_media_provider_card(card: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
146
160
|
"""Run a bounded, non-generative check for an image/video provider card."""
|
|
147
161
|
source = dict(card) if isinstance(card, Mapping) else {}
|
|
148
162
|
provider = str(source.get("provider") or "").strip().lower()
|
|
163
|
+
if provider == "canva":
|
|
164
|
+
return test_canva_credentials(source)
|
|
149
165
|
api_key = str(source.get("api_key") or "").strip()
|
|
150
166
|
model = str(source.get("model") or "").strip()
|
|
151
167
|
base_url = str(source.get("base_url") or "").strip().rstrip("/")
|
|
@@ -4,8 +4,6 @@ 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"
|
|
8
|
-
os.environ["PYTHONLEGACYWINDOWSSTDIO"] = "1"
|
|
9
7
|
_original_stdout, _original_stderr = sys.stdout, sys.stderr
|
|
10
8
|
if getattr(sys.stdout, "buffer", None) is not None and str(getattr(sys.stdout, "encoding", "") or "").lower().replace("-", "_") != "utf_8":
|
|
11
9
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace", line_buffering=True)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import queue
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Mapping
|
|
12
|
+
|
|
13
|
+
MCP_URL = "https://mcp.canva.com/mcp"
|
|
14
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CanvaMCPError(RuntimeError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CanvaMCPClient:
|
|
22
|
+
"""Direct local MCP client for Canva, using the official mcp-remote adapter."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, *, endpoint: str = MCP_URL, timeout: int = 120) -> None:
|
|
25
|
+
self.endpoint = endpoint
|
|
26
|
+
self.timeout = timeout
|
|
27
|
+
self._next_id = 0
|
|
28
|
+
self._process: subprocess.Popen[str] | None = None
|
|
29
|
+
self._messages: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
30
|
+
self._reader: threading.Thread | None = None
|
|
31
|
+
|
|
32
|
+
def _command(self) -> list[str]:
|
|
33
|
+
executable = shutil.which("npx.cmd") or shutil.which("npx") or "npx"
|
|
34
|
+
return [executable, "--yes", "mcp-remote@latest", self.endpoint]
|
|
35
|
+
|
|
36
|
+
def connect(self) -> dict[str, Any]:
|
|
37
|
+
if self._process and self._process.poll() is None:
|
|
38
|
+
return {}
|
|
39
|
+
env = {
|
|
40
|
+
**os.environ,
|
|
41
|
+
"MCP_REMOTE_QUIET": "1",
|
|
42
|
+
"PYTHONIOENCODING": "utf-8",
|
|
43
|
+
"PYTHONUTF8": "1",
|
|
44
|
+
}
|
|
45
|
+
self._process = subprocess.Popen(
|
|
46
|
+
self._command(),
|
|
47
|
+
stdin=subprocess.PIPE,
|
|
48
|
+
stdout=subprocess.PIPE,
|
|
49
|
+
stderr=subprocess.PIPE,
|
|
50
|
+
text=True,
|
|
51
|
+
encoding="utf-8",
|
|
52
|
+
errors="replace",
|
|
53
|
+
bufsize=1,
|
|
54
|
+
)
|
|
55
|
+
self._reader = threading.Thread(target=self._read_stdout, name="canva-mcp-reader", daemon=True)
|
|
56
|
+
self._reader.start()
|
|
57
|
+
result = self.request(
|
|
58
|
+
"initialize",
|
|
59
|
+
{
|
|
60
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
61
|
+
"capabilities": {},
|
|
62
|
+
"clientInfo": {"name": "Thunderbolt", "version": "0.5.8"},
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
self.notify("notifications/initialized", {})
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
def _read_stdout(self) -> None:
|
|
69
|
+
assert self._process and self._process.stdout
|
|
70
|
+
for line in self._process.stdout:
|
|
71
|
+
try:
|
|
72
|
+
message = json.loads(line)
|
|
73
|
+
except json.JSONDecodeError:
|
|
74
|
+
continue
|
|
75
|
+
if isinstance(message, dict):
|
|
76
|
+
self._messages.put(message)
|
|
77
|
+
|
|
78
|
+
def notify(self, method: str, params: Mapping[str, Any]) -> None:
|
|
79
|
+
self._send({"jsonrpc": "2.0", "method": method, "params": dict(params)})
|
|
80
|
+
|
|
81
|
+
def _send(self, message: Mapping[str, Any]) -> None:
|
|
82
|
+
if not self._process or self._process.poll() is not None or not self._process.stdin:
|
|
83
|
+
raise CanvaMCPError("A sessão directa Canva MCP não está ligada.")
|
|
84
|
+
self._process.stdin.write(json.dumps(message, ensure_ascii=False) + "\n")
|
|
85
|
+
self._process.stdin.flush()
|
|
86
|
+
|
|
87
|
+
def request(self, method: str, params: Mapping[str, Any]) -> dict[str, Any]:
|
|
88
|
+
self._next_id += 1
|
|
89
|
+
request_id = self._next_id
|
|
90
|
+
self._send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": dict(params)})
|
|
91
|
+
deadline = time.monotonic() + self.timeout
|
|
92
|
+
while time.monotonic() < deadline:
|
|
93
|
+
try:
|
|
94
|
+
message = self._messages.get(timeout=0.5)
|
|
95
|
+
except queue.Empty:
|
|
96
|
+
if self._process and self._process.poll() is not None:
|
|
97
|
+
raise CanvaMCPError("O processo mcp-remote terminou antes de responder.")
|
|
98
|
+
continue
|
|
99
|
+
if message.get("id") != request_id:
|
|
100
|
+
continue
|
|
101
|
+
if message.get("error"):
|
|
102
|
+
error = message["error"]
|
|
103
|
+
raise CanvaMCPError(str(error.get("message") or error))
|
|
104
|
+
result = message.get("result")
|
|
105
|
+
return result if isinstance(result, dict) else {"result": result}
|
|
106
|
+
raise CanvaMCPError(f"Timeout na operação MCP {method}.")
|
|
107
|
+
|
|
108
|
+
def tools(self) -> list[dict[str, Any]]:
|
|
109
|
+
result = self.request("tools/list", {})
|
|
110
|
+
tools = result.get("tools")
|
|
111
|
+
return [dict(tool) for tool in tools if isinstance(tool, Mapping)] if isinstance(tools, list) else []
|
|
112
|
+
|
|
113
|
+
def call(self, name: str, arguments: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
114
|
+
result = self.request("tools/call", {"name": name, "arguments": dict(arguments or {})})
|
|
115
|
+
if result.get("isError"):
|
|
116
|
+
raise CanvaMCPError(self._text(result.get("content")) or "A ferramenta Canva devolveu erro.")
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _text(content: Any) -> str:
|
|
121
|
+
if isinstance(content, list):
|
|
122
|
+
return "\n".join(str(item.get("text") or "") for item in content if isinstance(item, Mapping)).strip()
|
|
123
|
+
return str(content or "").strip()
|
|
124
|
+
|
|
125
|
+
def find_tool(self, *names: str) -> str:
|
|
126
|
+
available = {str(tool.get("name")) for tool in self.tools()}
|
|
127
|
+
for name in names:
|
|
128
|
+
if name in available:
|
|
129
|
+
return name
|
|
130
|
+
raise CanvaMCPError(f"A sessão Canva MCP não disponibiliza nenhuma destas operações: {', '.join(names)}.")
|
|
131
|
+
|
|
132
|
+
def close(self) -> None:
|
|
133
|
+
if self._process and self._process.poll() is None:
|
|
134
|
+
self._process.terminate()
|
|
135
|
+
try:
|
|
136
|
+
self._process.wait(timeout=5)
|
|
137
|
+
except subprocess.TimeoutExpired:
|
|
138
|
+
self._process.kill()
|
|
139
|
+
self._process = None
|
|
140
|
+
|
|
141
|
+
def __enter__(self) -> "CanvaMCPClient":
|
|
142
|
+
self.connect()
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
def __exit__(self, *_: Any) -> None:
|
|
146
|
+
self.close()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = ["CanvaMCPClient", "CanvaMCPError", "MCP_URL"]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from .canva_mcp_client import CanvaMCPClient, CanvaMCPError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _payload(result: Mapping[str, Any]) -> Any:
|
|
13
|
+
if isinstance(result.get("structuredContent"), Mapping):
|
|
14
|
+
return result["structuredContent"]
|
|
15
|
+
content = result.get("content")
|
|
16
|
+
if isinstance(content, list):
|
|
17
|
+
for item in content:
|
|
18
|
+
if isinstance(item, Mapping) and isinstance(item.get("json"), (dict, list)):
|
|
19
|
+
return item["json"]
|
|
20
|
+
if isinstance(item, Mapping) and item.get("text"):
|
|
21
|
+
try:
|
|
22
|
+
return json.loads(str(item["text"]))
|
|
23
|
+
except (TypeError, ValueError):
|
|
24
|
+
continue
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _items(value: Any) -> list[dict[str, Any]]:
|
|
29
|
+
if isinstance(value, Mapping):
|
|
30
|
+
for key in ("items", "designs", "results"):
|
|
31
|
+
if isinstance(value.get(key), list):
|
|
32
|
+
return [dict(item) for item in value[key] if isinstance(item, Mapping)]
|
|
33
|
+
return []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _first_id(value: Any, keys: tuple[str, ...]) -> str:
|
|
37
|
+
if isinstance(value, Mapping):
|
|
38
|
+
for key in keys:
|
|
39
|
+
candidate = str(value.get(key) or "").strip()
|
|
40
|
+
if candidate:
|
|
41
|
+
return candidate
|
|
42
|
+
for child in value.values():
|
|
43
|
+
found = _first_id(child, keys)
|
|
44
|
+
if found:
|
|
45
|
+
return found
|
|
46
|
+
elif isinstance(value, list):
|
|
47
|
+
for child in value:
|
|
48
|
+
found = _first_id(child, keys)
|
|
49
|
+
if found:
|
|
50
|
+
return found
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _first_text_element(value: Any) -> str:
|
|
55
|
+
if isinstance(value, Mapping):
|
|
56
|
+
for key in ("element_id", "id"):
|
|
57
|
+
candidate = str(value.get(key) or "").strip()
|
|
58
|
+
if candidate and any(token in str(value).lower() for token in ("text", "richtext", "content")):
|
|
59
|
+
return candidate
|
|
60
|
+
for child in value.values():
|
|
61
|
+
found = _first_text_element(child)
|
|
62
|
+
if found:
|
|
63
|
+
return found
|
|
64
|
+
elif isinstance(value, list):
|
|
65
|
+
for child in value:
|
|
66
|
+
found = _first_text_element(child)
|
|
67
|
+
if found:
|
|
68
|
+
return found
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _first_page(value: Any) -> tuple[str, list[dict[str, Any]]]:
|
|
73
|
+
pages: list[dict[str, Any]] = []
|
|
74
|
+
if isinstance(value, Mapping) and isinstance(value.get("pages"), list):
|
|
75
|
+
pages = [dict(page) for page in value["pages"] if isinstance(page, Mapping)]
|
|
76
|
+
page_id = str((pages[0] if pages else {}).get("page_id") or "").strip()
|
|
77
|
+
return page_id, pages
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run_direct_canva_thumbnail(
|
|
81
|
+
*,
|
|
82
|
+
title: str,
|
|
83
|
+
topic: str,
|
|
84
|
+
prompt: str,
|
|
85
|
+
blueprint: Mapping[str, Any],
|
|
86
|
+
destination: Path,
|
|
87
|
+
width: int,
|
|
88
|
+
height: int,
|
|
89
|
+
quality: str = "medium",
|
|
90
|
+
) -> Path:
|
|
91
|
+
"""Run the user-triggered Canva MCP flow in the local Thunderbolt process."""
|
|
92
|
+
with CanvaMCPClient() as client:
|
|
93
|
+
available = {str(item.get("name")) for item in client.tools()}
|
|
94
|
+
search_name = "search-designs" if "search-designs" in available else "search_designs"
|
|
95
|
+
result = _payload(client.call(search_name, {
|
|
96
|
+
"query": " ".join(word for word in f"{title} {topic}".split() if len(word) >= 3)[:255],
|
|
97
|
+
"sort_by": "relevance",
|
|
98
|
+
"ownership": "any",
|
|
99
|
+
"limit": 25,
|
|
100
|
+
"user_intent": "Find an existing Canva design to edit as a Thunderbolt thumbnail.",
|
|
101
|
+
}))
|
|
102
|
+
candidates = _items(result)
|
|
103
|
+
if not candidates:
|
|
104
|
+
raise CanvaMCPError("Canva não encontrou designs para as palavras-chave da thumbnail.")
|
|
105
|
+
selected = candidates[0]
|
|
106
|
+
design_id = str(selected.get("id") or "").strip()
|
|
107
|
+
if not design_id:
|
|
108
|
+
raise CanvaMCPError("A pesquisa Canva devolveu um design sem ID.")
|
|
109
|
+
|
|
110
|
+
get_name = "get-design-content" if "get-design-content" in available else "get_design_content"
|
|
111
|
+
content = _payload(client.call(get_name, {
|
|
112
|
+
"design_id": design_id,
|
|
113
|
+
"user_intent": "Inspect the selected Canva design before applying the thumbnail edit.",
|
|
114
|
+
}))
|
|
115
|
+
start_name = "start-editing-transaction" if "start-editing-transaction" in available else "start_editing_transaction"
|
|
116
|
+
started = _payload(client.call(start_name, {
|
|
117
|
+
"design_id": design_id,
|
|
118
|
+
"user_intent": "Edit the selected design according to the local Thunderbolt Thumbnail Blueprint.",
|
|
119
|
+
}))
|
|
120
|
+
transaction_id = _first_id(started, ("transaction_id",))
|
|
121
|
+
if not transaction_id:
|
|
122
|
+
raise CanvaMCPError("Canva não devolveu uma transacção de edição.")
|
|
123
|
+
page_id, pages = _first_page(started)
|
|
124
|
+
element_id = _first_text_element(started) or _first_text_element(content)
|
|
125
|
+
if not element_id:
|
|
126
|
+
raise CanvaMCPError("O design Canva não devolveu um elemento de texto editável.")
|
|
127
|
+
perform_name = "perform-editing-operations" if "perform-editing-operations" in available else "perform_editing_operations"
|
|
128
|
+
operations = [{"type": "replace_text", "element_id": element_id, "text": title[:255]}]
|
|
129
|
+
performed = _payload(client.call(perform_name, {
|
|
130
|
+
"transaction_id": transaction_id,
|
|
131
|
+
"operations": operations,
|
|
132
|
+
"page_index": 1,
|
|
133
|
+
"pages": pages,
|
|
134
|
+
"user_intent": f"Apply this local Thumbnail Blueprint to the thumbnail: {blueprint.get('id', '')}. Prompt: {prompt[:1000]}",
|
|
135
|
+
}))
|
|
136
|
+
commit_name = "commit-editing-transaction" if "commit-editing-transaction" in available else "commit_editing_transaction"
|
|
137
|
+
client.call(commit_name, {
|
|
138
|
+
"transaction_id": transaction_id,
|
|
139
|
+
"user_intent": "Save the approved thumbnail lettering edit before export.",
|
|
140
|
+
})
|
|
141
|
+
formats_name = "get-export-formats" if "get-export-formats" in available else "get_export_formats"
|
|
142
|
+
formats = _payload(client.call(formats_name, {
|
|
143
|
+
"design_id": design_id,
|
|
144
|
+
"user_intent": "Verify PNG export support before exporting the finished thumbnail.",
|
|
145
|
+
}))
|
|
146
|
+
format_text = json.dumps(formats, ensure_ascii=False).lower()
|
|
147
|
+
if '"png"' not in format_text:
|
|
148
|
+
raise CanvaMCPError("O design Canva seleccionado não suporta exportação PNG.")
|
|
149
|
+
export_name = "export-design" if "export-design" in available else "export_design"
|
|
150
|
+
exported = _payload(client.call(export_name, {
|
|
151
|
+
"design_id": design_id,
|
|
152
|
+
"format": {
|
|
153
|
+
"type": "png",
|
|
154
|
+
"width": width,
|
|
155
|
+
"height": height,
|
|
156
|
+
"export_quality": "pro" if quality == "high" else "regular",
|
|
157
|
+
"lossless": True,
|
|
158
|
+
"pages": [1],
|
|
159
|
+
},
|
|
160
|
+
"user_intent": "Export the finished Thunderbolt thumbnail as PNG.",
|
|
161
|
+
}))
|
|
162
|
+
url = _first_id(exported, ("url", "download_url"))
|
|
163
|
+
if not url:
|
|
164
|
+
raise CanvaMCPError("Canva não devolveu URL de download do PNG.")
|
|
165
|
+
response = requests.get(url, timeout=90)
|
|
166
|
+
response.raise_for_status()
|
|
167
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
destination.write_bytes(response.content)
|
|
169
|
+
return destination
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
__all__ = ["run_direct_canva_thumbnail"]
|
|
@@ -41,6 +41,7 @@ from .provider_routing import (
|
|
|
41
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
43
|
from .canva_client import CanvaClient
|
|
44
|
+
from .canva_mcp_workflow import run_direct_canva_thumbnail
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
class MediaGenerationError(RuntimeError):
|
|
@@ -324,6 +325,7 @@ def generate_image_for_card(
|
|
|
324
325
|
lettering_text: str = "",
|
|
325
326
|
lettering_prompt: str = "",
|
|
326
327
|
reference_image: Path | None = None,
|
|
328
|
+
thumbnail_blueprint: Mapping[str, Any] | None = None,
|
|
327
329
|
) -> Path:
|
|
328
330
|
"""Generate one image with the selected media card."""
|
|
329
331
|
card = dict(card)
|
|
@@ -333,6 +335,23 @@ def generate_image_for_card(
|
|
|
333
335
|
export_format = str(card.get("export_format") or "png").lower()
|
|
334
336
|
destination = STORAGE / "thumbnails" / f"canva-{abs(hash((topic, prompt, variant_index))) & 0xffffffffffffffff:x}.{export_format if export_format in {'png', 'jpg'} else 'png'}"
|
|
335
337
|
card["output_path"] = str(destination)
|
|
338
|
+
blueprint = dict(thumbnail_blueprint or {})
|
|
339
|
+
if not str(blueprint.get("content") or "").strip():
|
|
340
|
+
raise MediaGenerationError("Canva exige um Thumbnail Blueprint local válido.")
|
|
341
|
+
try:
|
|
342
|
+
return run_direct_canva_thumbnail(
|
|
343
|
+
title=str(topic or "Thunderbolt thumbnail")[:255],
|
|
344
|
+
topic=topic,
|
|
345
|
+
prompt=prompt,
|
|
346
|
+
blueprint=blueprint,
|
|
347
|
+
destination=destination,
|
|
348
|
+
width=int(card.get("thumbnail_width") or 1280),
|
|
349
|
+
height=int(card.get("thumbnail_height") or 720),
|
|
350
|
+
quality=str(card.get("export_quality") or "medium"),
|
|
351
|
+
)
|
|
352
|
+
except Exception as exc:
|
|
353
|
+
raise MediaGenerationError(str(exc)) from exc
|
|
354
|
+
|
|
336
355
|
def save_token(token: Mapping[str, Any]) -> None:
|
|
337
356
|
updated = dict(settings)
|
|
338
357
|
saved_cards = [dict(item) for item in updated.get("media_provider_cards", []) if isinstance(item, Mapping)]
|
|
@@ -417,6 +436,7 @@ def generate_image_from_pool(
|
|
|
417
436
|
lettering_prompt: str = "",
|
|
418
437
|
reference_image: Path | None = None,
|
|
419
438
|
thumbnail_only: bool = False,
|
|
439
|
+
thumbnail_blueprint: Mapping[str, Any] | None = None,
|
|
420
440
|
) -> Path:
|
|
421
441
|
"""Try eligible image cards in priority order, without cross-pool fallback."""
|
|
422
442
|
cards = media_cards_for_pool(settings, "image", thumbnail_only=True) if thumbnail_only else media_cards_for_pool(settings, "image")
|
|
@@ -434,6 +454,7 @@ def generate_image_from_pool(
|
|
|
434
454
|
lettering_text=lettering_text,
|
|
435
455
|
lettering_prompt=lettering_prompt,
|
|
436
456
|
reference_image=reference_image,
|
|
457
|
+
thumbnail_blueprint=thumbnail_blueprint,
|
|
437
458
|
)
|
|
438
459
|
except MediaGenerationError as exc:
|
|
439
460
|
errors.append(f"{card.get('provider')}: {str(exc)[:180]}")
|
|
@@ -4,8 +4,6 @@ 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"
|
|
8
|
-
os.environ["PYTHONLEGACYWINDOWSSTDIO"] = "1"
|
|
9
7
|
_original_stdout, _original_stderr = sys.stdout, sys.stderr
|
|
10
8
|
if getattr(sys.stdout, "buffer", None) is not None and str(getattr(sys.stdout, "encoding", "") or "").lower().replace("-", "_") != "utf_8":
|
|
11
9
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace", line_buffering=True)
|
|
@@ -84,6 +82,7 @@ def _generate_pipeline_thumbnail(
|
|
|
84
82
|
variant_index: int = 0,
|
|
85
83
|
lettering_text: str = "",
|
|
86
84
|
lettering_prompt: str = "",
|
|
85
|
+
thumbnail_blueprint: dict[str, Any] | None = None,
|
|
87
86
|
) -> Path:
|
|
88
87
|
"""Use the image pool while keeping the legacy single-Nano call seam."""
|
|
89
88
|
cards = media_cards_for_pool(settings, "image")
|
|
@@ -103,6 +102,7 @@ def _generate_pipeline_thumbnail(
|
|
|
103
102
|
variant_index=variant_index,
|
|
104
103
|
lettering_text=lettering_text,
|
|
105
104
|
lettering_prompt=lettering_prompt,
|
|
105
|
+
thumbnail_blueprint=thumbnail_blueprint,
|
|
106
106
|
)
|
|
107
107
|
|
|
108
108
|
|
|
@@ -1494,6 +1494,7 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
|
1494
1494
|
variant_index=0,
|
|
1495
1495
|
lettering_text=str(variant.get("overlay_text") or ""),
|
|
1496
1496
|
lettering_prompt=str(variant.get("lettering_prompt") or ""),
|
|
1497
|
+
thumbnail_blueprint=blueprint,
|
|
1497
1498
|
)
|
|
1498
1499
|
except (MediaGenerationError, ThumbnailGenerationError) as exc:
|
|
1499
1500
|
raise PipelineError(f"A thumbnail não foi gerada; o vídeo já está disponível em {video_path}: {exc}") from exc
|
package/hermes_ui/thumbnails.py
CHANGED
|
@@ -24,6 +24,7 @@ def _generate_image_with_pool(
|
|
|
24
24
|
lettering_text: str = "",
|
|
25
25
|
lettering_prompt: str = "",
|
|
26
26
|
reference_image: Path | None = None,
|
|
27
|
+
thumbnail_blueprint: dict[str, Any] | None = None,
|
|
27
28
|
) -> Path:
|
|
28
29
|
"""Use the legacy Nano adapter when it is the only active image card.
|
|
29
30
|
|
|
@@ -50,6 +51,7 @@ def _generate_image_with_pool(
|
|
|
50
51
|
lettering_text=lettering_text,
|
|
51
52
|
lettering_prompt=lettering_prompt,
|
|
52
53
|
thumbnail_only=True,
|
|
54
|
+
thumbnail_blueprint=thumbnail_blueprint,
|
|
53
55
|
)
|
|
54
56
|
|
|
55
57
|
|
|
@@ -260,7 +262,26 @@ def generate_thumbnail_for_task(
|
|
|
260
262
|
raise ThumbnailGenerationError("A thumbnail não tem um prompt de imagem para gerar.")
|
|
261
263
|
_archive_image(str(task_id), record.get("image_path"))
|
|
262
264
|
visual_prompt = record["prompt"]
|
|
263
|
-
|
|
265
|
+
effective_blueprint = dict(thumbnail_blueprint or {})
|
|
266
|
+
configured_cards = settings.get("media_provider_cards", []) if isinstance(settings, dict) else []
|
|
267
|
+
canva_selected = any(
|
|
268
|
+
isinstance(item, dict)
|
|
269
|
+
and str(item.get("provider") or "").lower() == "canva"
|
|
270
|
+
and bool(item.get("enabled", True))
|
|
271
|
+
for item in configured_cards
|
|
272
|
+
) if isinstance(configured_cards, list) else False
|
|
273
|
+
if not effective_blueprint and canva_selected:
|
|
274
|
+
channels = read_json("channels.json", [])
|
|
275
|
+
channel = next(
|
|
276
|
+
(item for item in channels if isinstance(item, dict) and str(item.get("id") or "") == record["channel_id"]),
|
|
277
|
+
{},
|
|
278
|
+
) if isinstance(channels, list) else {}
|
|
279
|
+
effective_blueprint = thumbnail_blueprint_for_channel(
|
|
280
|
+
{**channel, "thumbnail_blueprint_id": record["thumbnail_blueprint_id"] or channel.get("thumbnail_blueprint_id", "")}
|
|
281
|
+
)
|
|
282
|
+
rules = str(effective_blueprint.get("content") or "").strip()
|
|
283
|
+
if canva_selected and not rules:
|
|
284
|
+
raise ThumbnailGenerationError("A tarefa não tem um Thumbnail Blueprint local válido.")
|
|
264
285
|
if rules:
|
|
265
286
|
visual_prompt = f"{visual_prompt}\n\nTHUMBNAIL BLUEPRINT — FOLLOW THESE VISUAL RULES:\n{rules}"
|
|
266
287
|
image_path = _generate_image_with_pool(
|
|
@@ -270,6 +291,7 @@ def generate_thumbnail_for_task(
|
|
|
270
291
|
variant_index=record["variant_index"],
|
|
271
292
|
lettering_text=record.get("thumbnail_text") or "",
|
|
272
293
|
lettering_prompt=record.get("lettering_prompt") or "",
|
|
294
|
+
thumbnail_blueprint=effective_blueprint,
|
|
273
295
|
)
|
|
274
296
|
updated = _update_thumbnail_task(
|
|
275
297
|
task_id,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danhachuel/thunderbolt",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.11",
|
|
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",
|
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
"seed/references/*.md",
|
|
26
26
|
"seed/references/*.sql",
|
|
27
27
|
"README.md",
|
|
28
|
-
"CHANGELOG.md",
|
|
29
28
|
"MANUAL-INSTALACAO.md",
|
|
30
29
|
"THIRD-PARTY-NOTICES.md",
|
|
31
30
|
"requirements.txt",
|
package/scripts/cli.mjs
CHANGED
|
@@ -7,12 +7,6 @@ import { homedir, platform } from "node:os";
|
|
|
7
7
|
import { join, resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
|
|
10
|
-
// Definir no processo pai para Click/Streamlit herdarem as opções no Windows.
|
|
11
|
-
process.env.CLICK_NO_WIN_CONSOLE = "1";
|
|
12
|
-
process.env.PYTHONIOENCODING = "utf-8";
|
|
13
|
-
process.env.PYTHONUTF8 = "1";
|
|
14
|
-
process.env.PYTHONLEGACYWINDOWSSTDIO = "1";
|
|
15
|
-
|
|
16
10
|
const root = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
|
|
17
11
|
const args = process.argv.slice(2);
|
|
18
12
|
|
package/CHANGELOG.md
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.5.08 (normalizado pelo npm para 0.5.8)
|
|
4
|
-
|
|
5
|
-
Corrigido o erro `LookupError: unknown encoding: utf-16-le` que podia ocorrer durante o shutdown do Streamlit no Windows. O launcher Node agora materializa as variáveis de encoding e `CLICK_NO_WIN_CONSOLE` no processo pai antes de iniciar Python; os workers de automação e pipeline também aplicam as mesmas definições antes dos imports restantes.
|