@danhachuel/thunderbolt 0.7.48 → 0.7.50

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
@@ -6424,7 +6424,6 @@ def _render_facebook_automation_cards() -> None:
6424
6424
  def render_facebook_automation() -> None:
6425
6425
  st.title("Automação Facebook")
6426
6426
  st.caption("Fluxo local de posts: Ideia/Tema → LLM → Artigo → Google Imagens ou IA → textos e legendas → Upload pela Meta Graph API.")
6427
- st.info("Esta página não usa planilhas nem Google Drive. Os artigos, imagens, legendas e estados ficam no armazenamento local do Thunderbolt.")
6428
6427
  pages = _facebook_pages_for_automation()
6429
6428
  settings = read_json("settings.json", {})
6430
6429
  if not pages:
@@ -324,11 +324,17 @@ def _image_endpoint(card: Mapping[str, Any]) -> str:
324
324
  raise MediaGenerationError(f"O provider {card.get('provider')} não tem endpoint de imagem configurado.")
325
325
 
326
326
 
327
- def _thumbnail_aspect_ratio(thumbnail_blueprint: Mapping[str, Any] | None, prompt: str = "") -> str:
327
+ def _thumbnail_aspect_ratio(
328
+ thumbnail_blueprint: Mapping[str, Any] | None,
329
+ prompt: str = "",
330
+ explicit_aspect_ratio: str = "",
331
+ ) -> str:
332
+ if explicit_aspect_ratio in {"16:9", "9:16"}:
333
+ return explicit_aspect_ratio
328
334
  return infer_thumbnail_aspect_ratio(prompt, dict(thumbnail_blueprint or {}))
329
335
 
330
336
 
331
- def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", lettering_text: str = "", lettering_prompt: str = "", thumbnail_blueprint: Mapping[str, Any] | None = None) -> Any:
337
+ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", lettering_text: str = "", lettering_prompt: str = "", thumbnail_blueprint: Mapping[str, Any] | None = None, aspect_ratio: str = "") -> Any:
332
338
  provider = str(card.get("provider") or "").strip().lower()
333
339
  style = str(card.get("api_style") or media_provider_definition(provider).api_style)
334
340
  endpoint = _image_endpoint(card)
@@ -338,12 +344,12 @@ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", letter
338
344
  lettering_text=lettering_text,
339
345
  lettering_prompt=lettering_prompt,
340
346
  )
341
- aspect_ratio = _thumbnail_aspect_ratio(thumbnail_blueprint, prompt)
342
- requested_size = ("1024x1792" if aspect_ratio == "9:16" else "1792x1024") if provider == "pollinations" else ("720x1280 minimum" if aspect_ratio == "9:16" else "1280x720 minimum")
347
+ resolved_aspect_ratio = _thumbnail_aspect_ratio(thumbnail_blueprint, prompt, aspect_ratio)
348
+ requested_size = ("1024x1792" if resolved_aspect_ratio == "9:16" else "1792x1024") if provider == "pollinations" else ("720x1280 minimum" if resolved_aspect_ratio == "9:16" else "1280x720 minimum")
343
349
  constrained_prompt = _append_generation_constraints(
344
350
  image_prompt,
345
351
  kind="image",
346
- aspect_ratio=aspect_ratio,
352
+ aspect_ratio=resolved_aspect_ratio,
347
353
  size=requested_size,
348
354
  )
349
355
  constrained_prompt = _fit_provider_prompt(constrained_prompt, provider)
@@ -360,7 +366,7 @@ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", letter
360
366
  if style == "kie":
361
367
  body = {
362
368
  "model": _model(card),
363
- "input": {"prompt": constrained_prompt, "aspect_ratio": aspect_ratio, "resolution": "1K"},
369
+ "input": {"prompt": constrained_prompt, "aspect_ratio": resolved_aspect_ratio, "resolution": "1K"},
364
370
  }
365
371
  return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
366
372
  if provider == "agnes":
@@ -368,7 +374,7 @@ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", letter
368
374
  "model": _model(card) or AGNES_IMAGE_MODEL,
369
375
  "prompt": constrained_prompt,
370
376
  "size": "1K",
371
- "ratio": aspect_ratio,
377
+ "ratio": resolved_aspect_ratio,
372
378
  "return_base64": True,
