@danhachuel/thunderbolt 0.7.37 → 0.7.38

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
@@ -2155,7 +2155,7 @@ def render_thumbnail_blueprints():
2155
2155
  script = st.selectbox("Blueprint de roteiro associado", blueprint_ids, index=blueprint_ids.index(current_script) if current_script in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"), key=f"thumbnail_script_blueprint_channel_{channel_id}")
2156
2156
  with cols[2]:
2157
2157
  if st.button("Guardar par", key=f"thumbnail_blueprint_save_{channel_id}", width="stretch"):
2158
- if thumb == "Generic_Thumbnail_Blueprint" and script:
2158
+ if thumb in {"Youtube_Generic_Thumbnail_Blueprint", "Tiktok_Generic_Thumbnail_Blueprint"} and script:
2159
2159
  st.error("Not Allowed to Associate, System Use Only")
2160
2160
  else:
2161
2161
  update_channel(channel_id, {"thumbnail_blueprint_id": thumb, "default_thumbnail_blueprint_id": thumb, "blueprint_id": script, "default_blueprint_id": script})
@@ -6508,7 +6508,7 @@ def render_automation():
6508
6508
  st.link_button("Abrir canal", channel_url, type="primary", width="content")
6509
6509
  with header_cols[2]:
6510
6510
  st.markdown("**Thumbnail Blueprint**")
6511
- st.caption(str(paired_thumbnail.get("name") or "Generic_Thumbnail_Blueprint"))
6511
+ st.caption(str(paired_thumbnail.get("name") or "Youtube_Generic_Thumbnail_Blueprint"))
6512
6512
  with header_cols[3]:
6513
6513
  st.markdown("**Idioma do roteiro**")
6514
6514
  st.caption(video_language_label(normalize_video_language(channel.get("language") or "pt")))
@@ -6561,7 +6561,7 @@ def render_automation():
6561
6561
  "format": automation_format,
6562
6562
  })
6563
6563
  set_channel_defaults(channel_id, automation_blueprint, automation_voice)
6564
- paired_id = str(paired_thumbnail.get("id") or "Generic_Thumbnail_Blueprint")
6564
+ paired_id = str(paired_thumbnail.get("id") or "Youtube_Generic_Thumbnail_Blueprint")
6565
6565
  update_channel(channel_id, {"thumbnail_blueprint_id": paired_id, "default_thumbnail_blueprint_id": paired_id})
6566
6566
  st.success("Agendamento guardado.")
6567
6567
  st.rerun()
@@ -7,7 +7,7 @@ from typing import Any
7
7
 
8
8
  from .notifications import record_notification
9
9
  from .storage import StorageIntegrityError, append_json, now, read_json, update_json, write_json
10
- from .thumbnail_blueprints import GENERIC_THUMBNAIL_BLUEPRINT_ID
10
+ from .thumbnail_blueprints import thumbnail_blueprint_for_channel
11
11
 
12
12
  STAGES = ["niche", "blueprint", "brand", "topic", "script", "title", "keywords", "video", "thumbnail_prompt", "thumbnail", "upload"]
13
13
  # Ordem executada pelo worker. Cada etapa só é executada quando o seu artefacto
@@ -242,7 +242,7 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
242
242
  "generation_settings": generation_settings,
243
243
  "blueprint_id": payload.get("blueprint_id") or channel.get("default_blueprint_id") or channel.get("blueprint_id", ""),
244
244
  "blueprint_name": payload.get("blueprint_name", ""),
245
- "thumbnail_blueprint_id": payload.get("thumbnail_blueprint_id") or channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id") or GENERIC_THUMBNAIL_BLUEPRINT_ID,
245
+ "thumbnail_blueprint_id": payload.get("thumbnail_blueprint_id") or channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id") or thumbnail_blueprint_for_channel({**channel, "thumbnail_blueprint_id": ""}, payload.get("format", options.get("format", ""))).get("id", ""),
246
246
  "voice": payload.get("voice") or channel.get("default_voice") or channel.get("voice", ""),
247
247
  "automation_on": bool(channel.get("automation_on", False)),
248
248
  "automation_time": channel.get("automation_time", "00:00"),
@@ -525,7 +525,7 @@ def _refresh_task_channel_video_settings(task: dict[str, Any]) -> None:
525
525
  channel.get("default_thumbnail_blueprint_id")
526
526
  or channel.get("thumbnail_blueprint_id")
527
527
  or task.get("thumbnail_blueprint_id")
