Spaces:
Paused
Paused
Update tools/video_encode_tool.py
Browse files- tools/video_encode_tool.py +164 -1
tools/video_encode_tool.py
CHANGED
|
@@ -38,4 +38,167 @@ class _SimpleVideoEncoder:
|
|
| 38 |
print(f"[DEBUG] [Encoder] frame {i}/{T} gravado ({H}x{W}@{fps}fps)")
|
| 39 |
|
| 40 |
# Singleton global de uso simples
|
| 41 |
-
video_encode_tool_singleton = _SimpleVideoEncoder()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
print(f"[DEBUG] [Encoder] frame {i}/{T} gravado ({H}x{W}@{fps}fps)")
|
| 39 |
|
| 40 |
# Singleton global de uso simples
|
| 41 |
+
video_encode_tool_singleton = _SimpleVideoEncoder()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# aduc_framework/tools/video_encode_tool.py
|
| 46 |
+
#
|
| 47 |
+
# Versão 1.4.0 (Conjunto de Ferramentas de Vídeo Finalizado)
|
| 48 |
+
# Copyright (C) August 4, 2025 Carlos Rodrigues dos Santos
|
| 49 |
+
#
|
| 50 |
+
# Este módulo atua como o especialista central para todas as operações de
|
| 51 |
+
# manipulação e codificação de vídeo. Ele abstrai as interações com
|
| 52 |
+
# FFmpeg e imageio, fornecendo uma API limpa e robusta para o resto do framework.
|
| 53 |
+
# - save_video_from_tensor: Converte um tensor de pixel em um arquivo .mp4.
|
| 54 |
+
# - extract_..._frame: Extrai frames específicos de clipes de vídeo.
|
| 55 |
+
# - concatenate_videos: Monta o filme final a partir dos clipes de cena.
|
| 56 |
+
|
| 57 |
+
import os
|
| 58 |
+
import subprocess
|
| 59 |
+
import logging
|
| 60 |
+
import random
|
| 61 |
+
import time
|
| 62 |
+
import shutil
|
| 63 |
+
from typing import List, Optional, Tuple
|
| 64 |
+
|
| 65 |
+
import imageio
|
| 66 |
+
import numpy as np
|
| 67 |
+
import torch
|
| 68 |
+
|
| 69 |
+
logger = logging.getLogger(__name__)
|
| 70 |
+
|
| 71 |
+
class VideoToolError(Exception):
|
| 72 |
+
"""Exceção personalizada para erros originados do VideoEncodeTool."""
|
| 73 |
+
pass
|
| 74 |
+
|
| 75 |
+
class VideoEncodeTool:
|
| 76 |
+
"""
|
| 77 |
+
Um especialista para lidar com tarefas de codificação e manipulação de vídeo.
|
| 78 |
+
"""
|
| 79 |
+
|
| 80 |
+
def save_video_from_tensor(self, video_tensor: torch.Tensor, path: str, fps: int = 24, progress_callback=None):
|
| 81 |
+
"""
|
| 82 |
+
Salva um tensor de pixel como um arquivo de vídeo .mp4 usando parâmetros otimizados.
|
| 83 |
+
Espera um tensor no formato (B, C, F, H, W) onde B=1.
|
| 84 |
+
"""
|
| 85 |
+
# Verificações de robustez para garantir que o tensor é válido
|
| 86 |
+
if video_tensor is None or video_tensor.ndim != 5 or video_tensor.shape[0] != 1 or video_tensor.shape[2] == 0:
|
| 87 |
+
logger.warning(f"Tensor de vídeo inválido ou vazio recebido. Shape: {video_tensor.shape if video_tensor is not None else 'None'}. Pulando salvamento de vídeo para '{path}'.")
|
| 88 |
+
return
|
| 89 |
+
|
| 90 |
+
logger.info(f"Salvando tensor de vídeo com shape {video_tensor.shape} para '{os.path.basename(path)}'...")
|
| 91 |
+
|
| 92 |
+
try:
|
| 93 |
+
# Squeeze: (1, C, F, H, W) -> (C, F, H, W)
|
| 94 |
+
# Permute: (C, F, H, W) -> (F, H, W, C) - formato esperado por imageio
|
| 95 |
+
video_tensor_permuted = video_tensor.squeeze(0).permute(1, 2, 3, 0)
|
| 96 |
+
|
| 97 |
+
# Desnormaliza de [-1, 1] para [0, 1]
|
| 98 |
+
video_tensor_normalized = (video_tensor_permuted.clamp(-1, 1) + 1) / 2.0
|
| 99 |
+
|
| 100 |
+
# Converte para [0, 255], move para CPU e converte para numpy uint8
|
| 101 |
+
video_np = (video_tensor_normalized.detach().cpu().float().numpy() * 255).astype(np.uint8)
|
| 102 |
+
|
| 103 |
+
# Salva o vídeo com parâmetros de alta compatibilidade
|
| 104 |
+
with imageio.get_writer(
|
| 105 |
+
path,
|
| 106 |
+
fps=fps,
|
| 107 |
+
codec='libx264',
|
| 108 |
+
quality=8, # Qualidade boa (0-10, onde 10 é a melhor)
|
| 109 |
+
output_params=['-pix_fmt', 'yuv420p'] # Formato de pixel para compatibilidade máxima
|
| 110 |
+
) as writer:
|
| 111 |
+
for frame in video_np:
|
| 112 |
+
writer.append_data(frame)
|
| 113 |
+
if progress_callback:
|
| 114 |
+
progress_callback(i + 1, T)
|
| 115 |
+
if i % self.frame_log_every == 0:
|
| 116 |
+
print(f"[DEBUG] [Encoder] frame {i}/{T} gravado ({H}x{W}@{fps}fps)")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
logger.info(f"Vídeo salvo com sucesso em: {path}")
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.error(f"Falha ao salvar vídeo com imageio para '{path}': {e}", exc_info=True)
|
| 122 |
+
raise VideoToolError(f"Não foi possível escrever o arquivo de vídeo: {e}")
|
| 123 |
+
|
| 124 |
+
def extract_first_frame(self, video_path: str, output_image_path: str) -> str:
|
| 125 |
+
"""
|
| 126 |
+
Extrai o primeiro frame de um arquivo de vídeo e o salva como uma imagem.
|
| 127 |
+
"""
|
| 128 |
+
logger.info(f"Extraindo primeiro frame de '{os.path.basename(video_path)}'...")
|
| 129 |
+
cmd = ['ffmpeg', '-y', '-v', 'error', '-i', video_path, '-vframes', '1', '-q:v', '2', output_image_path]
|
| 130 |
+
try:
|
| 131 |
+
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
| 132 |
+
return output_image_path
|
| 133 |
+
except subprocess.CalledProcessError as e:
|
| 134 |
+
logger.error(f"FFmpeg (extract_first_frame) falhou: {e.stderr}")
|
| 135 |
+
raise VideoToolError(f"Falha ao extrair o primeiro frame de {video_path}")
|
| 136 |
+
|
| 137 |
+
def extract_last_frame(self, video_path: str, output_image_path: str) -> str:
|
| 138 |
+
"""
|
| 139 |
+
Extrai o último frame de um arquivo de vídeo e o salva como uma imagem.
|
| 140 |
+
"""
|
| 141 |
+
logger.info(f"Extraindo último frame de '{os.path.basename(video_path)}'...")
|
| 142 |
+
cmd = ['ffmpeg', '-y', '-v', 'error', '-sseof', '-0.1', '-i', video_path, '-vframes', '1', '-q:v', '2', output_image_path]
|
| 143 |
+
try:
|
| 144 |
+
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
| 145 |
+
return output_image_path
|
| 146 |
+
except subprocess.CalledProcessError as e:
|
| 147 |
+
logger.error(f"FFmpeg (extract_last_frame) falhou: {e.stderr}")
|
| 148 |
+
raise VideoToolError(f"Falha ao extrair o último frame de {video_path}")
|
| 149 |
+
|
| 150 |
+
def create_transition_bridge(self, start_image_path: str, end_image_path: str,
|
| 151 |
+
duration: float, fps: int, target_resolution: Tuple[int, int],
|
| 152 |
+
workspace_dir: str, effect: Optional[str] = None) -> str:
|
| 153 |
+
"""
|
| 154 |
+
Cria um clipe de vídeo curto que transiciona entre duas imagens estáticas.
|
| 155 |
+
"""
|
| 156 |
+
output_path = os.path.join(workspace_dir, f"bridge_{int(time.time())}_{random.randint(100, 999)}.mp4")
|
| 157 |
+
width, height = target_resolution
|
| 158 |
+
fade_effects = ["fade", "wipeleft", "wiperight", "wipeup", "wipedown", "dissolve", "fadeblack", "fadewhite", "radial", "rectcrop", "circleopen", "circleclose", "horzopen", "horzclose"]
|
| 159 |
+
selected_effect = effect if effect and effect.strip() else random.choice(fade_effects)
|
| 160 |
+
transition_duration = max(0.1, duration)
|
| 161 |
+
cmd = (f"ffmpeg -y -v error -loop 1 -t {transition_duration} -i \"{start_image_path}\" -loop 1 -t {transition_duration} -i \"{end_image_path}\" "
|
| 162 |
+
f"-filter_complex \"[0:v]scale={width}:{height},setsar=1[v0];[1:v]scale={width}:{height},setsar=1[v1];"
|
| 163 |
+
f"[v0][v1]xfade=transition={selected_effect}:duration={transition_duration}:offset=0[out]\" "
|
| 164 |
+
f"-map \"[out]\" -c:v libx264 -r {fps} -pix_fmt yuv420p \"{output_path}\"")
|
| 165 |
+
logger.info(f"Criando ponte de transição com efeito '{selected_effect}'...")
|
| 166 |
+
try:
|
| 167 |
+
subprocess.run(cmd, shell=True, check=True, text=True)
|
| 168 |
+
except subprocess.CalledProcessError as e:
|
| 169 |
+
raise VideoToolError(f"Falha ao criar vídeo de transição: {e.stderr}")
|
| 170 |
+
return output_path
|
| 171 |
+
|
| 172 |
+
def concatenate_videos(self, video_paths: List[str], output_path: str, workspace_dir: str) -> str:
|
| 173 |
+
"""
|
| 174 |
+
Concatena múltiplos clipes de vídeo em um único arquivo sem re-codificar.
|
| 175 |
+
"""
|
| 176 |
+
if not video_paths:
|
| 177 |
+
raise VideoToolError("Nenhum fragmento de vídeo fornecido para concatenação.")
|
| 178 |
+
# Se houver apenas um clipe, apenas o copie para o destino final.
|
| 179 |
+
if len(video_paths) == 1:
|
| 180 |
+
shutil.copy(video_paths[0], output_path)
|
| 181 |
+
logger.info(f"Apenas um clipe fornecido. Copiado para '{output_path}'.")
|
| 182 |
+
return output_path
|
| 183 |
+
|
| 184 |
+
list_file_path = os.path.join(workspace_dir, f"concat_list_{int(time.time())}.txt")
|
| 185 |
+
try:
|
| 186 |
+
with open(list_file_path, 'w', encoding='utf-8') as f:
|
| 187 |
+
for path in video_paths:
|
| 188 |
+
# Garante que o caminho seja absoluto para o ffmpeg encontrar
|
| 189 |
+
f.write(f"file '{os.path.abspath(path)}'\n")
|
| 190 |
+
|
| 191 |
+
cmd_list = ['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', list_file_path, '-c', 'copy', output_path]
|
| 192 |
+
logger.info(f"Concatenando {len(video_paths)} clipes para '{os.path.basename(output_path)}'...")
|
| 193 |
+
subprocess.run(cmd_list, check=True, capture_output=True, text=True)
|
| 194 |
+
logger.info("Concatenação FFmpeg bem-sucedida.")
|
| 195 |
+
return output_path
|
| 196 |
+
except subprocess.CalledProcessError as e:
|
| 197 |
+
logger.error(f"Falha ao montar o vídeo final com FFmpeg: {e.stderr}")
|
| 198 |
+
raise VideoToolError(f"Falha ao montar o vídeo final com FFmpeg.")
|
| 199 |
+
finally:
|
| 200 |
+
if os.path.exists(list_file_path):
|
| 201 |
+
os.remove(list_file_path)
|
| 202 |
+
|
| 203 |
+
# --- Instância Singleton ---
|
| 204 |
+
video_encode_tool_singleton = VideoEncodeTool()
|