Spaces:
Sleeping
Sleeping
File size: 13,859 Bytes
dbebf4e 708fe13 dbebf4e 708fe13 dbebf4e 708fe13 dbebf4e d34ee05 05961ac dbebf4e d34ee05 dbebf4e 8decb0b d34ee05 dbebf4e 7b10755 6b2cd53 d13a611 dbebf4e 73228b4 dbebf4e c1ce906 f8ef0a4 708fe13 f8ef0a4 c1ce906 d34ee05 708fe13 9516e0a dbebf4e 0da041d dbebf4e d34ee05 c1ce906 dbebf4e 3c45773 dbebf4e 708fe13 0da041d dbebf4e 8decb0b dbebf4e 0da041d cc3baf4 3c45773 6ae0803 708fe13 dbebf4e 8decb0b 7b957f8 94f37f7 708fe13 94f37f7 708fe13 ce2e6d9 3f1b495 708fe13 3f1b495 708fe13 3f1b495 708fe13 3f1b495 708fe13 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 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 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 | # -*- coding: utf-8 -*-
"""
FFmpeg API Service for n8n Video Automation.
This FastAPI application provides a single, highly-optimized endpoint
to orchestrate a full video production pipeline from raw assets.
Version: 9.0.0 (Ultimate - All-In-One Endpoint)
"""
# --- Core Imports ---
import asyncio
import json
import os
import shutil
import shlex
import uuid
from typing import Dict, List
# --- Library Imports ---
import aiohttp
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from fastapi import Request
from starlette.background import BackgroundTask
# ==============================================================================
# 1. APPLICATION CONFIGURATION & SETUP
# ==============================================================================
app = FastAPI(
title="Vid Automator API",
description="A single, powerful endpoint to create complete videos from raw assets.",
version="9.0.0",
)
TEMP_DIR = "/tmp/vid_automator"
BROWSER_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
# ==============================================================================
# 2. LIFECYCLE & HELPER FUNCTIONS
# ==============================================================================
@app.on_event("startup")
async def startup_event():
os.makedirs(TEMP_DIR, exist_ok=True)
def cleanup_directory(directory_path: str):
if os.path.exists(directory_path):
shutil.rmtree(directory_path, ignore_errors=True)
print(f"--- CLEANUP: Successfully removed {directory_path} ---")
async def run_subprocess(command: str) -> (str, str):
print(f"--- EXECUTING COMMAND ---\n{command}\n-------------------------")
process = await asyncio.create_subprocess_shell(
command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error_message = stderr.decode(errors='ignore').strip()
print(f"ERROR: Command failed.\nStderr: {error_message}")
raise RuntimeError(f"FFmpeg command failed: {error_message}")
print("SUCCESS: Command executed.")
return stdout.decode(errors='ignore'), stderr.decode(errors='ignore')
async def download_asset(session: aiohttp.ClientSession, url: str, path: str, asset_type: str):
print(f"Downloading {asset_type} from {url}...")
try:
async with session.get(url, timeout=120) as resp: # Aumentado timeout para downloads maiores
resp.raise_for_status()
with open(path, "wb") as f:
f.write(await resp.read())
print(f"SUCCESS: Downloaded {asset_type}.")
except Exception as e:
print(f"ERROR downloading {asset_type} from {url}: {e}")
raise
# ==============================================================================
# ENDPOINT OTIMIZADO: RECEBE UMA URL DE ÁUDIO E RETORNA A DURAÇÃO
# ==============================================================================
@app.post("/get-duration-from-url/")
async def get_duration_from_url(payload: Dict):
"""
Accepts a JSON payload with an 'audio_url', downloads the audio file,
calculates its duration using FFprobe, and returns the duration in
rounded seconds.
This endpoint is designed to be highly efficient for n8n workflows,
avoiding the need for the client to handle binary file uploads.
Args:
payload (Dict): A JSON object containing the key "audio_url".
Example: {"audio_url": "http://example.com/audio.mp3"}
Returns:
JSONResponse: A JSON object with the duration in rounded seconds.
Example: {"duration_seconds": 150}
"""
# ETAPA 1: Validação do Payload de Entrada
audio_url = payload.get("audio_url")
if not audio_url:
raise HTTPException(
status_code=422, # 422 Unprocessable Entity é mais semântico aqui
detail="Payload must contain a non-empty 'audio_url' key."
)
# ETAPA 2: Setup do Ambiente Temporário
unique_id = str(uuid.uuid4())
temp_audio_path = os.path.join(TEMP_DIR, f"{unique_id}_audio_to_probe.mp3")
try:
# ETAPA 3: Download Assíncrono do Arquivo de Áudio
async with aiohttp.ClientSession(headers=BROWSER_HEADERS) as session:
await download_asset(session, audio_url, temp_audio_path, "audio for probing")
# ETAPA 4: Executar o FFprobe no Arquivo Baixado
# O comando é o mesmo, apenas o caminho do arquivo mudou
quoted_path = shlex.quote(temp_audio_path)
command = f"ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {quoted_path}"
stdout, _ = await run_subprocess(command)
duration_str = stdout.strip()
if not duration_str:
raise ValueError("FFprobe did not return a valid duration.")
duration_float = float(duration_str)
# Arredonda o valor para o número inteiro mais próximo
duration_rounded = round(duration_float)
# ETAPA 5: Retornar a Resposta JSON
return JSONResponse(
content={"duration_seconds": duration_rounded}
)
except (ValueError, IOError) as e:
# Captura erros de download ou de parsing do FFprobe
raise HTTPException(status_code=400, detail=f"Error processing audio URL: {e}")
except Exception as e:
# Captura todos os outros erros, incluindo falhas no subprocesso
raise HTTPException(status_code=500, detail=f"An internal server error occurred: {e}")
finally:
# ETAPA 6: Garantir a Limpeza do Arquivo Temporário
# Este bloco é executado sempre, mesmo se ocorrerem erros
if os.path.exists(temp_audio_path):
os.remove(temp_audio_path)
print(f"--- CLEANUP: Removed temporary probe file {temp_audio_path} ---")
# ==============================================================================
# 3. SUPER ENDPOINT "TUDO-EM-UM"
# ==============================================================================
@app.post("/generate-complete-video/")
async def generate_complete_video_robust(request: Request):
"""
Orchestrates the entire video production pipeline by dynamically processing
a multipart/form-data request. This is the most robust method for handling
complex uploads from clients like n8n.
"""
unique_id = str(uuid.uuid4())
temp_processing_dir = os.path.join(TEMP_DIR, unique_id)
os.makedirs(temp_processing_dir)
try:
# ETAPA 1: Processar o formulário multipart manualmente
print("--- STEP 1/8: PROCESSING MULTIPART FORM DATA ---")
form_data = await request.form()
# Extrair campos de texto
image_urls_json = form_data.get("image_urls_json")
narration_url = form_data.get("narration_url")
music_volume = float(form_data.get("music_volume", 0.2))
output_filename = form_data.get("output_filename", "final_video.mp4")
# Extrair arquivos binários
subtitle_file = form_data.get("subtitle_file")
# Coleta todos os arquivos cujo nome de campo começa com "music_files"
music_files = [file for key, file in form_data.items() if key.startswith("music_files")]
# Validação robusta
if not all([image_urls_json, narration_url, subtitle_file, music_files]):
raise HTTPException(status_code=422, detail="Missing one or more required fields: image_urls_json, narration_url, subtitle_file, music_files.")
# ETAPA 2: Salvar e baixar todos os ativos
print("--- STEP 2/8: ACQUIRING ALL ASSETS ---")
narration_path = os.path.join(temp_processing_dir, "narration.mp3")
subtitle_path = os.path.join(temp_processing_dir, "subtitle.ass")
with open(subtitle_path, "wb") as buffer:
shutil.copyfileobj(subtitle_file.file, buffer)
music_paths = []
for i, music_file in enumerate(music_files):
path = os.path.join(temp_processing_dir, f"music_{i}.mp3")
with open(path, "wb") as buffer:
shutil.copyfileobj(music_file.file, buffer)
music_paths.append(path)
async with aiohttp.ClientSession(headers=BROWSER_HEADERS) as session:
await download_asset(session, narration_url, narration_path, "narration audio")
image_urls = json.loads(image_urls_json)
local_image_paths = []
for i, url in enumerate(image_urls):
local_path = os.path.join(temp_processing_dir, f"image_{i:02d}.jpg")
await download_asset(session, url, local_path, f"image {i+1}")
local_image_paths.append(local_path)
print("SUCCESS: All assets acquired.")
# O restante do pipeline (ETAPAS 3 a 8) permanece o mesmo, pois já opera em arquivos locais.
# ... (código das etapas 3 a 8 sem alterações)
# ETAPA 3: Unir músicas de fundo
print("--- STEP 3/8: MERGING BACKGROUND MUSIC ---")
merged_music_path = os.path.join(temp_processing_dir, "bg_music_merged.mp3")
if len(music_paths) > 1:
input_args_music = "".join([f"-i {shlex.quote(p)} " for p in music_paths])
filter_inputs = "".join([f"[{i}:a:0]" for i in range(len(music_paths))])
concat_filter = f"\"{filter_inputs}concat=n={len(music_paths)}:v=0:a=1[outa]\""
cmd_merge_music = f"ffmpeg {input_args_music} -filter_complex {concat_filter} -map \"[outa]\" -y {shlex.quote(merged_music_path)}"
await run_subprocess(cmd_merge_music)
else:
shutil.copy(music_paths[0], merged_music_path)
print("SUCCESS: Background music prepared.")
# ETAPA 4: Mixar narração e música
print("--- STEP 4/8: MIXING FINAL AUDIO ---")
final_audio_path = os.path.join(temp_processing_dir, "final_audio.mp3")
cmd_mix = (f"ffmpeg -i {shlex.quote(narration_path)} -i {shlex.quote(merged_music_path)} "
f"-filter_complex \"[1:a]volume={music_volume}[bg];[0:a][bg]amix=inputs=2:duration=first\" "
f"-y {shlex.quote(final_audio_path)}")
await run_subprocess(cmd_mix)
print("SUCCESS: Final audio track mixed.")
# ETAPA 5: Obter duração da narração
print("--- STEP 5/8: GETTING NARRATION DURATION ---")
cmd_ffprobe = f"ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {shlex.quote(narration_path)}"
stdout, _ = await run_subprocess(cmd_ffprobe)
total_duration = float(stdout.strip())
print(f"SUCCESS: Narration duration is {total_duration} seconds.")
# ETAPA 6: Criar vídeo com transições
print("--- STEP 6/8: CREATING VIDEO WITH TRANSITIONS ---")
transition_duration, num_images = 1.0, len(local_image_paths)
display_duration = (total_duration - transition_duration) / (num_images - 1)
input_args = "".join([f"-loop 1 -t {display_duration + transition_duration} -i {shlex.quote(p)} " for p in local_image_paths])
filter_complex_chain, last_stream = "", "0:v"
for i in range(num_images - 1):
cumulative_offset = (i + 1) * display_duration
filter_part = f"[{last_stream}][{i + 1}:v]xfade=transition=fade:duration={transition_duration}:offset={cumulative_offset}[v{i + 1}];"
filter_complex_chain += filter_part
last_stream = f"v{i + 1}"
video_with_transitions_path = os.path.join(temp_processing_dir, "video_with_transitions.mp4")
final_filter_chain = f"{filter_complex_chain}[{last_stream}]format=yuv420p,fps=25[final_v]"
cmd_video_transitions = f"ffmpeg {input_args} -filter_complex \"{final_filter_chain}\" -map \"[final_v]\" -an -y {shlex.quote(video_with_transitions_path)}"
await run_subprocess(cmd_video_transitions)
print("SUCCESS: Video with transitions created.")
# ETAPA 7: Unir vídeo ao áudio final
print("--- STEP 7/8: COMBINING VIDEO AND FINAL AUDIO ---")
video_with_audio_path = os.path.join(temp_processing_dir, "video_with_audio.mp4")
cmd_combine = (f"ffmpeg -i {shlex.quote(video_with_transitions_path)} -i {shlex.quote(final_audio_path)} -c:v copy -c:a aac -shortest -y {shlex.quote(video_with_audio_path)}")
await run_subprocess(cmd_combine)
print("SUCCESS: Video and final audio combined.")
# ETAPA 8: Queimar legendas
print("--- STEP 8/8: BURNING SUBTITLES ---")
final_video_path = os.path.join(temp_processing_dir, output_filename)
escaped_subtitle_path = subtitle_path.replace("'", "'\\''")
cmd_subtitles = (f"ffmpeg -i {shlex.quote(video_with_audio_path)} -vf \"ass='{escaped_subtitle_path}'\" -c:v libx264 -preset ultrafast -c:a copy -y {shlex.quote(final_video_path)}")
await run_subprocess(cmd_subtitles)
print("--- FINAL VIDEO CREATED SUCCESSFULLY ---")
custom_headers = {"X-Video-Duration-Seconds": str(round(total_duration, 2))}
cleanup_task = BackgroundTask(cleanup_directory, directory_path=temp_processing_dir)
return FileResponse(
path=final_video_path, filename=output_filename, media_type="video/mp4",
background=cleanup_task, headers=custom_headers
)
except Exception as e:
cleanup_directory(directory_path=temp_processing_dir)
raise HTTPException(status_code=500, detail=f"An internal server error occurred: {str(e)}")
# (Você pode remover os endpoints antigos como /merge-audio-files/, /mix-audio-tracks/, etc.) |