Spaces:
Paused
Paused
Update api/ltx_server.py
Browse files- api/ltx_server.py +84 -116
api/ltx_server.py
CHANGED
|
@@ -438,152 +438,120 @@ class VideoService:
|
|
| 438 |
|
| 439 |
|
| 440 |
|
| 441 |
-
|
| 442 |
-
"""Retorna metadados essenciais do vídeo (DNA)."""
|
| 443 |
-
cmd = f"ffprobe -v error -select_streams v:0 -show_entries " \
|
| 444 |
-
f"stream=codec_name,width,height,avg_frame_rate,duration -of json {shlex.quote(path)}"
|
| 445 |
-
try:
|
| 446 |
-
result = subprocess.run(shlex.split(cmd), capture_output=True, text=True, check=True)
|
| 447 |
-
data = json.loads(result.stdout)
|
| 448 |
-
stream = data.get("streams", [{}])[0]
|
| 449 |
-
fps_str = stream.get("avg_frame_rate", "0/1")
|
| 450 |
-
try:
|
| 451 |
-
num, den = map(float, fps_str.split("/"))
|
| 452 |
-
fps = num / den if den != 0 else 0
|
| 453 |
-
except Exception:
|
| 454 |
-
fps = 0
|
| 455 |
-
info = {
|
| 456 |
-
"arquivo": os.path.basename(path),
|
| 457 |
-
"codec": stream.get("codec_name"),
|
| 458 |
-
"resolução": f"{stream.get('width')}x{stream.get('height')}",
|
| 459 |
-
"fps": round(fps, 2),
|
| 460 |
-
"duração": float(stream.get("duration", 0))
|
| 461 |
-
}
|
| 462 |
-
print(f"🧬 [DNA] {info}")
|
| 463 |
-
return info
|
| 464 |
-
except subprocess.CalledProcessError:
|
| 465 |
-
print(f"⚠️ Falha ao ler metadados de {path}")
|
| 466 |
-
return {}
|
| 467 |
-
|
| 468 |
|
| 469 |
def _gerar_lista_com_transicoes(self, pasta: str, video_paths: list[str], crossfade_frames: int = 8) -> list[str]:
|
| 470 |
-
|
| 471 |
-
Gera uma nova lista de vídeos aplicando transições suaves (crossfade de N frames)
|
| 472 |
-
entre cada par de vídeos da lista original.
|
| 473 |
-
Mantém todos os arquivos na mesma pasta dos vídeos originais.
|
| 474 |
-
"""
|
| 475 |
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
|
|
|
|
|
|
| 484 |
|
| 485 |
-
|
|
|
|
|
|
|
| 486 |
|
| 487 |
-
# 🔹 Iterar pelos vídeos originais
|
| 488 |
for i, video_atual in enumerate(video_paths):
|
| 489 |
base_nome = os.path.splitext(os.path.basename(video_atual))[0]
|
| 490 |
-
|
|
|
|
|
|
|
|
|
|
| 491 |
|
| 492 |
-
#
|
| 493 |
if i == 0:
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
f'
|
| 498 |
-
f'"{video_podado_fim}"'
|
| 499 |
)
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
print(f"[DEBUG] Adicionado {video_podado_fim}")
|
| 503 |
|
| 504 |
-
#
|
| 505 |
if i < len(video_paths) - 1:
|
| 506 |
-
|
| 507 |
-
prox_nome = os.path.splitext(os.path.basename(
|
|
|
|
| 508 |
|
| 509 |
-
#
|
| 510 |
-
fim_tmp = os.path.join(pasta, f"{base_nome}
|
| 511 |
cmd_fim = (
|
| 512 |
-
f'ffmpeg -y -
|
| 513 |
-
f'-
|
| 514 |
)
|
| 515 |
-
|
| 516 |
|
| 517 |
-
#
|
| 518 |
-
inicio_tmp = os.path.join(pasta, f"{prox_nome}
|
| 519 |
cmd_inicio = (
|
| 520 |
-
f'ffmpeg -y -i "{
|
| 521 |
-
f'-an "{inicio_tmp}"'
|
| 522 |
)
|
| 523 |
-
|
| 524 |
|
| 525 |
-
#
|
| 526 |
-
|
| 527 |
-
|
| 528 |
f'ffmpeg -y -i "{fim_tmp}" -i "{inicio_tmp}" '
|
| 529 |
f'-filter_complex "xfade=transition=fade:duration={crossfade_frames/24:.3f}:offset=0" '
|
| 530 |
-
f'-c:v libx264 -pix_fmt yuv420p "{
|
| 531 |
)
|
| 532 |
-
|
|
|
|
| 533 |
|
| 534 |
-
|
| 535 |
-
print(f"[DEBUG] Transição adicionada: {transicao_path}")
|
| 536 |
-
|
| 537 |
-
# --- VÍDEO INTERMEDIÁRIO (remover início e fim) ---
|
| 538 |
if i + 1 < len(video_paths) - 1:
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
f'ffmpeg -y -i "{
|
| 542 |
-
f'"{
|
|
|
|
| 543 |
)
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
cmd_trim_inicio = (
|
| 552 |
-
f'ffmpeg -y -i "{proximo_video}" -vf "trim={crossfade_frames},setpts=PTS-STARTPTS" '
|
| 553 |
-
f'"{video_podado_inicio}"'
|
| 554 |
)
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
print(f"[DEBUG] Adicionado último vídeo podado: {video_podado_inicio}")
|
| 558 |
-
|
| 559 |
-
print(f"[DEBUG] Nova lista final de {len(nova_lista)} arquivos criada.")
|
| 560 |
-
return nova_lista
|
| 561 |
-
|
| 562 |
-
def _concat_mp4s_no_reencode(self, mp4_list: List[str], out_path: str):
|
| 563 |
-
"""
|
| 564 |
-
Concatena múltiplos MP4s sem reencode usando o demuxer do ffmpeg.
|
| 565 |
-
ATENÇÃO: todos os arquivos precisam ter mesmo codec, fps, resolução etc.
|
| 566 |
-
"""
|
| 567 |
-
if not mp4_list or len(mp4_list) < 2:
|
| 568 |
-
raise ValueError("Forneça pelo menos dois arquivos MP4 para concatenar.")
|
| 569 |
|
| 570 |
-
#
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
|
| 576 |
-
|
| 577 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
|
| 579 |
-
try:
|
| 580 |
-
subprocess.check_call(shlex.split(cmd))
|
| 581 |
-
finally:
|
| 582 |
-
try:
|
| 583 |
-
os.remove(list_path)
|
| 584 |
-
except Exception:
|
| 585 |
-
pass
|
| 586 |
|
|
|
|
| 587 |
def generate(
|
| 588 |
self,
|
| 589 |
prompt,
|
|
@@ -807,7 +775,7 @@ class VideoService:
|
|
| 807 |
print(f"[DEBUG] Falha no move; usando tmp como final: {e}")
|
| 808 |
|
| 809 |
final_concat = os.path.join(results_dir, f"concat_fim_{used_seed}.mp4")
|
| 810 |
-
partes_mp4_fade = self._gerar_lista_com_transicoes(pasta=results_dir, video_paths=partes_mp4, crossfade_frames=
|
| 811 |
self._concat_mp4s_no_reencode(partes_mp4_fade, final_concat)
|
| 812 |
|
| 813 |
|
|
|
|
| 438 |
|
| 439 |
|
| 440 |
|
| 441 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
|
| 443 |
def _gerar_lista_com_transicoes(self, pasta: str, video_paths: list[str], crossfade_frames: int = 8) -> list[str]:
|
| 444 |
+
import os, subprocess, json
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
|
| 446 |
+
def get_duracao_frames(video):
|
| 447 |
+
"""Retorna duração em frames (int)."""
|
| 448 |
+
cmd = f'ffprobe -v error -select_streams v:0 -show_entries stream=nb_frames -of json "{video}"'
|
| 449 |
+
try:
|
| 450 |
+
res = subprocess.check_output(cmd, shell=True)
|
| 451 |
+
info = json.loads(res)
|
| 452 |
+
return int(info["streams"][0]["nb_frames"])
|
| 453 |
+
except Exception:
|
| 454 |
+
return 0
|
| 455 |
|
| 456 |
+
def run_safe(cmd, desc):
|
| 457 |
+
print(f"[DEBUG] {desc}: {cmd}")
|
| 458 |
+
result = subprocess.run(cmd, shell=True)
|
| 459 |
+
if result.returncode != 0:
|
| 460 |
+
print(f"[WARN] Falha ao executar: {desc}")
|
| 461 |
+
return result.returncode == 0
|
| 462 |
|
| 463 |
+
nova_lista = []
|
| 464 |
+
video_paths = [v for v in video_paths if os.path.isfile(v)]
|
| 465 |
+
print(f"[DEBUG] Iniciando geração de transições ({len(video_paths)} vídeos, fade={crossfade_frames}f)")
|
| 466 |
|
|
|
|
| 467 |
for i, video_atual in enumerate(video_paths):
|
| 468 |
base_nome = os.path.splitext(os.path.basename(video_atual))[0]
|
| 469 |
+
dur_frames = get_duracao_frames(video_atual)
|
| 470 |
+
if dur_frames <= crossfade_frames * 2:
|
| 471 |
+
print(f"[WARN] {video_atual} tem poucos frames ({dur_frames}). Pulando...")
|
| 472 |
+
continue
|
| 473 |
|
| 474 |
+
# 1️⃣ PRIMEIRO VÍDEO
|
| 475 |
if i == 0:
|
| 476 |
+
podado_fim = os.path.join(pasta, f"{base_nome}_fim.mp4")
|
| 477 |
+
cmd = (
|
| 478 |
+
f'ffmpeg -y -i "{video_atual}" -filter:v "trim=0:{dur_frames - crossfade_frames},setpts=PTS-STARTPTS" '
|
| 479 |
+
f'-an "{podado_fim}"'
|
|
|
|
| 480 |
)
|
| 481 |
+
if run_safe(cmd, f"Podando fim de {video_atual}"):
|
| 482 |
+
nova_lista.append(podado_fim)
|
|
|
|
| 483 |
|
| 484 |
+
# 2️⃣ GERAR TRANSIÇÃO COM O PRÓXIMO
|
| 485 |
if i < len(video_paths) - 1:
|
| 486 |
+
prox = video_paths[i + 1]
|
| 487 |
+
prox_nome = os.path.splitext(os.path.basename(prox))[0]
|
| 488 |
+
dur_prox = get_duracao_frames(prox)
|
| 489 |
|
| 490 |
+
# cortar fim do atual
|
| 491 |
+
fim_tmp = os.path.join(pasta, f"{base_nome}_fimtmp.mp4")
|
| 492 |
cmd_fim = (
|
| 493 |
+
f'ffmpeg -y -ss {max((dur_frames - crossfade_frames) / 24, 0):.3f} -i "{video_atual}" '
|
| 494 |
+
f'-frames:v {crossfade_frames} -an "{fim_tmp}"'
|
| 495 |
)
|
| 496 |
+
run_safe(cmd_fim, f"Extraindo fim de {base_nome}")
|
| 497 |
|
| 498 |
+
# cortar início do próximo
|
| 499 |
+
inicio_tmp = os.path.join(pasta, f"{prox_nome}_iniciotmp.mp4")
|
| 500 |
cmd_inicio = (
|
| 501 |
+
f'ffmpeg -y -i "{prox}" -frames:v {crossfade_frames} -an "{inicio_tmp}"'
|
|
|
|
| 502 |
)
|
| 503 |
+
run_safe(cmd_inicio, f"Extraindo início de {prox_nome}")
|
| 504 |
|
| 505 |
+
# crossfade (vídeo sem áudio)
|
| 506 |
+
transicao = os.path.join(pasta, f"transicao_{i+1}.mp4")
|
| 507 |
+
cmd_xfade = (
|
| 508 |
f'ffmpeg -y -i "{fim_tmp}" -i "{inicio_tmp}" '
|
| 509 |
f'-filter_complex "xfade=transition=fade:duration={crossfade_frames/24:.3f}:offset=0" '
|
| 510 |
+
f'-c:v libx264 -pix_fmt yuv420p -an "{transicao}"'
|
| 511 |
)
|
| 512 |
+
if run_safe(cmd_xfade, f"Transição {i+1}"):
|
| 513 |
+
nova_lista.append(transicao)
|
| 514 |
|
| 515 |
+
# podar início/fim do próximo
|
|
|
|
|
|
|
|
|
|
| 516 |
if i + 1 < len(video_paths) - 1:
|
| 517 |
+
podado_if = os.path.join(pasta, f"{prox_nome}_if.mp4")
|
| 518 |
+
cmd_if = (
|
| 519 |
+
f'ffmpeg -y -i "{prox}" '
|
| 520 |
+
f'-filter:v "trim={crossfade_frames}:{dur_prox - crossfade_frames},setpts=PTS-STARTPTS" '
|
| 521 |
+
f'-an "{podado_if}"'
|
| 522 |
)
|
| 523 |
+
if run_safe(cmd_if, f"Podando início/fim de {prox_nome}"):
|
| 524 |
+
nova_lista.append(podado_if)
|
| 525 |
+
else:
|
| 526 |
+
podado_inicio = os.path.join(pasta, f"{prox_nome}_inicio.mp4")
|
| 527 |
+
cmd_ini = (
|
| 528 |
+
f'ffmpeg -y -i "{prox}" '
|
| 529 |
+
f'-filter:v "trim={crossfade_frames}:{dur_prox},setpts=PTS-STARTPTS" -an "{podado_inicio}"'
|
|
|
|
|
|
|
|
|
|
| 530 |
)
|
| 531 |
+
if run_safe(cmd_ini, f"Podando início de {prox_nome}"):
|
| 532 |
+
nova_lista.append(podado_inicio)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
|
| 534 |
+
# 🔹 Remover vídeos vazios
|
| 535 |
+
nova_lista = [v for v in nova_lista if os.path.isfile(v) and get_duracao_frames(v) > 0]
|
| 536 |
+
print(f"[DEBUG] Lista final para concatenação: {len(nova_lista)} vídeos válidos")
|
| 537 |
+
for v in nova_lista:
|
| 538 |
+
print(f" - {v}")
|
| 539 |
|
| 540 |
+
return nova_list
|
| 541 |
+
|
| 542 |
+
def _concat_mp4s_no_reencode(self, lista_mp4, output_path):
|
| 543 |
+
import tempfile, subprocess
|
| 544 |
+
with tempfile.NamedTemporaryFile("w", delete=False, suffix=".txt") as f:
|
| 545 |
+
for mp4 in lista_mp4:
|
| 546 |
+
f.write(f"file '{os.path.abspath(mp4)}'\n")
|
| 547 |
+
lista_path = f.name
|
| 548 |
+
|
| 549 |
+
print(f"[DEBUG] Concatenando {len(lista_mp4)} partes em {output_path}")
|
| 550 |
+
cmd = f'ffmpeg -y -f concat -safe 0 -i "{lista_path}" -c copy "{output_path}"'
|
| 551 |
+
subprocess.run(cmd, shell=True, check=True)
|
| 552 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
|
| 554 |
+
|
| 555 |
def generate(
|
| 556 |
self,
|
| 557 |
prompt,
|
|
|
|
| 775 |
print(f"[DEBUG] Falha no move; usando tmp como final: {e}")
|
| 776 |
|
| 777 |
final_concat = os.path.join(results_dir, f"concat_fim_{used_seed}.mp4")
|
| 778 |
+
partes_mp4_fade = self._gerar_lista_com_transicoes(pasta=results_dir, video_paths=partes_mp4, crossfade_frames=9)
|
| 779 |
self._concat_mp4s_no_reencode(partes_mp4_fade, final_concat)
|
| 780 |
|
| 781 |
|