373
379
  "extra_body": {"response_format": "b64_json"},
374
380
  }
@@ -377,12 +383,12 @@ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", letter
377
383
  return requests.post(
378
384
  endpoint,
379
385
  headers=_headers(card),
380
- json={"model": _model(card), "prompt": constrained_prompt, "n": 1, "aspect_ratio": aspect_ratio},
386
+ json={"model": _model(card), "prompt": constrained_prompt, "n": 1, "aspect_ratio": resolved_aspect_ratio},
381
387
  timeout=180,
382
388
  )
383
389
  body = {"model": _model(card), "prompt": constrained_prompt, "n": 1, "response_format": "b64_json"}
384
390
  if provider == "pollinations":
385
- body["size"] = "1024x1792" if aspect_ratio == "9:16" else "1792x1024"
391
+ body["size"] = "1024x1792" if resolved_aspect_ratio == "9:16" else "1792x1024"
386
392
  return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
387
393
 
388
394
 
@@ -397,6 +403,7 @@ def generate_image_for_card(
397
403
  lettering_prompt: str = "",
398
404
  reference_image: Path | None = None,
399
405
  thumbnail_blueprint: Mapping[str, Any] | None = None,
406
+ aspect_ratio: str = "",
400
407
  ) -> Path:
401
408
  """Generate one image with the selected media card."""
402
409
  card = _hydrate_media_card(settings, card)
@@ -439,7 +446,7 @@ def generate_image_for_card(
439
446
  )
440
447
  except Exception as exc:
441
448
  raise MediaGenerationError(str(exc)) from exc
442
- aspect_ratio = _thumbnail_aspect_ratio(thumbnail_blueprint, prompt)
449
+ aspect_ratio = _thumbnail_aspect_ratio(thumbnail_blueprint, prompt, aspect_ratio)
443
450
  if provider == "nano_banana":
444
451
  merged = dict(settings)
445
452
  merged["gemini_image_api_key"] = _api_key(card)
