@danhachuel/leadmaster 0.1.0 → 0.1.2
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/CHANGELOG.md +52 -0
- package/README.md +15 -24
- package/backend/main.py +26 -62
- package/backend/services/llm.py +5 -125
- package/backend/services/storage.py +48 -68
- package/frontend/dist/assets/index-0xtgAs3t.js +90 -0
- package/frontend/dist/assets/{index-eswbuT3S.css → index-pSbjfRli.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/package.json +4 -6
- package/scripts/cli.mjs +8 -17
- package/backend/services/moneyprinter.py +0 -102
- package/backend/services/openmontage.py +0 -62
- package/backend/services/pipeline.py +0 -515
- package/backend/services/short_video_maker.py +0 -72
- package/backend/services/youtube_upload.py +0 -163
- package/frontend/dist/assets/index-CANBU7DH.js +0 -93
|
@@ -1,515 +0,0 @@
|
|
|
1
|
-
"""Motor do pipeline de vídeos faceless.
|
|
2
|
-
|
|
3
|
-
Cada job atravessa 5 etapas:
|
|
4
|
-
roteiro → thumbnail → wide (16:9) → shorts (9:16) → upload
|
|
5
|
-
|
|
6
|
-
- Cada etapa roda em uma thread própria do job, com checkpoints de PAUSAR/PARAR
|
|
7
|
-
entre etapas e persistência imediata no `pipeline.json`.
|
|
8
|
-
- Falhas sempre viram mensagens acionáveis + botão Repetir no frontend.
|
|
9
|
-
- O agendador publica vídeos (não listado → público) após 1h do upload.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
import threading
|
|
13
|
-
import time
|
|
14
|
-
import uuid
|
|
15
|
-
from datetime import datetime, timedelta
|
|
16
|
-
|
|
17
|
-
from . import agents, llm, moneyprinter, openmontage, short_video_maker, storage, youtube_upload
|
|
18
|
-
|
|
19
|
-
ETAPAS = ["roteiro", "thumbnail", "wide", "shorts", "upload"]
|
|
20
|
-
ETAPA_LABELS = {
|
|
21
|
-
"roteiro": "Roteiro",
|
|
22
|
-
"thumbnail": "Thumbnail/Título",
|
|
23
|
-
"wide": "Produção Wide (16:9)",
|
|
24
|
-
"shorts": "Produção Shorts (9:16)",
|
|
25
|
-
"upload": "Upload YouTube",
|
|
26
|
-
}
|
|
27
|
-
STATUS_JOB = ["rascunho", "em_andamento", "pausado", "concluido", "erro", "cancelado"]
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class _Parada(Exception):
|
|
31
|
-
"""Sinal interno: o usuário pediu parar/cancelar o job."""
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def progresso(job: dict) -> int:
|
|
35
|
-
"""Progresso 0–100 (etapas puladas contam como concluídas)."""
|
|
36
|
-
etapas = job.get("etapas", [])
|
|
37
|
-
if not etapas:
|
|
38
|
-
return 0
|
|
39
|
-
feitas = sum(1 for e in etapas if e["status"] in ("concluida", "pulada"))
|
|
40
|
-
em_curso = sum(0.5 for e in etapas if e["status"] == "em_andamento")
|
|
41
|
-
return int(round((feitas + em_curso) / len(etapas) * 100))
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _hora() -> str:
|
|
45
|
-
return datetime.now().strftime("%H:%M:%S")
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class PipelineEngine:
|
|
49
|
-
def __init__(self):
|
|
50
|
-
self._pausas: dict[str, threading.Event] = {}
|
|
51
|
-
self._paradas: dict[str, threading.Event] = {}
|
|
52
|
-
self._threads: dict[str, threading.Thread] = {}
|
|
53
|
-
|
|
54
|
-
# ------------------------------------------------------------------
|
|
55
|
-
# Criação e consulta
|
|
56
|
-
# ------------------------------------------------------------------
|
|
57
|
-
|
|
58
|
-
def criar_job(self, dados: dict) -> dict:
|
|
59
|
-
tema = (dados.get("tema") or "").strip()
|
|
60
|
-
if not tema:
|
|
61
|
-
raise ValueError("Informe o tema do vídeo.")
|
|
62
|
-
formatos = dados.get("formatos") or ["wide", "shorts"]
|
|
63
|
-
formatos = [f for f in formatos if f in ("wide", "shorts")]
|
|
64
|
-
if not formatos:
|
|
65
|
-
raise ValueError("Selecione ao menos um formato (Wide e/ou Shorts).")
|
|
66
|
-
|
|
67
|
-
job = {
|
|
68
|
-
"id": uuid.uuid4().hex[:12],
|
|
69
|
-
"titulo": (dados.get("titulo") or "").strip(),
|
|
70
|
-
"tema": tema,
|
|
71
|
-
"tom": dados.get("tom") or "informativo",
|
|
72
|
-
"duracao_min": max(1, min(20, int(dados.get("duracao_min") or 8))),
|
|
73
|
-
"keyword_seo": (dados.get("keyword_seo") or "").strip(),
|
|
74
|
-
"formatos": formatos,
|
|
75
|
-
"criado_em": storage.now_iso(),
|
|
76
|
-
"atualizado_em": storage.now_iso(),
|
|
77
|
-
"status": "rascunho",
|
|
78
|
-
"etapa_atual": None,
|
|
79
|
-
"etapas": [
|
|
80
|
-
{
|
|
81
|
-
"id": etapa,
|
|
82
|
-
"nome": ETAPA_LABELS[etapa],
|
|
83
|
-
"status": "pendente",
|
|
84
|
-
"iniciada_em": None,
|
|
85
|
-
"concluida_em": None,
|
|
86
|
-
"mensagem": "",
|
|
87
|
-
"dados": {},
|
|
88
|
-
"logs": [],
|
|
89
|
-
}
|
|
90
|
-
for etapa in ETAPAS
|
|
91
|
-
],
|
|
92
|
-
"dados": {},
|
|
93
|
-
"upload": {},
|
|
94
|
-
}
|
|
95
|
-
storage.upsert_job(job)
|
|
96
|
-
return job
|
|
97
|
-
|
|
98
|
-
def listar(self) -> list:
|
|
99
|
-
jobs = storage.list_jobs()
|
|
100
|
-
for job in jobs:
|
|
101
|
-
job["progresso"] = progresso(job)
|
|
102
|
-
return sorted(jobs, key=lambda j: j.get("criado_em", ""), reverse=True)
|
|
103
|
-
|
|
104
|
-
# ------------------------------------------------------------------
|
|
105
|
-
# Controles (iniciar/pausar/parar/repetir)
|
|
106
|
-
# ------------------------------------------------------------------
|
|
107
|
-
|
|
108
|
-
def agir(self, job_id: str, acao: str) -> dict:
|
|
109
|
-
if acao == "repetir":
|
|
110
|
-
self._resetar_erro(job_id)
|
|
111
|
-
return self.iniciar(job_id)
|
|
112
|
-
if acao == "iniciar":
|
|
113
|
-
return self.iniciar(job_id)
|
|
114
|
-
if acao == "pausar":
|
|
115
|
-
return self.pausar(job_id)
|
|
116
|
-
if acao == "parar":
|
|
117
|
-
return self.parar(job_id)
|
|
118
|
-
raise ValueError(f"Ação desconhecida: {acao}")
|
|
119
|
-
|
|
120
|
-
def _resetar_erro(self, job_id: str) -> None:
|
|
121
|
-
"""Repetir: volta a primeira etapa com erro para pendente."""
|
|
122
|
-
job = storage.get_job(job_id)
|
|
123
|
-
if not job:
|
|
124
|
-
return
|
|
125
|
-
for etapa in job["etapas"]:
|
|
126
|
-
if etapa["status"] == "erro":
|
|
127
|
-
etapa["status"] = "pendente"
|
|
128
|
-
etapa["iniciada_em"] = None
|
|
129
|
-
etapa["concluida_em"] = None
|
|
130
|
-
etapa["mensagem"] = "Repetição solicitada pelo usuário."
|
|
131
|
-
break
|
|
132
|
-
storage.upsert_job(job)
|
|
133
|
-
|
|
134
|
-
def iniciar(self, job_id: str) -> dict:
|
|
135
|
-
job = storage.get_job(job_id)
|
|
136
|
-
if not job:
|
|
137
|
-
return {"ok": False, "mensagem": "Job não encontrado."}
|
|
138
|
-
if job["status"] == "em_andamento":
|
|
139
|
-
return {"ok": False, "mensagem": "Job já está em andamento."}
|
|
140
|
-
if job["status"] == "concluido":
|
|
141
|
-
return {"ok": False, "mensagem": "Job já concluído — crie um novo vídeo."}
|
|
142
|
-
|
|
143
|
-
self._pausas.setdefault(job_id, threading.Event())
|
|
144
|
-
self._pausas[job_id].clear()
|
|
145
|
-
self._paradas.setdefault(job_id, threading.Event())
|
|
146
|
-
self._paradas[job_id].clear()
|
|
147
|
-
|
|
148
|
-
thread = self._threads.get(job_id)
|
|
149
|
-
if thread and thread.is_alive():
|
|
150
|
-
job["status"] = "em_andamento"
|
|
151
|
-
job["atualizado_em"] = storage.now_iso()
|
|
152
|
-
storage.upsert_job(job)
|
|
153
|
-
return {"ok": True, "mensagem": "Job retomado."}
|
|
154
|
-
|
|
155
|
-
job["status"] = "em_andamento"
|
|
156
|
-
job["atualizado_em"] = storage.now_iso()
|
|
157
|
-
storage.upsert_job(job)
|
|
158
|
-
t = threading.Thread(target=self._executar, args=(job_id,), daemon=True)
|
|
159
|
-
self._threads[job_id] = t
|
|
160
|
-
t.start()
|
|
161
|
-
return {"ok": True, "mensagem": "Pipeline iniciado."}
|
|
162
|
-
|
|
163
|
-
def pausar(self, job_id: str) -> dict:
|
|
164
|
-
pausa = self._pausas.get(job_id)
|
|
165
|
-
thread = self._threads.get(job_id)
|
|
166
|
-
if not pausa or not thread or not thread.is_alive():
|
|
167
|
-
return {"ok": False, "mensagem": "Job não está em execução."}
|
|
168
|
-
pausa.set()
|
|
169
|
-
return {"ok": True, "mensagem": "Pausa solicitada — aplicada entre etapas."}
|
|
170
|
-
|
|
171
|
-
def parar(self, job_id: str) -> dict:
|
|
172
|
-
parada = self._paradas.setdefault(job_id, threading.Event())
|
|
173
|
-
parada.set()
|
|
174
|
-
pausa = self._pausas.get(job_id)
|
|
175
|
-
if pausa:
|
|
176
|
-
pausa.clear() # destrava a espera para que a parada seja percebida
|
|
177
|
-
return {"ok": True, "mensagem": "Parada solicitada."}
|
|
178
|
-
|
|
179
|
-
# ------------------------------------------------------------------
|
|
180
|
-
# Worker
|
|
181
|
-
# ------------------------------------------------------------------
|
|
182
|
-
|
|
183
|
-
def _checar(self, job_id: str) -> None:
|
|
184
|
-
parada = self._paradas.get(job_id)
|
|
185
|
-
if parada and parada.is_set():
|
|
186
|
-
raise _Parada()
|
|
187
|
-
pausa = self._pausas.get(job_id)
|
|
188
|
-
if pausa and pausa.is_set():
|
|
189
|
-
job = storage.get_job(job_id)
|
|
190
|
-
if job and job["status"] == "em_andamento":
|
|
191
|
-
job["status"] = "pausado"
|
|
192
|
-
job["atualizado_em"] = storage.now_iso()
|
|
193
|
-
storage.upsert_job(job)
|
|
194
|
-
while pausa.is_set():
|
|
195
|
-
time.sleep(0.5)
|
|
196
|
-
if parada and parada.is_set():
|
|
197
|
-
raise _Parada()
|
|
198
|
-
job = storage.get_job(job_id)
|
|
199
|
-
if job and job["status"] == "pausado":
|
|
200
|
-
job["status"] = "em_andamento"
|
|
201
|
-
job["atualizado_em"] = storage.now_iso()
|
|
202
|
-
storage.upsert_job(job)
|
|
203
|
-
|
|
204
|
-
def _executar(self, job_id: str) -> None:
|
|
205
|
-
try:
|
|
206
|
-
while True:
|
|
207
|
-
job = storage.get_job(job_id)
|
|
208
|
-
if not job:
|
|
209
|
-
return
|
|
210
|
-
pendente = None
|
|
211
|
-
for etapa in job["etapas"]:
|
|
212
|
-
if etapa["status"] == "pendente":
|
|
213
|
-
pendente = etapa
|
|
214
|
-
break
|
|
215
|
-
if pendente is None:
|
|
216
|
-
# Nada mais a executar: conclui com o status correto.
|
|
217
|
-
com_erro = any(e["status"] == "erro" for e in job["etapas"])
|
|
218
|
-
job["status"] = "erro" if com_erro else "concluido"
|
|
219
|
-
job["etapa_atual"] = None
|
|
220
|
-
job["atualizado_em"] = storage.now_iso()
|
|
221
|
-
storage.upsert_job(job)
|
|
222
|
-
return
|
|
223
|
-
self._checar(job_id)
|
|
224
|
-
self._executar_etapa(job_id, pendente["id"])
|
|
225
|
-
# Etapa falhou? Interrompe o fluxo — "Repetir" retoma daqui.
|
|
226
|
-
job = storage.get_job(job_id)
|
|
227
|
-
if job and job["status"] == "erro":
|
|
228
|
-
return
|
|
229
|
-
except _Parada:
|
|
230
|
-
job = storage.get_job(job_id)
|
|
231
|
-
if job:
|
|
232
|
-
job["status"] = "cancelado"
|
|
233
|
-
job["atualizado_em"] = storage.now_iso()
|
|
234
|
-
for etapa in job["etapas"]:
|
|
235
|
-
if etapa["status"] == "em_andamento":
|
|
236
|
-
etapa["status"] = "pendente"
|
|
237
|
-
etapa["mensagem"] = "Etapa interrompida."
|
|
238
|
-
storage.upsert_job(job)
|
|
239
|
-
except Exception as e: # erro inesperado fora das etapas
|
|
240
|
-
job = storage.get_job(job_id)
|
|
241
|
-
if job:
|
|
242
|
-
job["status"] = "erro"
|
|
243
|
-
job["atualizado_em"] = storage.now_iso()
|
|
244
|
-
storage.upsert_job(job)
|
|
245
|
-
|
|
246
|
-
def _executar_etapa(self, job_id: str, etapa_id: str) -> None:
|
|
247
|
-
job = storage.get_job(job_id)
|
|
248
|
-
etapa = next(e for e in job["etapas"] if e["id"] == etapa_id)
|
|
249
|
-
etapa["status"] = "em_andamento"
|
|
250
|
-
etapa["iniciada_em"] = storage.now_iso()
|
|
251
|
-
job["etapa_atual"] = etapa_id
|
|
252
|
-
job["atualizado_em"] = storage.now_iso()
|
|
253
|
-
storage.upsert_job(job)
|
|
254
|
-
|
|
255
|
-
try:
|
|
256
|
-
if etapa_id == "roteiro":
|
|
257
|
-
resultado = self._etapa_roteiro(job)
|
|
258
|
-
elif etapa_id == "thumbnail":
|
|
259
|
-
resultado = self._etapa_thumbnail(job)
|
|
260
|
-
elif etapa_id == "wide":
|
|
261
|
-
resultado = self._etapa_wide(job)
|
|
262
|
-
elif etapa_id == "shorts":
|
|
263
|
-
resultado = self._etapa_shorts(job)
|
|
264
|
-
elif etapa_id == "upload":
|
|
265
|
-
resultado = self._etapa_upload(job)
|
|
266
|
-
else:
|
|
267
|
-
raise ValueError(f"Etapa desconhecida: {etapa_id}")
|
|
268
|
-
except Exception as e:
|
|
269
|
-
job = storage.get_job(job_id)
|
|
270
|
-
etapa = next(e2 for e2 in job["etapas"] if e2["id"] == etapa_id)
|
|
271
|
-
etapa["status"] = "erro"
|
|
272
|
-
etapa["mensagem"] = str(e)
|
|
273
|
-
etapa["logs"].append(f"[{_hora()}] ✗ {e}")
|
|
274
|
-
etapa["concluida_em"] = storage.now_iso()
|
|
275
|
-
job["status"] = "erro"
|
|
276
|
-
job["atualizado_em"] = storage.now_iso()
|
|
277
|
-
storage.upsert_job(job)
|
|
278
|
-
return
|
|
279
|
-
|
|
280
|
-
job = storage.get_job(job_id)
|
|
281
|
-
etapa = next(e2 for e2 in job["etapas"] if e2["id"] == etapa_id)
|
|
282
|
-
etapa["status"] = resultado["status"]
|
|
283
|
-
etapa["mensagem"] = resultado["mensagem"]
|
|
284
|
-
etapa["dados"] = resultado.get("dados", {})
|
|
285
|
-
etapa["concluida_em"] = storage.now_iso()
|
|
286
|
-
for linha in resultado.get("logs", []):
|
|
287
|
-
etapa["logs"].append(f"[{_hora()}] {linha}")
|
|
288
|
-
job["dados"].update(resultado.get("job_dados", {}))
|
|
289
|
-
if "upload" in resultado.get("job_dados", {}):
|
|
290
|
-
job["upload"] = resultado["job_dados"]["upload"]
|
|
291
|
-
job["etapa_atual"] = None
|
|
292
|
-
job["atualizado_em"] = storage.now_iso()
|
|
293
|
-
storage.upsert_job(job)
|
|
294
|
-
|
|
295
|
-
# ------------------------------------------------------------------
|
|
296
|
-
# Etapas
|
|
297
|
-
# ------------------------------------------------------------------
|
|
298
|
-
|
|
299
|
-
def _etapa_roteiro(self, job: dict) -> dict:
|
|
300
|
-
resultado = llm.gerar_roteiro(
|
|
301
|
-
tema=job["tema"],
|
|
302
|
-
titulo=job.get("titulo", ""),
|
|
303
|
-
tom=job.get("tom", "informativo"),
|
|
304
|
-
duracao_min=job.get("duracao_min", 8),
|
|
305
|
-
)
|
|
306
|
-
roteiro = resultado["roteiro"]
|
|
307
|
-
arquivo = storage.roteiros_dir() / f"{job['id']}.txt"
|
|
308
|
-
arquivo.write_text(roteiro, encoding="utf-8")
|
|
309
|
-
logs = [
|
|
310
|
-
f"Roteiro com {len(roteiro):,} caracteres (origem: {resultado['origem']}).".replace(",", "."),
|
|
311
|
-
f"Salvo em {arquivo}",
|
|
312
|
-
]
|
|
313
|
-
if resultado.get("aviso"):
|
|
314
|
-
logs.append(f"⚠ {resultado['aviso']}")
|
|
315
|
-
return {
|
|
316
|
-
"status": "concluida",
|
|
317
|
-
"mensagem": f"Roteiro pronto ({len(roteiro):,} caracteres — {resultado['origem']}).".replace(",", "."),
|
|
318
|
-
"job_dados": {
|
|
319
|
-
"roteiro": roteiro,
|
|
320
|
-
"roteiro_chars": len(roteiro),
|
|
321
|
-
"arquivo_roteiro": str(arquivo),
|
|
322
|
-
"origem_roteiro": resultado["origem"],
|
|
323
|
-
},
|
|
324
|
-
"logs": logs,
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
def _etapa_thumbnail(self, job: dict) -> dict:
|
|
328
|
-
resultado = llm.gerar_titulo_e_thumbnail(job["tema"], job.get("dados", {}).get("roteiro", ""))
|
|
329
|
-
return {
|
|
330
|
-
"status": "concluida",
|
|
331
|
-
"mensagem": (
|
|
332
|
-
f"Título e prompt de thumbnail gerados ({resultado['origem']}). "
|
|
333
|
-
"Renderize a imagem no MoneyPrinterTurbo com o prompt salvo."
|
|
334
|
-
),
|
|
335
|
-
"job_dados": {
|
|
336
|
-
"titulo_gerado": resultado["titulo"],
|
|
337
|
-
"titulo_alternativo": resultado["titulo_alternativo"],
|
|
338
|
-
"thumbnail_prompt": resultado["thumbnail_prompt"],
|
|
339
|
-
"origem_titulo": resultado["origem"],
|
|
340
|
-
},
|
|
341
|
-
"logs": [
|
|
342
|
-
f"Título: {resultado['titulo']}",
|
|
343
|
-
f"Alternativo: {resultado['titulo_alternativo']}",
|
|
344
|
-
f"Prompt thumbnail: {resultado['thumbnail_prompt'][:120]}...",
|
|
345
|
-
],
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
def _etapa_wide(self, job: dict) -> dict:
|
|
349
|
-
if "wide" not in job.get("formatos", []):
|
|
350
|
-
return {"status": "pulada", "mensagem": "Formato Wide não selecionado para este job.", "logs": []}
|
|
351
|
-
roteiro = job.get("dados", {}).get("roteiro", "")
|
|
352
|
-
subject = job.get("dados", {}).get("titulo_gerado") or job.get("titulo") or job["tema"]
|
|
353
|
-
logs = []
|
|
354
|
-
try:
|
|
355
|
-
task_id = moneyprinter.criar_video_wide(
|
|
356
|
-
subject=subject,
|
|
357
|
-
script=roteiro,
|
|
358
|
-
terms=job.get("keyword_seo") or job["tema"],
|
|
359
|
-
)
|
|
360
|
-
logs.append(f"Tarefa criada no MoneyPrinterTurbo: {task_id}")
|
|
361
|
-
resultado = moneyprinter.aguardar_video(task_id, timeout_min=20)
|
|
362
|
-
videos = resultado.get("videos", [])
|
|
363
|
-
logs.append(f"Vídeo wide pronto: {videos[0] if videos else 'sem URL listada'}")
|
|
364
|
-
return {
|
|
365
|
-
"status": "concluida",
|
|
366
|
-
"mensagem": "Vídeo wide 16:9 produzido com MoneyPrinterTurbo.",
|
|
367
|
-
"job_dados": {"videos_wide": videos, "task_id_wide": task_id, "producao_wide": "moneyprinter"},
|
|
368
|
-
"logs": logs,
|
|
369
|
-
}
|
|
370
|
-
except moneyprinter.ServicoIndisponivel as e:
|
|
371
|
-
logs.append(f"✗ {e}")
|
|
372
|
-
if openmontage.disponivel():
|
|
373
|
-
logs.append("Tentando fallback via OpenMontage...")
|
|
374
|
-
om = openmontage.produzir(job["id"], job["dados"]["arquivo_roteiro"])
|
|
375
|
-
logs.append(f"OpenMontage concluído: {om.get('video_path') or om.get('observacao')}")
|
|
376
|
-
return {
|
|
377
|
-
"status": "concluida",
|
|
378
|
-
"mensagem": "Vídeo wide produzido via OpenMontage (fallback).",
|
|
379
|
-
"job_dados": {
|
|
380
|
-
"videos_wide": [],
|
|
381
|
-
"arquivo_local": om.get("video_path") or "",
|
|
382
|
-
"producao_wide": "openmontage",
|
|
383
|
-
},
|
|
384
|
-
"logs": logs,
|
|
385
|
-
}
|
|
386
|
-
raise
|
|
387
|
-
|
|
388
|
-
def _etapa_shorts(self, job: dict) -> dict:
|
|
389
|
-
if "shorts" not in job.get("formatos", []):
|
|
390
|
-
return {"status": "pulada", "mensagem": "Formato Shorts não selecionado para este job.", "logs": []}
|
|
391
|
-
roteiro = job.get("dados", {}).get("roteiro", "")
|
|
392
|
-
titulo = job.get("dados", {}).get("titulo_gerado") or job.get("titulo") or job["tema"]
|
|
393
|
-
resultado = short_video_maker.criar_short(
|
|
394
|
-
titulo=titulo,
|
|
395
|
-
script=roteiro,
|
|
396
|
-
tema=job["tema"],
|
|
397
|
-
)
|
|
398
|
-
return {
|
|
399
|
-
"status": "concluida",
|
|
400
|
-
"mensagem": "Tarefa enviada ao Short-Video-Maker (9:16).",
|
|
401
|
-
"job_dados": {"resposta_shorts": resultado},
|
|
402
|
-
"logs": [f"HTTP {resultado['status_http']} do Short-Video-Maker."],
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
def _etapa_upload(self, job: dict) -> dict:
|
|
406
|
-
conf = storage.get_config()["integracoes"]["youtube_upload"]
|
|
407
|
-
if not (conf.get("url") or conf.get("uploader_mcp_path")):
|
|
408
|
-
return {
|
|
409
|
-
"status": "pulada",
|
|
410
|
-
"mensagem": (
|
|
411
|
-
"Upload manual: nenhum uploader configurado. Configure em "
|
|
412
|
-
"Ferramentas → Upload YouTube e clique em Repetir."
|
|
413
|
-
),
|
|
414
|
-
"logs": ["Uploader não configurado — etapa pulada (não é erro)."],
|
|
415
|
-
}
|
|
416
|
-
videos = job.get("dados", {}).get("videos_wide") or []
|
|
417
|
-
arquivo = job.get("dados", {}).get("arquivo_local") or ""
|
|
418
|
-
if not videos and not arquivo:
|
|
419
|
-
raise youtube_upload.UploadErro(
|
|
420
|
-
"Nenhum vídeo produzido para enviar. Conclua a etapa de produção (Wide) antes do upload."
|
|
421
|
-
)
|
|
422
|
-
titulo = job.get("dados", {}).get("titulo_gerado") or job.get("titulo") or job["tema"]
|
|
423
|
-
descricao = f"{titulo}\n\n{job.get('keyword_seo') or job['tema']}\n\nGerado pelo LeadMaster."
|
|
424
|
-
resultado = youtube_upload.upload_video(
|
|
425
|
-
titulo=titulo,
|
|
426
|
-
descricao=descricao,
|
|
427
|
-
arquivo=arquivo,
|
|
428
|
-
video_url=videos[0] if videos else "",
|
|
429
|
-
tags=job.get("keyword_seo", ""),
|
|
430
|
-
)
|
|
431
|
-
upload = dict(job.get("upload") or {})
|
|
432
|
-
upload.update(
|
|
433
|
-
{
|
|
434
|
-
"video_id": resultado.get("video_id", ""),
|
|
435
|
-
"url": resultado.get("url", ""),
|
|
436
|
-
"modo_upload": resultado.get("modo", ""),
|
|
437
|
-
"status": "nao_listado",
|
|
438
|
-
"publicado_em": None,
|
|
439
|
-
}
|
|
440
|
-
)
|
|
441
|
-
logs = [f"Upload via {resultado.get('modo')} concluído: {resultado.get('url') or resultado.get('video_id')}"]
|
|
442
|
-
if conf.get("modo") == "nao_listado_1h_publico":
|
|
443
|
-
horas = max(0, int(conf.get("espera_publicacao_horas", 1)))
|
|
444
|
-
agenda = (datetime.now().astimezone() + timedelta(hours=horas)).isoformat(timespec="seconds")
|
|
445
|
-
upload["agenda_publicacao"] = agenda
|
|
446
|
-
logs.append(f"Vídeo não listado; publicação automática agendada para {agenda}.")
|
|
447
|
-
mensagem = f"Upload não listado criado. Publicação automática em {horas}h ({agenda[11:16]})."
|
|
448
|
-
else:
|
|
449
|
-
upload["agenda_publicacao"] = None
|
|
450
|
-
mensagem = "Upload criado (modo direto, sem agendamento)."
|
|
451
|
-
return {
|
|
452
|
-
"status": "concluida",
|
|
453
|
-
"mensagem": mensagem,
|
|
454
|
-
"job_dados": {"upload": upload},
|
|
455
|
-
"logs": logs,
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
# ------------------------------------------------------------------
|
|
459
|
-
# Agendador de publicação (não listado → público)
|
|
460
|
-
# ------------------------------------------------------------------
|
|
461
|
-
|
|
462
|
-
def iniciar_agendador(self) -> None:
|
|
463
|
-
if getattr(self, "_agendador_ativo", False):
|
|
464
|
-
return
|
|
465
|
-
self._agendador_ativo = True
|
|
466
|
-
threading.Thread(target=self._loop_agendador, daemon=True).start()
|
|
467
|
-
|
|
468
|
-
def _loop_agendador(self) -> None:
|
|
469
|
-
while True:
|
|
470
|
-
time.sleep(60)
|
|
471
|
-
try:
|
|
472
|
-
self._processar_agenda()
|
|
473
|
-
except Exception:
|
|
474
|
-
pass # agendador é resiliente por definição
|
|
475
|
-
|
|
476
|
-
def _processar_agenda(self) -> None:
|
|
477
|
-
agora = datetime.now().astimezone()
|
|
478
|
-
for job in storage.list_jobs():
|
|
479
|
-
upload = job.get("upload") or {}
|
|
480
|
-
agenda = upload.get("agenda_publicacao")
|
|
481
|
-
if not agenda or upload.get("publicado_em"):
|
|
482
|
-
continue
|
|
483
|
-
if datetime.fromisoformat(agenda) > agora:
|
|
484
|
-
continue
|
|
485
|
-
if int(upload.get("tentativas", 0)) >= 10:
|
|
486
|
-
upload["agenda_publicacao"] = None
|
|
487
|
-
upload["status"] = "falhou"
|
|
488
|
-
job["upload"] = upload
|
|
489
|
-
storage.upsert_job(job)
|
|
490
|
-
continue
|
|
491
|
-
try:
|
|
492
|
-
youtube_upload.tornar_publico(upload.get("video_id", ""))
|
|
493
|
-
upload["publicado_em"] = storage.now_iso()
|
|
494
|
-
upload["status"] = "publico"
|
|
495
|
-
upload.pop("ultimo_erro", None)
|
|
496
|
-
job["upload"] = upload
|
|
497
|
-
storage.upsert_job(job)
|
|
498
|
-
agents.disparar_webhook("video.publicado", {"job_id": job["id"], "url": upload.get("url")})
|
|
499
|
-
except Exception as e:
|
|
500
|
-
upload["tentativas"] = int(upload.get("tentativas", 0)) + 1
|
|
501
|
-
upload["ultimo_erro"] = str(e)
|
|
502
|
-
job["upload"] = upload
|
|
503
|
-
storage.upsert_job(job)
|
|
504
|
-
|
|
505
|
-
def recuperar(self) -> None:
|
|
506
|
-
"""Após reinício do servidor: jobs 'em_andamento' voltam a 'pausado'."""
|
|
507
|
-
for job in storage.list_jobs():
|
|
508
|
-
if job.get("status") == "em_andamento":
|
|
509
|
-
job["status"] = "pausado"
|
|
510
|
-
job["atualizado_em"] = storage.now_iso()
|
|
511
|
-
for etapa in job["etapas"]:
|
|
512
|
-
if etapa["status"] == "em_andamento":
|
|
513
|
-
etapa["status"] = "pendente"
|
|
514
|
-
etapa["mensagem"] = "Etapa interrompida pelo reinício do servidor."
|
|
515
|
-
storage.upsert_job(job)
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
"""Integração com o Short-Video-Maker (produção de Shorts 9:16).
|
|
2
|
-
|
|
3
|
-
API esperada: POST http://localhost:3123/api/short-video
|
|
4
|
-
Como o formato exato do projeto pode variar, a resposta bruta (truncada) é
|
|
5
|
-
sempre guardada no job para inspeção.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import httpx
|
|
9
|
-
|
|
10
|
-
from . import storage
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class ServicoIndisponivel(Exception):
|
|
14
|
-
"""Short-Video-Maker não está rodando — mensagem acionável."""
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class ProducaoErro(Exception):
|
|
18
|
-
"""O serviço respondeu, mas a produção falhou."""
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _conf() -> dict:
|
|
22
|
-
return storage.get_config()["integracoes"]["short_video_maker"]
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _url() -> str:
|
|
26
|
-
return _conf()["url"].rstrip("/")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def verificar() -> bool:
|
|
30
|
-
try:
|
|
31
|
-
httpx.get(_url(), timeout=2)
|
|
32
|
-
return True
|
|
33
|
-
except httpx.HTTPError:
|
|
34
|
-
return False
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def criar_short(
|
|
38
|
-
titulo: str,
|
|
39
|
-
script: str,
|
|
40
|
-
tema: str = "",
|
|
41
|
-
voz: str = "pt-BR",
|
|
42
|
-
duracao_segundos: int = 45,
|
|
43
|
-
) -> dict:
|
|
44
|
-
"""Envia o roteiro cortado para o Short-Video-Maker e devolve a resposta."""
|
|
45
|
-
# Shorts usam o início do roteiro (gancho) — corte na primeira quebra limpa.
|
|
46
|
-
corte = script[:1200]
|
|
47
|
-
if len(script) > 1200:
|
|
48
|
-
fim = corte.rfind(". ")
|
|
49
|
-
corte = corte[: fim + 1] if fim > 400 else corte
|
|
50
|
-
payload = {
|
|
51
|
-
"title": titulo,
|
|
52
|
-
"script": corte,
|
|
53
|
-
"topic": tema or titulo,
|
|
54
|
-
"voice": voz,
|
|
55
|
-
"duration": duracao_segundos,
|
|
56
|
-
"aspect": "9:16",
|
|
57
|
-
}
|
|
58
|
-
try:
|
|
59
|
-
r = httpx.post(_url() + "/api/short-video", json=payload, timeout=300)
|
|
60
|
-
except httpx.HTTPError as e:
|
|
61
|
-
raise ServicoIndisponivel(
|
|
62
|
-
f"Short-Video-Maker não respondeu em {_url()}. "
|
|
63
|
-
"Inicie o serviço na porta 3123 e clique em Repetir."
|
|
64
|
-
) from e
|
|
65
|
-
texto = r.text[:600]
|
|
66
|
-
if r.status_code >= 400:
|
|
67
|
-
raise ProducaoErro(f"Short-Video-Maker rejeitou a tarefa (HTTP {r.status_code}): {texto}")
|
|
68
|
-
try:
|
|
69
|
-
corpo = r.json()
|
|
70
|
-
except ValueError:
|
|
71
|
-
corpo = {"resposta_bruta": texto}
|
|
72
|
-
return {"status_http": r.status_code, "resposta": corpo}
|