@danhachuel/thunderbolt 0.3.23 → 0.3.25

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.
@@ -1,10 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import shutil
5
+ import tempfile
4
6
  import uuid
5
7
  from pathlib import Path
6
8
  from typing import Any
7
9
 
10
+ from .creative_generation import generate_thumbnail_prompt
8
11
  from .storage import STORAGE, now, read_json, write_json
9
12
  from .thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
10
13
 
@@ -51,13 +54,18 @@ def normalize_thumbnail_task(task: dict[str, Any]) -> dict[str, Any]:
51
54
  "task_id": str(task.get("id") or ""),
52
55
  "title": title,
53
56
  "topic": str(task.get("topic") or "").strip(),
57
+ "channel_id": str(task.get("channel_id") or "").strip(),
54
58
  "channel_name": str(task.get("channel_name") or "Canal sem nome").strip(),
59
+ "blueprint_id": str(task.get("blueprint_id") or "").strip(),
60
+ "blueprint_name": str(task.get("blueprint_name") or "").strip(),
55
61
  "status": str(task.get("thumbnail_status") or "not_generated"),
56
62
  "prompt": str(prompt or "").strip(),
57
63
  "image_path": _as_path(image_path),
58
64
  "variant": variant,
59
65
  "variants": variants,
60
66
  "variant_index": _variant_index(task, variant, variants),
67
+ "thumbnail_text": str(variant.get("overlay_text") or task.get("thumbnail_text") or "").strip(),
68
+ "lettering_prompt": str(variant.get("lettering_prompt") or task.get("thumbnail_lettering_prompt") or "").strip(),
61
69
  }
62
70
 
63
71
 
@@ -82,46 +90,277 @@ def list_thumbnail_tasks() -> list[dict[str, Any]]:
82
90
  return records
83
91
 
84
92
 
