@danhachuel/leadmaster 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,515 @@
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)
@@ -0,0 +1,72 @@
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}