@danhachuel/thunderbolt 0.5.8 → 0.5.9

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 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
+ ]
@@ -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
@@ -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
- rules = str((thumbnail_blueprint or {}).get("content") or "").strip()
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.08",
3
+ "version": "0.5.9",
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.