@danhachuel/thunderbolt 0.3.41 → 0.3.43

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.
@@ -0,0 +1,282 @@
1
+ """Configuration schema for independent image and video provider pools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Mapping
7
+
8
+
9
+ MEDIA_CARDS_KEY = "media_provider_cards"
10
+ MEDIA_IMAGE_ACTIVE_CARD_KEY = "media_image_active_card_id"
11
+ MEDIA_VIDEO_ACTIVE_CARD_KEY = "media_video_active_card_id"
12
+ MEDIA_IMAGE_ACTIVE_PROVIDER_KEY = "media_image_provider"
13
+ MEDIA_VIDEO_ACTIVE_PROVIDER_KEY = "media_video_provider"
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class MediaProviderDefinition:
18
+ code: str
19
+ label: str
20
+ default_base_url: str = ""
21
+ requires_api_key: bool = True
22
+ supports_image: bool = False
23
+ supports_video: bool = False
24
+ supports_text: bool = False
25
+ local: bool = False
26
+ api_style: str = "custom"
27
+ extra_fields: tuple[str, ...] = ()
28
+ description: str = ""
29
+
30
+
31
+ MEDIA_PROVIDER_CATALOG: tuple[MediaProviderDefinition, ...] = (
32
+ MediaProviderDefinition(
33
+ "nano_banana",
34
+ "Nano Banana",
35
+ default_base_url="https://generativelanguage.googleapis.com/v1beta",
36
+ supports_image=True,
37
+ api_style="gemini_interactions",
38
+ extra_fields=("aspect_ratio", "image_size"),
39
+ description="Geração de imagem nativa Gemini para thumbnails e artes.",
40
+ ),
41
+ MediaProviderDefinition(
42
+ "pollinations",
43
+ "Pollinations.ai",
44
+ default_base_url="https://gen.pollinations.ai/v1",
45
+ supports_image=True,
46
+ supports_video=True,
47
+ supports_text=True,
48
+ api_style="openai_compatible",
49
+ description="Gateway multimodal com endpoints compatíveis e catálogo próprio.",
50
+ ),
51
+ MediaProviderDefinition(
52
+ "agnes",
53
+ "Agnes AI",
54
+ default_base_url="https://apihub.agnes-ai.com/v1",
55
+ supports_image=True,
56
+ supports_video=True,
57
+ supports_text=True,
58
+ api_style="agnes",
59
+ description="Gateway multimodal Agnes para texto, imagem e vídeo.",
60
+ ),
61
+ MediaProviderDefinition(
62
+ "huggingface",
63
+ "Hugging Face Inference API",
64
+ default_base_url="https://router.huggingface.co/v1",
65
+ supports_image=True,
66
+ supports_text=True,
67
+ api_style="huggingface",
68
+ description="Inference Providers; a capacidade depende do modelo seleccionado.",
69
+ ),
70
+ MediaProviderDefinition(
71
+ "cloudflare_workers_ai",
72
+ "Cloudflare Workers AI",
73
+ default_base_url="https://api.cloudflare.com/client/v4",
74
+ supports_image=True,
75
+ api_style="cloudflare",
76
+ extra_fields=("account_id",),
77
+ description="Workers AI com Account ID e API token; a rota de modelo é derivada.",
78
+ ),
79
+ MediaProviderDefinition(
80
+ "inferenceport",
81
+ "InferencePort Proxy",
82
+ default_base_url="http://localhost:8080/v1",
83
+ requires_api_key=False,
84
+ supports_image=True,
85
+ supports_video=True,
86
+ supports_text=True,
87
+ local=True,
88
+ api_style="openai_compatible",
89
+ description="Proxy local OpenAI-compatible; não exige API key por defeito.",
90
+ ),
91
+ MediaProviderDefinition(
92
+ "alibaba_cloud",
93
+ "阿里云 (Alibaba Cloud Model Studio)",
94
+ default_base_url="https://dashscope-intl.aliyuncs.com/api/v1",
95
+ supports_image=True,
96
+ supports_video=True,
97
+ supports_text=True,
98
+ api_style="dashscope",
99
+ extra_fields=("region",),
100
+ description="DashScope/Model Studio; tarefas de imagem e vídeo podem ser assíncronas.",
101
+ ),
102
+ MediaProviderDefinition(
103
+ "kie_ai",
104
+ "KIE AI",
105
+ default_base_url="https://api.kie.ai/api/v1",
106
+ supports_image=True,
107
+ supports_video=True,
108
+ supports_text=True,
109
+ api_style="kie",
110
+ description="Gateway multimodal com tarefas e consulta de resultados.",
111
+ ),
112
+ MediaProviderDefinition(
113
+ "fal_ai",
114
+ "FAL AI",
115
+ default_base_url="https://queue.fal.run",
116
+ supports_image=True,
117
+ supports_video=True,
118
+ api_style="fal_queue",
119
+ description="Model APIs da FAL com queue, status e resultado.",
120
+ ),
121
+ )
122
+
123
+ _MEDIA_BY_CODE = {item.code: item for item in MEDIA_PROVIDER_CATALOG}
124
+
125
+
126
+ def media_provider_catalog() -> list[dict[str, Any]]:
127
+ return [
128
+ {
129
+ "code": item.code,
130
+ "label": item.label,
131
+ "default_base_url": item.default_base_url,
132
+ "requires_api_key": item.requires_api_key,
133
+ "supports_image": item.supports_image,
134
+ "supports_video": item.supports_video,
135
+ "supports_text": item.supports_text,
136
+ "local": item.local,
137
+ "api_style": item.api_style,
138
+ "extra_fields": item.extra_fields,
139
+ "description": item.description,
140
+ }
141
+ for item in MEDIA_PROVIDER_CATALOG
142
+ ]
143
+
144
+
145
+ def media_provider_definition(provider: Any) -> MediaProviderDefinition:
146
+ code = str(provider or "").strip().lower()
147
+ return _MEDIA_BY_CODE.get(code, _MEDIA_BY_CODE["inferenceport"])
148
+
149
+
150
+ def normalize_media_card(card: Any, index: int = 0) -> dict[str, Any]:
151
+ source = dict(card) if isinstance(card, Mapping) else {}
152
+ provider = str(source.get("provider") or "nano_banana").strip().lower()
153
+ definition = media_provider_definition(provider)
154
+ if provider not in _MEDIA_BY_CODE:
155
+ provider = definition.code
156
+ card_id = str(source.get("id") or f"media-{provider}-{index + 1}").strip()
157
+ result: dict[str, Any] = {
158
+ "id": card_id,
159
+ "provider": provider,
160
+ "api_key": str(source.get("api_key") or source.get("key") or "").strip(),
161
+ "model": str(source.get("model") or source.get("model_name") or "").strip(),
162
+ "base_url": str(source.get("base_url") or "").strip() or definition.default_base_url,
163
+ "enabled": bool(source.get("enabled", True)),
164
+ "priority": max(0, int(source.get("priority", index)) if str(source.get("priority", index)).strip().lstrip("-").isdigit() else index),
165
+ "supports_image": bool(source.get("supports_image", definition.supports_image)),
166
+ "supports_video": bool(source.get("supports_video", definition.supports_video)),
167
+ "supports_text": bool(source.get("supports_text", definition.supports_text)),
168
+ "api_style": definition.api_style,
169
+ "local": definition.local,
170
+ }
171
+ for field in definition.extra_fields:
172
+ result[field] = str(source.get(field) or "").strip()
173
+ test_result = source.get("test_result")
174
+ if isinstance(test_result, Mapping) and str(test_result.get("status") or "") in {"success", "error"}:
175
+ result["test_result"] = {
176
+ "status": str(test_result.get("status")),
177
+ "message": str(test_result.get("message") or "")[:240],
178
+ "tested_at": str(test_result.get("tested_at") or "")[:64],
179
+ }
180
+ return result
181
+
182
+
183
+ def new_media_card(provider: Any, *, card_id: str | None = None) -> dict[str, Any]:
184
+ code = str(provider or "").strip().lower()
185
+ if code not in _MEDIA_BY_CODE:
186
+ raise ValueError("Provider de imagem/vídeo inválido.")
187
+ definition = _MEDIA_BY_CODE[code]
188
+ return normalize_media_card(
189
+ {
190
+ "id": card_id or f"media-{code}-1",
191
+ "provider": code,
192
+ "model": "gemini-3.1-flash-image" if code == "nano_banana" else "",
193
+ "base_url": definition.default_base_url,
194
+ "enabled": True,
195
+ }
196
+ )
197
+
198
+
199
+ def ensure_media_provider_cards(settings: Mapping[str, Any]) -> tuple[dict[str, Any], bool]:
200
+ result = dict(settings)
201
+ raw_cards = result.get(MEDIA_CARDS_KEY)
202
+ changed = False
203
+ if isinstance(raw_cards, list) and raw_cards:
204
+ cards = [normalize_media_card(item, index) for index, item in enumerate(raw_cards)]
205
+ changed = cards != raw_cards
206
+ else:
207
+ cards: list[dict[str, Any]] = []
208
+ legacy_key = str(result.get("gemini_image_api_key") or "").strip()
209
+ legacy_model = str(result.get("gemini_image_model") or "gemini-3.1-flash-image").strip()
210
+ cards.append(
211
+ normalize_media_card(
212
+ {
213
+ "id": "media-nano-banana-default",
214
+ "provider": "nano_banana",
215
+ "api_key": legacy_key,
216
+ "model": legacy_model,
217
+ "base_url": "https://generativelanguage.googleapis.com/v1beta",
218
+ "aspect_ratio": str(result.get("gemini_image_aspect_ratio") or "16:9"),
219
+ "image_size": str(result.get("gemini_image_size") or "1K"),
220
+ "enabled": True,
221
+ },
222
+ 0,
223
+ )
224
+ )
225
+ changed = True
226
+ result[MEDIA_CARDS_KEY] = cards
227
+
228
+ for pool, key, capability in (
229
+ ("image", MEDIA_IMAGE_ACTIVE_CARD_KEY, "supports_image"),
230
+ ("video", MEDIA_VIDEO_ACTIVE_CARD_KEY, "supports_video"),
231
+ ):
232
+ active_id = str(result.get(key) or "").strip()
233
+ valid = [card for card in cards if card.get(capability) and card.get("enabled", True)]
234
+ if active_id not in {str(card.get("id")) for card in cards} or not any(str(card.get("id")) == active_id and card.get(capability) and card.get("enabled", True) for card in cards):
235
+ active_id = str(valid[0].get("id")) if valid else ""
236
+ if result.get(key) != active_id:
237
+ result[key] = active_id
238
+ changed = True
239
+ legacy_provider_key = MEDIA_IMAGE_ACTIVE_PROVIDER_KEY if pool == "image" else MEDIA_VIDEO_ACTIVE_PROVIDER_KEY
240
+ active_card = next((card for card in cards if str(card.get("id")) == active_id), None)
241
+ active_provider = str(active_card.get("provider") or "") if active_card else ""
242
+ if result.get(legacy_provider_key) != active_provider:
243
+ result[legacy_provider_key] = active_provider
244
+ changed = True
245
+ return result, changed
246
+
247
+
248
+ def apply_media_provider_cards_to_settings(settings: Mapping[str, Any], cards: list[Mapping[str, Any]], image_active_id: str = "", video_active_id: str = "") -> dict[str, Any]:
249
+ result = dict(settings)
250
+ normalized = [normalize_media_card(item, index) for index, item in enumerate(cards)]
251
+ if not normalized:
252
+ normalized = [new_media_card("nano_banana", card_id="media-nano-banana-default")]
253
+ result[MEDIA_CARDS_KEY] = normalized
254
+ for key, wanted, capability in (
255
+ (MEDIA_IMAGE_ACTIVE_CARD_KEY, image_active_id, "supports_image"),
256
+ (MEDIA_VIDEO_ACTIVE_CARD_KEY, video_active_id, "supports_video"),
257
+ ):
258
+ selected = next((card for card in normalized if str(card.get("id")) == str(wanted) and card.get(capability) and card.get("enabled", True)), None)
259
+ if selected is None:
260
+ selected = next((card for card in normalized if card.get(capability) and card.get("enabled", True)), None)
261
+ result[key] = str(selected.get("id")) if selected else ""
262
+ image_card = next((card for card in normalized if str(card.get("id")) == result[MEDIA_IMAGE_ACTIVE_CARD_KEY]), None)
263
+ video_card = next((card for card in normalized if str(card.get("id")) == result[MEDIA_VIDEO_ACTIVE_CARD_KEY]), None)
264
+ result[MEDIA_IMAGE_ACTIVE_PROVIDER_KEY] = str(image_card.get("provider") or "") if image_card else ""
265
+ result[MEDIA_VIDEO_ACTIVE_PROVIDER_KEY] = str(video_card.get("provider") or "") if video_card else ""
266
+ nano_card = next((card for card in normalized if card.get("provider") == "nano_banana"), None)
267
+ if nano_card:
268
+ result["gemini_image_api_key"] = str(nano_card.get("api_key") or "")
269
+ result["gemini_image_model"] = str(nano_card.get("model") or "gemini-3.1-flash-image")
270
+ result["gemini_image_aspect_ratio"] = str(nano_card.get("aspect_ratio") or result.get("gemini_image_aspect_ratio") or "16:9")
271
+ result["gemini_image_size"] = str(nano_card.get("image_size") or result.get("gemini_image_size") or "1K")
272
+ return result
273
+
274
+
275
+ def media_cards_for_pool(settings: Mapping[str, Any], pool: str) -> list[dict[str, Any]]:
276
+ migrated, _ = ensure_media_provider_cards(settings)
277
+ capability = "supports_image" if pool == "image" else "supports_video" if pool == "video" else "supports_text"
278
+ active_key = MEDIA_IMAGE_ACTIVE_CARD_KEY if pool == "image" else MEDIA_VIDEO_ACTIVE_CARD_KEY if pool == "video" else ""
279
+ active_id = str(migrated.get(active_key) or "")
280
+ cards = [dict(item) for item in migrated.get(MEDIA_CARDS_KEY, []) if item.get("enabled", True) and item.get(capability)]
281
+ cards.sort(key=lambda card: (0 if str(card.get("id")) == active_id else 1, int(card.get("priority", 0))))
282
+ return cards
@@ -17,6 +17,8 @@ from hermes_ui.script_documents import save_script_document
17
17
  from hermes_ui.script_generation import generate_script_document
18
18
  from hermes_ui.storage import STORAGE, ensure_storage, read_json, write_json
19
19
  from hermes_ui.llm_providers import active_llm_card, provider_definition
20
+ from hermes_ui.media_generation import MediaGenerationError, generate_image_from_pool, generate_video_from_pool
21
+ from hermes_ui.media_providers import media_cards_for_pool
20
22
  from hermes_ui.thumbnail_generation import generate_thumbnail_image
21
23
 
22
24
  PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
@@ -43,6 +45,36 @@ def _settings() -> dict[str, Any]:
43
45
  return value if isinstance(value, dict) else {}
44
46
 
45
47
 
48
+ def _generate_pipeline_thumbnail(
49
+ settings: dict[str, Any],
50
+ prompt: str,
51
+ *,
52
+ topic: str,
53
+ variant_index: int = 0,
54
+ lettering_text: str = "",
55
+ lettering_prompt: str = "",
56
+ ) -> Path:
57
+ """Use the image pool while keeping the legacy single-Nano call seam."""
58
+ cards = media_cards_for_pool(settings, "image")
59
+ if len(cards) == 1 and str(cards[0].get("provider") or "") == "nano_banana":
60
+ return generate_thumbnail_image(
61
+ settings,
62
+ prompt,
63
+ topic=topic,
64
+ variant_index=variant_index,
65
+ lettering_text=lettering_text,
66
+ lettering_prompt=lettering_prompt,
67
+ )
68
+ return generate_image_from_pool(
69
+ settings,
70
+ prompt,
71
+ topic=topic,
72
+ variant_index=variant_index,
73
+ lettering_text=lettering_text,
74
+ lettering_prompt=lettering_prompt,
75
+ )
76
+
77
+
46
78
  def _lock_path() -> Path:
47
79
  ensure_storage()
48
80
  return STORAGE / "state" / PIPELINE_LOCK_FILENAME
@@ -470,8 +502,26 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
470
502
  _update(task_id, artifacts=artifacts, progress=30)
471
503
 
472
504
  _update(task_id, stage="title", state="doing", progress=35)
473
- creative = generate_creative_package(settings, channel, topic, blueprint, language=str(task.get("language") or channel.get("language") or "Português"))
474
- title = str(creative.get("title") or topic).strip()
505
+ provided_title = str(task.get("title") or "").strip()
506
+ existing_variant = task.get("thumbnail_variant") if isinstance(task.get("thumbnail_variant"), dict) else {}
507
+ existing_variant = dict(existing_variant)
508
+ if not str(existing_variant.get("image_prompt") or "").strip() and str(task.get("thumbnail_prompt") or "").strip():
509
+ existing_variant["image_prompt"] = str(task.get("thumbnail_prompt") or "").strip()
510
+ if not str(existing_variant.get("overlay_text") or "").strip() and str(task.get("thumbnail_text") or "").strip():
511
+ existing_variant["overlay_text"] = str(task.get("thumbnail_text") or "").strip()
512
+ prepared_thumbnail = bool(str(existing_variant.get("image_prompt") or "").strip())
513
+ if provided_title and prepared_thumbnail:
514
+ # The creation UI already generated the title/thumbnail brief. Do not call
515
+ # the complete creative-package generator a second time for this task.
516
+ creative = {
517
+ "title": provided_title,
518
+ "keywords": [],
519
+ "thumbnail_variant": existing_variant,
520
+ "title_candidates": task.get("title_candidates") if isinstance(task.get("title_candidates"), list) else [],
521
+ }
522
+ else:
523
+ creative = generate_creative_package(settings, channel, topic, blueprint, language=str(task.get("language") or channel.get("language") or "Português"))
524
+ title = str(creative.get("title") or provided_title or topic).strip()
475
525
  provided_keywords = generation_settings.get("video_keywords")
476
526
  if isinstance(provided_keywords, str):
477
527
  provided_keywords = re.split(r"[,\n;|]+", provided_keywords)
@@ -482,6 +532,8 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
482
532
 
483
533
  _update(task_id, stage="keywords", state="doing", progress=48)
484
534
  variant = creative.get("thumbnail_variant") if isinstance(creative.get("thumbnail_variant"), dict) else {}
535
+ if not variant and prepared_thumbnail:
536
+ variant = existing_variant
485
537
  prompt_payload = {
486
538
  "topic": topic,
487
539
  "title": title,
@@ -501,7 +553,7 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
501
553
  artifacts = {**artifacts, "thumbnail_prompt_json": prompt_artifact}
502
554
  _update(task_id, stage="thumbnail_prompt", state="doing", progress=52, thumbnail_prompt=str(variant.get("image_prompt") or ""), thumbnail_text=str(variant.get("overlay_text") or ""), thumbnail_status="prompt_ready", artifacts=artifacts)
503
555
  _update(task_id, stage="thumbnail", state="doing", progress=56, thumbnail_prompt=str(variant.get("image_prompt") or ""), thumbnail_text=str(variant.get("overlay_text") or ""), thumbnail_status="prompt_ready", artifacts=artifacts)
504
- thumbnail_path = generate_thumbnail_image(
556
+ thumbnail_path = _generate_pipeline_thumbnail(
505
557
  settings,
506
558
  str(variant.get("image_prompt") or ""),
507
559
  topic=topic,
@@ -513,7 +565,15 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
513
565
  _update(task_id, artifacts=artifacts, thumbnail_status="generated", progress=62)
514
566
 
515
567
  _update(task_id, stage="video", state="doing", progress=68)
516
- video_path = _run_video_helper({**task, "topic": topic})
568
+ video_cards = media_cards_for_pool(settings, "video")
569
+ if bool(settings.get("media_video_pool_enabled")) and video_cards:
570
+ try:
571
+ video_prompt = f"Título: {title}\n\nRoteiro:\n{str(script.get('content') or '')[:12000]}"
572
+ video_path = generate_video_from_pool(settings, video_prompt)
573
+ except MediaGenerationError as exc:
574
+ raise PipelineError(f"Pool de vídeo externo: {exc}") from exc
575
+ else:
576
+ video_path = _run_video_helper({**task, "topic": topic})
517
577
  artifacts["video"] = str(video_path)
518
578
  _update(task_id, artifacts=artifacts, progress=80)
519
579