@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.
- package/app/main.py +494 -140
- package/hermes_ui/creative_generation.py +54 -0
- package/hermes_ui/draft_video.py +127 -0
- package/hermes_ui/languages.py +117 -2
- package/hermes_ui/thumbnail_generation.py +28 -1
- package/hermes_ui/thumbnails.py +276 -37
- package/package.json +1 -1
package/hermes_ui/thumbnails.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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__ = [
|
|
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