85
- def regenerate_thumbnail(task_id: str, settings: dict[str, Any]) -> tuple[dict[str, Any], Path]:
93
+ def _archive_image(task_id: str, image_path: Path | None) -> Path | None:
94
+ if not image_path or not image_path.is_file():
95
+ return None
96
+ history_dir = STORAGE / "thumbnails" / "history"
97
+ history_dir.mkdir(parents=True, exist_ok=True)
98
+ history_path = history_dir / f"{task_id}-{uuid.uuid4().hex[:10]}-{image_path.name}"
99
+ shutil.copy2(image_path, history_path)
100
+ return history_path
101
+
102
+
103
+ def _variant_for_record(record: dict[str, Any]) -> dict[str, Any]:
104
+ variant = dict(record.get("variant") or {})
105
+ variants = record.get("variants") if isinstance(record.get("variants"), list) else []
106
+ index = int(record.get("variant_index") or 0)
107
+ if not variant and 0 <= index < len(variants) and isinstance(variants[index], dict):
108
+ variant = dict(variants[index])
109
+ return variant
110
+
111
+
112
+ def _persist_thumbnail_result(
113
+ tasks: list[dict[str, Any]],
114
+ task: dict[str, Any],
115
+ record: dict[str, Any],
116
+ image_path: Path,
117
+ *,
118
+ variant: dict[str, Any] | None = None,
119
+ source: str = "generated",
120
+ lettering_prompt: str = "",
121
+ ) -> dict[str, Any]:
122
+ selected_variant = _variant_for_record(record)
123
+ if variant:
124
+ selected_variant.update({key: value for key, value in variant.items() if value is not None})
125
+ selected_variant["image_path"] = str(image_path)
126
+ selected_variant["status"] = "generated"
127
+ selected_variant.setdefault("image_prompt", record.get("prompt") or "")
128
+ task["thumbnail_variant"] = selected_variant
129
+ variants = task.get("thumbnail_variants")
130
+ if not isinstance(variants, list):
131
+ variants = []
132
+ variants = list(variants)
133
+ index = int(record.get("variant_index") or 0)
134
+ while len(variants) <= index:
135
+ variants.append({})
136
+ variants[index] = {**(variants[index] if isinstance(variants[index], dict) else {}), **selected_variant}
137
+ task["thumbnail_variants"] = variants
138
+ task["thumbnail_prompt"] = str(selected_variant.get("image_prompt") or record.get("prompt") or "")
139
+ task["thumbnail_text"] = str(selected_variant.get("overlay_text") or task.get("thumbnail_text") or "")
140
+ if lettering_prompt:
141
+ task["thumbnail_lettering_prompt"] = lettering_prompt
142
+ task["thumbnail_source"] = source
143
+ task["thumbnail_status"] = "generated"
144
+ artifacts = dict(task.get("artifacts") or {})
145
+ artifacts["thumbnail"] = str(image_path)
146
+ task["artifacts"] = artifacts
147
+ task["thumbnail_path"] = str(image_path)
148
+ task["updated_at"] = now()
149
+ return task
150
+
151
+
152
+ def _persist_thumbnail_prompt_result(
153
+ task: dict[str, Any],
154
+ record: dict[str, Any],
155
+ variant: dict[str, Any],
156
+ ) -> dict[str, Any]:
157
+ """Persist a prompt-only update while leaving the active image and artifacts untouched."""
158
+ selected_variant = _variant_for_record(record)
159
+ selected_variant.update({key: value for key, value in variant.items() if value is not None})
160
+ if record.get("image_path"):
161
+ selected_variant.setdefault("image_path", str(record["image_path"]))
162
+ selected_variant["status"] = "prompt_ready"
163
+ variants = task.get("thumbnail_variants")
164
+ if not isinstance(variants, list):
165
+ variants = []
166
+ variants = list(variants)
167
+ index = int(record.get("variant_index") or 0)
168
+ while len(variants) <= index:
169
+ variants.append({})
170
+ variants[index] = {
171
+ **(variants[index] if isinstance(variants[index], dict) else {}),
172
+ **selected_variant,
173
+ }
174
+ task["thumbnail_variant"] = selected_variant
175
+ task["thumbnail_variants"] = variants
176
+ task["thumbnail_prompt"] = str(selected_variant.get("image_prompt") or "")
177
+ task["thumbnail_text"] = str(selected_variant.get("overlay_text") or task.get("thumbnail_text") or "")
178
+ task["thumbnail_lettering_prompt"] = str(
179
+ selected_variant.get("lettering_prompt") or task.get("thumbnail_lettering_prompt") or ""
180
+ )
181
+ task["thumbnail_source"] = "prompt_regenerated"
182
+ task["thumbnail_status"] = "prompt_ready"
183
+ task["updated_at"] = now()
184
+ return task
185
+
186
+
187
+ def _find_task(tasks: list[Any], task_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
188
+ for task in tasks:
189
+ if isinstance(task, dict) and str(task.get("id") or "") == str(task_id):
190
+ return task, normalize_thumbnail_task(task)
191
+ raise ThumbnailGenerationError(f"A tarefa {task_id} não foi encontrada.")
192
+
193
+
194
+ def generate_thumbnail_for_task(task_id: str, settings: dict[str, Any]) -> tuple[dict[str, Any], Path]:
195
+ """Generate an image from the task's current prompt and persist it on the task."""
86
196
  tasks = read_json("tasks.json", [])
87
197
  if not isinstance(tasks, list):
88
198
  raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
89
- for task in tasks:
90
- if not isinstance(task, dict) or str(task.get("id") or "") != str(task_id):
91
- continue
92
- record = normalize_thumbnail_task(task)
93
- if not record["prompt"]:
94
- raise ThumbnailGenerationError("Esta tarefa não tem um prompt de imagem para refazer a thumbnail.")
95
- previous_image = record.get("image_path")
96
- if previous_image and previous_image.is_file():
97
- history_dir = STORAGE / "thumbnails" / "history"
98
- history_dir.mkdir(parents=True, exist_ok=True)
99
- history_path = history_dir / f"{task_id}-{uuid.uuid4().hex[:10]}-{previous_image.name}"
100
- shutil.copy2(previous_image, history_path)
101
- image_path = generate_thumbnail_image(
102
- settings,
103
- record["prompt"],
104
- topic=record["title"] or record["topic"],
105
- variant_index=record["variant_index"],
106
- )
107
- artifacts = dict(task.get("artifacts") or {})
108
- artifacts["thumbnail"] = str(image_path)
109
- task["artifacts"] = artifacts
110
- task["thumbnail_path"] = str(image_path)
111
- task["thumbnail_status"] = "generated"
112
- variant = dict(record["variant"])
113
- if variant:
114
- variant["image_path"] = str(image_path)
115
- task["thumbnail_variant"] = variant
116
- variants = task.get("thumbnail_variants")
117
- if isinstance(variants, list) and 0 <= record["variant_index"] < len(variants) and isinstance(variants[record["variant_index"]], dict):
118
- variants[record["variant_index"]] = {**variants[record["variant_index"]], "image_path": str(image_path)}
119
- task["thumbnail_variants"] = variants
120
- task["updated_at"] = now()
121
- write_json("tasks.json", tasks)
122
- return task, image_path
123
- raise ThumbnailGenerationError(f"A tarefa {task_id} não foi encontrada.")
199
+ task, record = _find_task(tasks, task_id)
200
+ if not record["prompt"]:
201
+ raise ThumbnailGenerationError("A thumbnail não tem um prompt de imagem para gerar.")
202
+ _archive_image(str(task_id), record.get("image_path"))
203
+ image_path = generate_thumbnail_image(
204
+ settings,
205
+ record["prompt"],
206
+ topic=record["title"] or record["topic"],
207
+ variant_index=record["variant_index"],
208
+ )
209
+ _persist_thumbnail_result(tasks, task, record, image_path)
210
+ write_json("tasks.json", tasks)
211
+ return task, image_path
212
+
213
+
214
+ def regenerate_thumbnail_prompt(
215
+ task_id: str,
216
+ settings: dict[str, Any],
217
+ channel: dict[str, Any],
218
+ blueprint: dict[str, Any] | None = None,
219
+ language: str = "",
220
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
221
+ """Regenerate and persist only the thumbnail prompt, keeping the existing image unchanged."""
222
+ tasks = read_json("tasks.json", [])
223
+ if not isinstance(tasks, list):
224
+ raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
225
+ task, record = _find_task(tasks, task_id)
226
+ topic = record["topic"] or record["title"]
227
+ if not topic:
228
+ raise ThumbnailGenerationError("A tarefa não tem tópico para refazer o prompt da thumbnail.")
229
+ variant = generate_thumbnail_prompt(
230
+ settings,
231
+ channel or {},
232
+ topic,
233
+ current_prompt=record["prompt"],
234
+ blueprint=blueprint,
235
+ language=language,
236
+ )
237
+ _persist_thumbnail_prompt_result(task, record, variant)
238
+ write_json("tasks.json", tasks)
239
+ return task, variant
240
+
241
+
242
+ def regenerate_thumbnail_prompt_and_image(
243
+ task_id: str,
244
+ settings: dict[str, Any],
245
+ variant: dict[str, Any],
246
+ ) -> tuple[dict[str, Any], Path]:
247
+ """Persist a newly generated prompt and render its image as one atomic task update."""
248
+ tasks = read_json("tasks.json", [])
249
+ if not isinstance(tasks, list):
250
+ raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
251
+ task, record = _find_task(tasks, task_id)
252
+ prompt = str((variant or {}).get("image_prompt") or "").strip()
253
+ if not prompt:
254
+ raise ThumbnailGenerationError("O provider não devolveu um prompt de imagem válido.")
255
+ _archive_image(str(task_id), record.get("image_path"))
256
+ image_path = generate_thumbnail_image(
257
+ settings,
258
+ prompt,
259
+ topic=record["title"] or record["topic"],
260
+ variant_index=record["variant_index"],
261
+ )
262
+ _persist_thumbnail_result(tasks, task, record, image_path, variant=variant, source="prompt_regenerated")
263
+ write_json("tasks.json", tasks)
264
+ return task, image_path
265
+
266
+
267
+ def regenerate_thumbnail_lettering(
268
+ task_id: str,
269
+ settings: dict[str, Any],
270
+ lettering_prompt: str = "",
271
+ ) -> tuple[dict[str, Any], Path]:
272
+ """Edit only the lettering while sending the existing image as a Nano Banana reference."""
273
+ tasks = read_json("tasks.json", [])
274
+ if not isinstance(tasks, list):
275
+ raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
276
+ task, record = _find_task(tasks, task_id)
277
+ previous_image = record.get("image_path")
278
+ if not previous_image or not previous_image.is_file():
279
+ raise ThumbnailGenerationError("A thumbnail precisa de uma imagem existente para refazer o lettering.")
280
+ base_prompt = record["prompt"] or "Cria uma thumbnail de YouTube cinematográfica e de alto contraste."
281
+ edit_prompt = str(lettering_prompt or "").strip() or (
282
+ f"Refaz apenas o lettering da thumbnail para o vídeo {record['title']!r}. "
283
+ "Escolhe uma frase curta e forte, com no máximo quatro palavras, relacionada com o título."
284
+ )
285
+ combined_prompt = (
286
+ "BASE IMAGE LAYER — preserva exactamente a composição, enquadramento, sujeitos, fundo, iluminação, "
287
+ "cores, objectos e estilo da imagem de referência. Não recries nem movas nenhum elemento.\n"
288
+ f"{base_prompt}\n\n"
289
+ "LETTERING EDIT LAYER — altera exclusivamente o texto/lettering visível da thumbnail. "
290
+ "Mantém tudo o que pertence à BASE IMAGE LAYER pixelmente tão próximo quanto possível, sem mudar a imagem.\n"
291
+ f"{edit_prompt}\n"
292
+ "Não adicionar logótipos, marcas de água ou outros elementos."
293
+ )
294
+ _archive_image(str(task_id), previous_image)
295
+ image_path = generate_thumbnail_image(
296
+ settings,
297
+ combined_prompt,
298
+ topic=record["title"] or record["topic"],
299
+ variant_index=record["variant_index"],
300
+ reference_image=previous_image,
301
+ )
302
+ variant = _variant_for_record(record)
303
+ variant["image_prompt"] = base_prompt
304
+ variant["lettering_prompt"] = edit_prompt
305
+ _persist_thumbnail_result(tasks, task, record, image_path, variant=variant, source="lettering_regenerated", lettering_prompt=edit_prompt)
306
+ write_json("tasks.json", tasks)
307
+ return task, image_path
308
+
309
+
310
+ def upload_thumbnail_image(
311
+ task_id: str,
312
+ image_bytes: bytes,
313
+ filename: str,
314
+ content_type: str = "",
315
+ ) -> tuple[dict[str, Any], Path]:
316
+ """Store a user-provided image, preserve the old one, and attach it to the task."""
317
+ if not isinstance(image_bytes, (bytes, bytearray)) or not image_bytes:
318
+ raise ThumbnailGenerationError("O ficheiro de imagem está vazio.")
319
+ if len(image_bytes) > 20 * 1024 * 1024:
320
+ raise ThumbnailGenerationError("A imagem excede o limite de 20 MB.")
321
+ suffix = Path(str(filename or "")).suffix.lower()
322
+ allowed = {".png", ".jpg", ".jpeg", ".webp"}
323
+ if suffix not in allowed:
324
+ raise ThumbnailGenerationError("Use uma imagem PNG, JPG, JPEG ou WEBP.")
325
+ if content_type and not str(content_type).lower().startswith("image/"):
326
+ raise ThumbnailGenerationError("O ficheiro enviado não é uma imagem válida.")
327
+ tasks = read_json("tasks.json", [])
328
+ if not isinstance(tasks, list):
329
+ raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
330
+ task, record = _find_task(tasks, task_id)
331
+ _archive_image(str(task_id), record.get("image_path"))
332
+ output_dir = STORAGE / "thumbnails" / "uploads"
333
+ output_dir.mkdir(parents=True, exist_ok=True)
334
+ destination = output_dir / f"uploaded-{task_id}-{uuid.uuid4().hex[:12]}{suffix}"
335
+ fd, temp_name = tempfile.mkstemp(prefix=f".{destination.name}.", dir=output_dir)
336
+ try:
337
+ with os.fdopen(fd, "wb") as handle:
338
+ handle.write(bytes(image_bytes))
339
+ handle.flush()
340
+ os.fsync(handle.fileno())
341
+ os.replace(temp_name, destination)
342
+ finally:
343
+ if os.path.exists(temp_name):
344
+ os.unlink(temp_name)
345
+ variant = _variant_for_record(record)
346
+ _persist_thumbnail_result(tasks, task, record, destination, variant=variant, source="uploaded")
347
+ write_json("tasks.json", tasks)
348
+ return task, destination
349
+
350
+
351
+ def regenerate_thumbnail(task_id: str, settings: dict[str, Any]) -> tuple[dict[str, Any], Path]:
352
+ """Backward-compatible alias for regenerating from the current prompt."""
353
+ return generate_thumbnail_for_task(task_id, settings)
124
354
 
125
355
 
126
- __all__ = ["list_thumbnail_tasks", "normalize_thumbnail_task", "regenerate_thumbnail"]
356
+ __all__ = [
357
+ "generate_thumbnail_for_task",
358
+ "list_thumbnail_tasks",
359
+ "normalize_thumbnail_task",
360
+ "regenerate_thumbnail",
361
+ "regenerate_thumbnail_lettering",
362
+ "regenerate_thumbnail_prompt",
363
+ "regenerate_thumbnail_prompt_and_image",
364
+ "upload_thumbnail_image",
365
+ ]
127
366
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
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",