@@ -498,7 +505,7 @@ def generate_image_for_card(
498
505
  raise MediaGenerationError(f"Hugging Face text-to-image falhou: {str(exc)[:240]}") from exc
499
506
 
500
507
  def request(current: dict[str, Any]) -> Any:
501
- return _image_request(current, prompt, topic=topic, lettering_text=lettering_text, lettering_prompt=lettering_prompt, thumbnail_blueprint=thumbnail_blueprint)
508
+ return _image_request(current, prompt, topic=topic, lettering_text=lettering_text, lettering_prompt=lettering_prompt, thumbnail_blueprint=thumbnail_blueprint, aspect_ratio=aspect_ratio)
502
509
 
503
510
  try:
504
511
  routed = route_json_request(settings, pool=POOL_IMAGE, cards=[card], request=request)
@@ -542,6 +549,7 @@ def generate_image_from_pool(
542
549
  reference_image: Path | None = None,
543
550
  thumbnail_only: bool = False,
544
551
  thumbnail_blueprint: Mapping[str, Any] | None = None,
552
+ aspect_ratio: str = "",
545
553
  ) -> Path:
546
554
  """Try eligible image cards in priority order, without cross-pool fallback."""
547
555
  cards = media_cards_for_pool(settings, "image", thumbnail_only=True) if thumbnail_only else media_cards_for_pool(settings, "image")
@@ -560,6 +568,7 @@ def generate_image_from_pool(
560
568
  lettering_prompt=lettering_prompt,
561
569
  reference_image=reference_image,
562
570
  thumbnail_blueprint=thumbnail_blueprint,
571
+ aspect_ratio=aspect_ratio,
563
572
  )
564
573
  except MediaGenerationError as exc:
565
574
  errors.append(f"{card.get('provider')}: {str(exc)[:180]}")
@@ -33,7 +33,7 @@ from hermes_ui.media_generation import MediaGenerationError, _append_generation_
33
33
  from hermes_ui.media_providers import FULL_IA_VIDEO_PROVIDER_CODES, media_cards_for_pool, media_provider_definition
34
34
  from hermes_ui.material_sources import material_api_keys, material_source_cards, selected_material_source
35
35
  from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image, infer_thumbnail_aspect_ratio
36
- from hermes_ui.thumbnail_blueprints import thumbnail_blueprint_for_channel
36
+ from hermes_ui.thumbnail_blueprints import thumbnail_aspect_ratio_for_channel_task, thumbnail_blueprint_for_channel
37
37
  from hermes_ui.voice_preview import synthesize_preview
38
38
 
39
39
  PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
@@ -86,6 +86,7 @@ def _generate_pipeline_thumbnail(
86
86
  lettering_text: str = "",
87
87
  lettering_prompt: str = "",
88
88
  thumbnail_blueprint: dict[str, Any] | None = None,
89
+ aspect_ratio: str = "",
89
90
  ) -> Path:
90
91
  """Use the image pool while keeping the legacy single-Nano call seam."""
91
92
  cards = media_cards_for_pool(settings, "image")
@@ -97,7 +98,7 @@ def _generate_pipeline_thumbnail(
97
98
  variant_index=variant_index,
98
99
  lettering_text=lettering_text,
99
100
  lettering_prompt=lettering_prompt,
100
- aspect_ratio=infer_thumbnail_aspect_ratio(prompt, thumbnail_blueprint),
101
+ aspect_ratio=aspect_ratio or infer_thumbnail_aspect_ratio(prompt, thumbnail_blueprint),
101
102
  )
102
103
  return generate_image_from_pool(
103
104
  settings,
@@ -107,6 +108,7 @@ def _generate_pipeline_thumbnail(
107
108
  lettering_text=lettering_text,
108
109
  lettering_prompt=lettering_prompt,
109
110
  thumbnail_blueprint=thumbnail_blueprint,
111
+ aspect_ratio=aspect_ratio,
110
112
  )
111
113
 
112
114
 
@@ -1356,6 +1358,7 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
1356
1358
  blueprint = {"id": str(task.get("blueprint_id") or ""), "name": str(task.get("blueprint_name") or task.get("blueprint_id") or "")}
1357
1359
  visual_format = task.get("format") or ("portrait" if str(task.get("platform") or "").casefold() in {"tiktok", "instagram"} else "wide")
1358
1360
  visual_blueprint = thumbnail_blueprint_for_channel(channel, visual_format)
1361
+ thumbnail_aspect_ratio = thumbnail_aspect_ratio_for_channel_task(channel, task, visual_blueprint)
1359
1362
  if visual_blueprint.get("content"):
1360
1363
  blueprint = {**blueprint, "thumbnail_blueprint_rules": visual_blueprint["content"]}
1361
1364
  route = _normalise_video_route(task, settings)
@@ -1549,8 +1552,8 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
1549
1552
  "keywords": keywords,
1550
1553
  "thumbnail": variant,
1551
1554
  "requirements": {
1552
- "aspect_ratio": "16:9",
1553
- "resolution": "1920x1080",
1555
+ "aspect_ratio": thumbnail_aspect_ratio,
1556
+ "resolution": "1024x1792" if thumbnail_aspect_ratio == "9:16" else "1792x1024",
1554
1557
  "max_elements": 3,
1555
1558
  "max_overlay_words": 4,
1556
1559
  "lettering_required": True,
@@ -1589,6 +1592,7 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
1589
1592
  lettering_text=str(variant.get("overlay_text") or ""),
1590
1593
  lettering_prompt=str(variant.get("lettering_prompt") or ""),
1591
1594
  thumbnail_blueprint=blueprint,
1595
+ aspect_ratio=thumbnail_aspect_ratio,
1592
1596
  )
1593
1597
  except (MediaGenerationError, ThumbnailGenerationError) as exc:
1594
1598
  raise PipelineError(f"A thumbnail não foi gerada; o vídeo já está disponível em {video_path}: {exc}") from exc
@@ -89,6 +89,47 @@ def thumbnail_blueprint_for_task(channel: Mapping[str, Any], task: Mapping[str,
89
89
  return thumbnail_blueprint_for_channel({**channel, "thumbnail_blueprint_id": ""}, format_value)
90
90
 
91
91
 
92
+ def thumbnail_aspect_ratio_for_channel_task(
93
+ channel: Mapping[str, Any] | None,
94
+ task: Mapping[str, Any] | None = None,
95
+ blueprint: Mapping[str, Any] | None = None,
96
+ ) -> str:
97
+ """Resolve thumbnail orientation consistently for new and persisted tasks.
98
+
99
+ A channel-specific blueprint remains authoritative. When no specific blueprint
100
+ exists, platform defaults are used: TikTok/Instagram are portrait and YouTube
101
+ is landscape. This deliberately ignores stale generic blueprint ids persisted
102
+ on older YouTube tasks.
103
+ """
104
+ channel = channel or {}
105
+ task = task or {}
106
+ selected = dict(blueprint or {})
107
+ direct = str(
108
+ channel.get("default_thumbnail_blueprint_id")
109
+ or channel.get("thumbnail_blueprint_id")
110
+ or task.get("thumbnail_blueprint_id")
111
+ or ""
112
+ ).strip()
113
+ generic_ids = {
114
+ "Generic_Thumbnail_Blueprint",
115
+ HORIZONTAL_GENERIC_THUMBNAIL_BLUEPRINT_ID,
116
+ VERTICAL_GENERIC_THUMBNAIL_BLUEPRINT_ID,
117
+ }
118
+ if not selected and direct and direct not in generic_ids:
119
+ selected = resolve_thumbnail_blueprint(direct)
120
+ if selected.get("content") and direct not in generic_ids:
121
+ return "9:16" if _contains_vertical_rules(selected.get("content")) else "16:9"
122
+
123
+ platform = str(channel.get("platform") or task.get("platform") or "").strip().casefold()
124
+ if platform in {"tiktok", "instagram", "instagram reels"}:
125
+ return "9:16"
126
+ return "16:9"
127
+
128
+
129
+ def _contains_vertical_rules(value: Any) -> bool:
130
+ return bool(re.search(r"\b9\s*:\s*16\b|\bvertical\b|\bportrait\b", str(value or ""), flags=re.IGNORECASE))
131
+
132
+
92
133
  def thumbnail_blueprint_for_blueprint(blueprint_id: Any, format_value: Any = "") -> dict[str, Any]:
93
134
  """Resolve the visual pair for a script Blueprint, falling back to Generic."""
94
135
  paired_id = _pair_state().get(str(blueprint_id or "").strip(), "")
@@ -190,7 +190,7 @@ def generate_thumbnail_image(
190
190
  reference_image: str | Path | None = None,
191
191
  lettering_text: str = "",
192
192
  lettering_prompt: str = "",
193
- aspect_ratio: str = DEFAULT_ASPECT_RATIO,
193
+ aspect_ratio: str | None = None,
194
194
  ) -> Path:
195
195
  api_key = str(settings.get("gemini_image_api_key") or "").strip()
196
196
  if not api_key:
@@ -204,7 +204,7 @@ def generate_thumbnail_image(
204
204
  lettering_text=lettering_text,
205
205
  lettering_prompt=lettering_prompt,
206
206
  )
207
- effective_aspect_ratio = infer_thumbnail_aspect_ratio(clean_prompt, fallback=aspect_ratio)
207
+ effective_aspect_ratio = aspect_ratio if aspect_ratio in {"16:9", "9:16"} else infer_thumbnail_aspect_ratio(clean_prompt)
208
208
 
209
209
  model = str(settings.get("gemini_image_model") or DEFAULT_GEMINI_IMAGE_MODEL).strip()
210
210
  image_size = DEFAULT_IMAGE_SIZE
@@ -12,7 +12,7 @@ from .media_generation import generate_image_from_pool
12
12
  from .media_providers import media_cards_for_pool
13
13
  from .storage import STORAGE, ensure_storage, now, read_json, update_json, write_json
14
14
  from .thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image, infer_thumbnail_aspect_ratio
15
- from .thumbnail_blueprints import thumbnail_blueprint_for_task
15
+ from .thumbnail_blueprints import thumbnail_aspect_ratio_for_channel_task, thumbnail_blueprint_for_task
16
16
 
17
17
 
18
18
  def _generate_image_with_pool(
@@ -25,6 +25,7 @@ def _generate_image_with_pool(
25
25
  lettering_prompt: str = "",
26
26
  reference_image: Path | None = None,
27
27
  thumbnail_blueprint: dict[str, Any] | None = None,
28
+ aspect_ratio: str = "",
28
29
  ) -> Path:
29
30
  """Use the legacy Nano adapter when it is the only active image card.
30
31
 
@@ -41,7 +42,7 @@ def _generate_image_with_pool(
41
42
  reference_image=reference_image,
42
43
  lettering_text=lettering_text,
43
44
  lettering_prompt=lettering_prompt,
44
- aspect_ratio=infer_thumbnail_aspect_ratio(prompt, thumbnail_blueprint),
45
+ aspect_ratio=aspect_ratio or infer_thumbnail_aspect_ratio(prompt, thumbnail_blueprint),
45
46
  )
46
47
  return generate_image_from_pool(
47
48
  settings,
@@ -53,6 +54,7 @@ def _generate_image_with_pool(
53
54
  lettering_prompt=lettering_prompt,
54
55
  thumbnail_only=True,
55
56
  thumbnail_blueprint=thumbnail_blueprint,
57
+ aspect_ratio=aspect_ratio,
56
58
  )
57
59
 
58
60
 
@@ -271,6 +273,7 @@ def generate_thumbnail_for_task(
271
273
  and bool(item.get("enabled", True))
272
274
  for item in configured_cards
273
275
  ) if isinstance(configured_cards, list) else False
276
+ channel = {}
274
277
  if not effective_blueprint:
275
278
  channels = read_json("channels.json", [])
276
279
  channel = next(
@@ -278,6 +281,7 @@ def generate_thumbnail_for_task(
278
281
  {},
279
282
  ) if isinstance(channels, list) else {}
280
283
  effective_blueprint = thumbnail_blueprint_for_task(channel, task)
284
+ aspect_ratio = thumbnail_aspect_ratio_for_channel_task(channel, task, effective_blueprint)
281
285
  rules = str(effective_blueprint.get("content") or "").strip()
282
286
  if canva_selected and not rules:
283
287
  raise ThumbnailGenerationError("A tarefa não tem um Thumbnail Blueprint local válido.")
@@ -291,6 +295,7 @@ def generate_thumbnail_for_task(
291
295
  lettering_text=record.get("thumbnail_text") or "",
292
296
  lettering_prompt=record.get("lettering_prompt") or "",
293
297
  thumbnail_blueprint=effective_blueprint,
298
+ aspect_ratio=aspect_ratio,
294
299
  )
295
300
  updated = _update_thumbnail_task(
296
301
  task_id,
@@ -353,6 +358,7 @@ def regenerate_thumbnail_prompt_and_image(
353
358
  {},
354
359
  ) if isinstance(channels, list) else {}
355
360
  effective_blueprint = thumbnail_blueprint_for_task(channel, task)
361
+ aspect_ratio = thumbnail_aspect_ratio_for_channel_task(channel, task, effective_blueprint)
356
362
  _archive_image(str(task_id), record.get("image_path"))
357
363
  image_path = _generate_image_with_pool(
358
364
  settings,
@@ -362,6 +368,7 @@ def regenerate_thumbnail_prompt_and_image(
362
368
  lettering_text=str((variant or {}).get("overlay_text") or ""),
363
369
  lettering_prompt=str((variant or {}).get("lettering_prompt") or ""),
364
370
  thumbnail_blueprint=effective_blueprint,
371
+ aspect_ratio=aspect_ratio,
365
372
  )
366
373
  updated = _update_thumbnail_task(
367
374
  task_id,
@@ -429,6 +436,7 @@ def regenerate_thumbnail_lettering(
429
436
  "Não adicionar logótipos, marcas de água ou outros elementos."
430
437
  )
431
438
  _archive_image(str(task_id), previous_image)
439
+ lettering_blueprint = thumbnail_blueprint_for_task(resolved_channel, task)
432
440
  image_path = _generate_image_with_pool(
433
441
  settings,
434
442
  combined_prompt,
@@ -437,7 +445,8 @@ def regenerate_thumbnail_lettering(
437
445
  reference_image=previous_image,
438
446
  lettering_text=exact_headline,
439
447
  lettering_prompt=edit_prompt,
440
- thumbnail_blueprint=thumbnail_blueprint_for_task(resolved_channel, task),
448
+ thumbnail_blueprint=lettering_blueprint,
449
+ aspect_ratio=thumbnail_aspect_ratio_for_channel_task(resolved_channel, task, lettering_blueprint),
441
450
  )
442
451
  variant = _variant_for_record(record)
443
452
  variant["image_prompt"] = base_prompt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.7.48",
3
+ "version": "0.7.50",
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",