528
- or GENERIC_THUMBNAIL_BLUEPRINT_ID
528
+ or thumbnail_blueprint_for_channel({**channel, "thumbnail_blueprint_id": ""}, task.get("format", "")).get("id", "")
529
529
  )
530
530
  task["blueprint_id"] = channel.get("default_blueprint_id") or channel.get("blueprint_id") or task.get("blueprint_id", "")
531
531
  if channel.get("style_wide"):
@@ -1353,7 +1353,8 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
1353
1353
  blueprint = _blueprint_for_channel(channel)
1354
1354
  if not blueprint and (task.get("blueprint_id") or task.get("blueprint_name")):
1355
1355
  blueprint = {"id": str(task.get("blueprint_id") or ""), "name": str(task.get("blueprint_name") or task.get("blueprint_id") or "")}
1356
- visual_blueprint = thumbnail_blueprint_for_channel(channel)
1356
+ visual_format = task.get("format") or ("portrait" if str(task.get("platform") or "").casefold() in {"tiktok", "instagram"} else "wide")
1357
+ visual_blueprint = thumbnail_blueprint_for_channel(channel, visual_format)
1357
1358
  if visual_blueprint.get("content"):
1358
1359
  blueprint = {**blueprint, "thumbnail_blueprint_rules": visual_blueprint["content"]}
1359
1360
  route = _normalise_video_route(task, settings)
@@ -7,9 +7,12 @@ from pathlib import Path
7
7
  from typing import Any, Mapping
8
8
 
9
9
  from .provider_routing import route_llm_json
10
- from .storage import BLUEPRINTS, atomic_write, list_blueprint_files, load_blueprint_file
10
+ from .storage import BLUEPRINTS, SEED_THUMBNAIL_BLUEPRINTS, atomic_write, list_blueprint_files, load_blueprint_file
11
11
 
12
- GENERIC_THUMBNAIL_BLUEPRINT_ID = "Generic_Thumbnail_Blueprint"
12
+ HORIZONTAL_GENERIC_THUMBNAIL_BLUEPRINT_ID = "Youtube_Generic_Thumbnail_Blueprint"
13
+ VERTICAL_GENERIC_THUMBNAIL_BLUEPRINT_ID = "Tiktok_Generic_Thumbnail_Blueprint"
14
+ # Backwards-compatible name for callers that only need the landscape default.
15
+ GENERIC_THUMBNAIL_BLUEPRINT_ID = HORIZONTAL_GENERIC_THUMBNAIL_BLUEPRINT_ID
13
16
  GENERIC_ASSOCIATION_ERROR = "Not Allowed to Associate, System Use Only"
14
17
 
15
18
  PROMPT_MASTER = '''You are a forensic YouTube thumbnail analyst. Build a reusable Thumbnail Blueprint from the reference channel videos below.
@@ -38,26 +41,41 @@ def resolve_thumbnail_blueprint(identifier: Any) -> dict[str, Any]:
38
41
  wanted = str(identifier or "").strip()
39
42
  if not wanted:
40
43
  return {}
41
- folder = BLUEPRINTS / "thumbnails"
42
- for path in folder.glob("*.md"):
43
- if path.stem == wanted or path.name == wanted:
44
- return {"id": path.stem, "name": path.stem, "path": str(path), "content": path.read_text(encoding="utf-8")}
44
+ if wanted == "Generic_Thumbnail_Blueprint":
45
+ wanted = HORIZONTAL_GENERIC_THUMBNAIL_BLUEPRINT_ID
46
+ folders = (BLUEPRINTS / "thumbnails", SEED_THUMBNAIL_BLUEPRINTS)
47
+ for folder in folders:
48
+ for path in folder.glob("*.md"):
49
+ if path.stem == wanted or path.name == wanted:
50
+ return {"id": path.stem, "name": path.stem, "path": str(path), "content": path.read_text(encoding="utf-8")}
45
51
  return {"id": wanted, "name": wanted}
46
52
 
47
53
 
48
- def thumbnail_blueprint_for_channel(channel: Mapping[str, Any]) -> dict[str, Any]:
54
+ def _generic_thumbnail_blueprint_id(format_value: Any = "", platform: Any = "") -> str:
55
+ raw = str(format_value or "").strip().casefold()
56
+ if raw in {"portrait", "vertical", "shorts", "9:16", "tiktok", "reels"}:
57
+ return VERTICAL_GENERIC_THUMBNAIL_BLUEPRINT_ID
58
+ platform_raw = str(platform or "").strip().casefold()
59
+ if platform_raw in {"tiktok", "instagram", "instagram reels", "shorts"} and not raw:
60
+ return VERTICAL_GENERIC_THUMBNAIL_BLUEPRINT_ID
61
+ return HORIZONTAL_GENERIC_THUMBNAIL_BLUEPRINT_ID
62
+
63
+
64
+ def thumbnail_blueprint_for_channel(channel: Mapping[str, Any], format_value: Any = "") -> dict[str, Any]:
49
65
  direct = channel.get("default_thumbnail_blueprint_id") or channel.get("thumbnail_blueprint_id")
50
66
  if direct:
51
67
  return resolve_thumbnail_blueprint(direct)
52
68
  script_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "").strip()
53
69
  pairs = _pair_state()
54
- return resolve_thumbnail_blueprint(pairs.get(script_id, "") or GENERIC_THUMBNAIL_BLUEPRINT_ID)
70
+ return resolve_thumbnail_blueprint(
71
+ pairs.get(script_id, "") or _generic_thumbnail_blueprint_id(format_value, channel.get("platform"))
72
+ )
55
73
 
56
74
 
57
75
  def thumbnail_blueprint_for_blueprint(blueprint_id: Any) -> dict[str, Any]:
58
76
  """Resolve the visual pair for a script Blueprint, falling back to Generic."""
59
77
  paired_id = _pair_state().get(str(blueprint_id or "").strip(), "")
60
- return resolve_thumbnail_blueprint(paired_id or GENERIC_THUMBNAIL_BLUEPRINT_ID)
78
+ return resolve_thumbnail_blueprint(paired_id or HORIZONTAL_GENERIC_THUMBNAIL_BLUEPRINT_ID)
61
79
 
62
80
 
63
81
  def _pair_state() -> dict[str, str]:
@@ -70,7 +88,7 @@ def _pair_state() -> dict[str, str]:
70
88
 
71
89
 
72
90
  def save_thumbnail_blueprint_pair(thumbnail_id: str, blueprint_id: str) -> None:
73
- if str(thumbnail_id) == GENERIC_THUMBNAIL_BLUEPRINT_ID and str(blueprint_id):
91
+ if str(thumbnail_id) in {HORIZONTAL_GENERIC_THUMBNAIL_BLUEPRINT_ID, VERTICAL_GENERIC_THUMBNAIL_BLUEPRINT_ID} and str(blueprint_id):
74
92
  raise ValueError(GENERIC_ASSOCIATION_ERROR)
75
93
  pairs = _pair_state()
76
94
  if blueprint_id:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.7.37",
3
+ "version": "0.7.38",
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",
@@ -0,0 +1,181 @@
1
+ 🔒 TRAVA DE ESTILO — NÃO NEGOCIÁVEL
2
+ [Um parágrafo descrevendo o estilo visual geral do canal: ex.: "Estilo limpo, minimalista, com iluminação high‑key, cores sólidas e composição centrada no rosto/produto, otimizado para consumo em tela vertical de celular."]
3
+
4
+ ❌ Não permitido (elementos que o canal nunca usa, com base nas capas observadas):
5
+
6
+ [ex.: Sem texto no rodapé da tela, sem rostos cortados, sem cores saturadas, etc.]
7
+
8
+ [Liste todas as proibições observadas]
9
+
10
+ 📱 ZONAS SEGURAS (VERTICAL 9:16) — OBRIGATÓRIO
11
+ A capa é feita para 1080 × 1920 px. A UI do TikTok/Reels cobre partes da imagem — nada essencial pode ficar ali.
12
+
13
+ text
14
+ 0 px ─────────────────────────────
15
+ ┌────────────────────┐
16
+ │ ZONA MORTA TOPO │ ~150 px (barra de progresso / "Seguindo")
17
+ ├───────────────┬────┤
18
+ │ │ UI │
19
+ │ ÁREA SEGURA │ DI │ ~220 px (curtir, comentar,
20
+ │ texto + │ RE │ compartilhar, som, avatar)
21
+ │ rosto/produto│ │
22
+ │ │ │
23
+ ├───────────────┴────┤
24
+ │ ZONA MORTA BASE │ ~480 px (legenda, @usuário,
25
+ │ │ música, CTA, barra de navegação)
26
+ └────────────────────┘
27
+ 1920 px ────────────────────────────
28
+ Regras rígidas de zona segura:
29
+
30
+ Margem superior: 150 px livres
31
+
32
+ Margem inferior: 480 px livres
33
+
34
+ Margem direita: 220 px livres
35
+
36
+ Margem esquerda: 80 px livres
37
+
38
+ Área útil real: aproximadamente 780 × 1290 px (canto superior esquerdo em 80,150)
39
+
40
+ Recorte do grid do perfil (3:4):
41
+
42
+ O TikTok e o Instagram cortam a capa em 3:4 no grid do perfil.
43
+
44
+ O núcleo da mensagem (rosto + 2–4 palavras) deve caber dentro do recorte central 1080 × 1440 px (y entre 240 e 1680).
45
+
46
+ Teste sempre: "se essa capa for cortada em 3:4, ainda entendo a mensagem?"
47
+
48
+ 🧍‍♂️ ENQUADRAMENTO E POSE
49
+ Sujeito principal: [descreva o elemento visual principal – pessoa, produto, cena, etc.]
50
+
51
+ Regras de enquadramento:
52
+
53
+ [ex.: Sujeito centralizado horizontalmente, ocupando o terço médio do quadro]
54
+
55
+ [ex.: Rosto/produto nunca abaixo de y = 1400 px]
56
+
57
+ [Adicione regras]
58
+
59
+ Ângulo de câmera: [baixo, alto, nível dos olhos, etc.]
60
+
61
+ Corte (cropping): [quanto do sujeito preenche o quadro — em vertical, o sujeito costuma ocupar 50–70% da altura útil]
62
+
63
+ Energia / ação: [estático, dinâmico, expressivo, ação congelada, etc.]
64
+
65
+ Expressão / leitura à distância: [a emoção precisa ser legível a 2 metros de distância do celular]
66
+
67
+ 🎨 FUNDO E ILUMINAÇÃO
68
+ Ambiente de fundo: [estúdio, externo, escuro, cor sólida, etc.]
69
+
70
+ Iluminação: [high‑key, low‑key, natural, dramática, etc.]
71
+
72
+ Efeitos: [bokeh, flare, fumaça, glow, etc.]
73
+
74
+ Profundidade: [profundidade de campo rasa, foco profundo, etc.]
75
+
76
+ Ruído visual: [nível de detalhe, poluído vs. limpo — em vertical, prefira fundos simples para não competir com o texto]
77
+
78
+ 🧭 IDENTIFICADORES / SÍMBOLOS DO CANAL
79
+ [Descreva símbolos recorrentes que comunicam contexto instantaneamente — bandeiras, logos, ícones específicos, rostos recorrentes, cores-assinatura. Se não houver, escreva explicitamente: "O canal não utiliza identificadores simbólicos recorrentes."]
80
+
81
+ 🚨 ELEMENTO DE ATENÇÃO VISUAL
82
+ [Descreva se o canal usa setas, círculos, zooms, emojis, contornos — especifique cor, forma, posição e regras de uso. Em vertical, o elemento deve ficar acima da zona morta inferior. Se o canal NÃO usa, escreva explicitamente: "O canal não utiliza elementos de atenção visual."]
83
+
84
+ 📝 ESTILO DE TEXTO
85
+ Estrutura:
86
+
87
+ [Número de linhas / faixas de texto e posicionamento — ex.: bloco de 2 linhas no terço superior, alinhado à esquerda]
88
+
89
+ [Hierarquia — ex.: headline grande + sub‑headline menor]
90
+
91
+ Tipografia:
92
+
93
+ [Família, peso, caixa — ex.: sans‑serif bold, CAIXA ALTA, contorno grosso]
94
+
95
+ Hierarquia de cor:
96
+
97
+ [Cor de fundo do bloco, cor do texto de cada camada, contorno/sombra]
98
+
99
+ Tamanho mínimo legível:
100
+
101
+ Fonte nunca menor que 90 px de altura de caixa (em 1080 px de largura)
102
+
103
+ Contraste alto obrigatório (texto claro sobre fundo escuro ou vice‑versa)
104
+
105
+ Cobertura de texto: [% aproximado da capa ocupada por texto — ex.: faixa superior ocupando 25%]
106
+
107
+ Regra do mudo: a capa precisa funcionar com o som desligado.
108
+
109
+ 🧠 PSICOLOGIA DO TEXTO
110
+ [Descreva os gatilhos emocionais usados nas headlines — urgência, curiosidade, choque, números, perguntas, etc.]
111
+
112
+ Exemplos de palavras/frases observadas:
113
+
114
+ [ex.: "Você não vai acreditar", "Destruiu", "Novo", "Por quê", "Ninguém fala disso"]
115
+
116
+ Restrição de comprimento: 2–4 palavras. Máximo 5. Texto longo morre em tela vertical.
117
+
118
+ 🎯 REGRAS DE COMPOSIÇÃO
119
+ Aplicação da regra dos terços: [onde os elementos-chave são posicionados no quadro vertical]
120
+
121
+ Hierarquia focal: [ordem de importância visual — sujeito, texto, elemento de atenção, fundo]
122
+
123
+ Espaço negativo: [como as áreas vazias são usadas — em vertical, o espaço negativo é o que evita que o texto seja engolido pela UI]
124
+
125
+ Controle de distração: [o que é evitado para manter o foco — ex.: nada de detalhes no canto inferior direito]
126
+
127
+ Mobile‑first: [garantir leitura em tela de 5–6 polegadas, com brilho ambiente alto]
128
+
129
+ 📐 FORMATO E QUALIDADE
130
+ Proporção:
131
+ 9:16 (vertical)
132
+
133
+ Resolução:
134
+ 1080 × 1920 px (mínimo) — 1440 × 2560 px (ideal / futuro‑proof)
135
+
136
+ Compatibilidade:
137
+ TikTok · Instagram Reels · YouTube Shorts · Kwai — mesma capa, sem reenquadramento
138
+
139
+ Estilo de imagem:
140
+ [Fotorrealista / ilustrado / misto — descreva o nível de realismo]
141
+
142
+ Nitidez:
143
+ [Clareza esperada — ex.: detalhes nítidos no sujeito, fundo levemente suave]
144
+
145
+ Restrições:
146
+
147
+ [Liste restrições técnicas observadas — sem marca d'água, sem blur, sem iluminação chapada, sem texto cortado pela UI, etc.]
148
+
149
+ [Nenhum elemento essencial fora da ÁREA SEGURA definida acima]
150
+
151
+ 🧠 OBJETIVO FINAL
152
+ Objetivo primário:
153
+ Maximizar CTR / taxa de retenção nos 3 primeiros segundos para [nicho do canal — ex.: reviews de tecnologia, finanças pessoais, true crime, etc.] em formato vertical.
154
+
155
+ Gatilhos emocionais:
156
+ [Emoções-alvo — ex.: curiosidade, medo, surpresa, excitação, indignação]
157
+
158
+ Público:
159
+ [Descreva o espectador-alvo — ex.: entusiastas de tech, investidores, público 18–34]
160
+
161
+ A capa deve comunicar instantaneamente:
162
+ [Mensagem central — ex.: "Uma revelação chocante", "Um produto imperdível", "Você está fazendo isso errado"]
163
+
164
+ Teste de aprovação (checklist):
165
+
166
+ □ Mensagem compreendida em menos de 1 segundo
167
+ □ Nada importante abaixo de y = 1440 px
168
+ □ Nada importante à direita de x = 860 px
169
+ □ Funciona no recorte 3:4 do grid do perfil
170
+ □ Funciona com o som desligado
171
+ □ Legível em tela de celular a 2 metros de distância
172
+ 🧾 FORMATO DE ENTRADA FINAL
173
+ TÍTULO DO VÍDEO / TEMA:
174
+ "[Insira o título real do vídeo ou o tópico do roteiro aqui]"
175
+
176
+ ⚙️ INSTRUÇÃO FINAL DE SISTEMA
177
+ Usando o estilo travado definido acima, gere uma capa vertical para TikTok/Instagram Reels que siga rigorosamente todas as regras composicionais, textuais e visuais.
178
+
179
+ Extraia automaticamente 2–4 palavras do TÍTULO DO VÍDEO fornecido para servir como headline e posicione-a de acordo com as regras de ESTILO DE TEXTO e de ZONAS SEGURAS (nada abaixo de y = 1440 px, nada à direita de x = 860 px).
180
+
181
+ A saída deve ser uma capa pronta para upload em 9:16, mínimo 1080 × 1920 px, replicando a identidade visual exata do canal — válida simultaneamente para TikTok, Reels e Shorts, e legível também no recorte 3:4 do grid do perfil. [Especifique se o logo deve ser incluído ou não — ex.: "SEM LOGO" ou "INCLUIR LOGO DO CANAL no canto superior esquerdo, dentro da área segura".]