Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -5,7 +5,7 @@ FFmpeg API Service for n8n Video Automation.
|
|
| 5 |
This FastAPI application provides a highly optimized, single endpoint
|
| 6 |
to perform all FFmpeg/FFprobe operations for video creation.
|
| 7 |
|
| 8 |
-
Version: 6.2.
|
| 9 |
"""
|
| 10 |
# --- Core Imports ---
|
| 11 |
import asyncio
|
|
@@ -14,12 +14,12 @@ import os
|
|
| 14 |
import shutil
|
| 15 |
import shlex
|
| 16 |
import uuid
|
| 17 |
-
from typing import
|
| 18 |
|
| 19 |
# --- Library Imports ---
|
| 20 |
import aiohttp
|
| 21 |
-
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
|
| 22 |
-
from fastapi.responses import FileResponse
|
| 23 |
from starlette.background import BackgroundTask
|
| 24 |
|
| 25 |
# ==============================================================================
|
|
@@ -28,7 +28,7 @@ from starlette.background import BackgroundTask
|
|
| 28 |
app = FastAPI(
|
| 29 |
title="FFmpeg as a Service for n8n",
|
| 30 |
description="A robust API to create full videos with crossfade transitions.",
|
| 31 |
-
version="6.2.
|
| 32 |
)
|
| 33 |
|
| 34 |
TEMP_DIR = "/tmp/ffmpeg_processing"
|
|
@@ -47,7 +47,6 @@ async def startup_event():
|
|
| 47 |
def cleanup_directory(directory_path: str):
|
| 48 |
"""
|
| 49 |
Safely removes a directory and its contents after processing.
|
| 50 |
-
This is critical for preventing disk space exhaustion.
|
| 51 |
"""
|
| 52 |
if os.path.exists(directory_path):
|
| 53 |
shutil.rmtree(directory_path, ignore_errors=True)
|
|
@@ -56,7 +55,7 @@ def cleanup_directory(directory_path: str):
|
|
| 56 |
async def run_subprocess(command: str) -> (str, str):
|
| 57 |
"""
|
| 58 |
Executes a shell command asynchronously, capturing its output.
|
| 59 |
-
Raises RuntimeError on failure
|
| 60 |
"""
|
| 61 |
print(f"--- EXECUTING COMMAND ---\n{command}\n-------------------------")
|
| 62 |
process = await asyncio.create_subprocess_shell(
|
|
@@ -68,19 +67,21 @@ async def run_subprocess(command: str) -> (str, str):
|
|
| 68 |
|
| 69 |
if process.returncode != 0:
|
| 70 |
error_message = stderr.decode(errors='ignore').strip()
|
| 71 |
-
print(f"ERROR: Command failed
|
| 72 |
raise RuntimeError(f"FFmpeg command failed: {error_message}")
|
| 73 |
|
| 74 |
print("SUCCESS: Command executed.")
|
| 75 |
return stdout.decode(errors='ignore'), stderr.decode(errors='ignore')
|
| 76 |
|
| 77 |
-
#
|
| 78 |
-
async def download_asset(session: aiohttp.ClientSession, url: str, path: str):
|
| 79 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 80 |
async with session.get(url) as resp:
|
| 81 |
if not resp.ok:
|
| 82 |
-
|
| 83 |
-
raise IOError(f"Failed to download asset from {url}, status: {resp.status}")
|
| 84 |
with open(path, "wb") as f:
|
| 85 |
f.write(await resp.read())
|
| 86 |
|
|
@@ -95,9 +96,6 @@ async def create_full_video_with_subtitles_v2(
|
|
| 95 |
subtitle_file: UploadFile = File(...),
|
| 96 |
output_filename: str = Form("final_video.mp4")
|
| 97 |
):
|
| 98 |
-
"""
|
| 99 |
-
Orchestrates the entire video creation process with professional crossfade transitions.
|
| 100 |
-
"""
|
| 101 |
unique_id = str(uuid.uuid4())
|
| 102 |
temp_processing_dir = os.path.join(TEMP_DIR, unique_id)
|
| 103 |
os.makedirs(temp_processing_dir)
|
|
@@ -110,21 +108,18 @@ async def create_full_video_with_subtitles_v2(
|
|
| 110 |
print("--- STEP 1/6: ACQUIRING ASSETS ---")
|
| 111 |
with open(subtitle_path, "wb") as buffer:
|
| 112 |
shutil.copyfileobj(subtitle_file.file, buffer)
|
|
|
|
| 113 |
|
| 114 |
image_urls = json.loads(image_urls_json)
|
| 115 |
local_image_paths = []
|
| 116 |
|
| 117 |
async with aiohttp.ClientSession(headers=BROWSER_HEADERS) as session:
|
| 118 |
-
|
| 119 |
-
await download_asset(session, audio_url, audio_path)
|
| 120 |
-
print("SUCCESS: Audio downloaded.")
|
| 121 |
-
|
| 122 |
-
# Baixa imagens
|
| 123 |
for i, url in enumerate(image_urls):
|
| 124 |
local_path = os.path.join(temp_processing_dir, f"image_{i:02d}.jpg")
|
| 125 |
await download_asset(session, url, local_path)
|
| 126 |
local_image_paths.append(local_path)
|
| 127 |
-
|
| 128 |
|
| 129 |
# ETAPA 2: Obter Duração do Áudio
|
| 130 |
print("--- STEP 2/6: GETTING AUDIO DURATION ---")
|
|
@@ -145,33 +140,43 @@ async def create_full_video_with_subtitles_v2(
|
|
| 145 |
|
| 146 |
input_args = ""
|
| 147 |
for path in local_image_paths:
|
|
|
|
| 148 |
input_args += f"-loop 1 -t {image_duration} -i {shlex.quote(path)} "
|
| 149 |
|
| 150 |
filter_complex_chain = ""
|
| 151 |
last_stream = "0:v"
|
| 152 |
-
|
| 153 |
-
|
|
|
|
| 154 |
for i in range(num_images - 1):
|
| 155 |
next_stream_idx = i + 1
|
| 156 |
output_stream_name = f"v{next_stream_idx}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
filter_part = f"[{last_stream}][{next_stream_idx}:v]xfade=transition=fade:duration={transition_duration}:offset={offset}[{output_stream_name}];"
|
| 158 |
filter_complex_chain += filter_part
|
| 159 |
last_stream = output_stream_name
|
| 160 |
|
| 161 |
silent_video_path = os.path.join(temp_processing_dir, "silent_video_with_transitions.mp4")
|
| 162 |
|
| 163 |
-
#
|
| 164 |
final_filter_chain = f"{filter_complex_chain}[{last_stream}]format=yuv420p,fps=25[final_v]"
|
| 165 |
|
| 166 |
cmd_video_transitions = (
|
| 167 |
f"ffmpeg {input_args} "
|
| 168 |
f"-filter_complex \"{final_filter_chain}\" "
|
| 169 |
-
f"-map \"[final_v]\" -y {shlex.quote(silent_video_path)}"
|
| 170 |
)
|
| 171 |
|
| 172 |
await run_subprocess(cmd_video_transitions)
|
| 173 |
print("SUCCESS: Video with transitions created.")
|
| 174 |
|
|
|
|
| 175 |
# ETAPA 4: Combinar com áudio
|
| 176 |
print("--- STEP 4/6: COMBINING WITH AUDIO ---")
|
| 177 |
video_with_audio_path = os.path.join(temp_processing_dir, "video_with_audio.mp4")
|
|
@@ -193,7 +198,6 @@ async def create_full_video_with_subtitles_v2(
|
|
| 193 |
await run_subprocess(cmd_subtitles)
|
| 194 |
print("--- STEP 6/6: FINAL VIDEO CREATED SUCCESSFULLY ---")
|
| 195 |
|
| 196 |
-
# ETAPA FINAL: Retorno e Limpeza
|
| 197 |
cleanup_task = BackgroundTask(cleanup_directory, directory_path=temp_processing_dir)
|
| 198 |
return FileResponse(
|
| 199 |
path=final_output_path,
|
|
|
|
| 5 |
This FastAPI application provides a highly optimized, single endpoint
|
| 6 |
to perform all FFmpeg/FFprobe operations for video creation.
|
| 7 |
|
| 8 |
+
Version: 6.2.1 (Final - Including all helper functions)
|
| 9 |
"""
|
| 10 |
# --- Core Imports ---
|
| 11 |
import asyncio
|
|
|
|
| 14 |
import shutil
|
| 15 |
import shlex
|
| 16 |
import uuid
|
| 17 |
+
from typing import Dict
|
| 18 |
|
| 19 |
# --- Library Imports ---
|
| 20 |
import aiohttp
|
| 21 |
+
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
|
| 22 |
+
from fastapi.responses import FileResponse
|
| 23 |
from starlette.background import BackgroundTask
|
| 24 |
|
| 25 |
# ==============================================================================
|
|
|
|
| 28 |
app = FastAPI(
|
| 29 |
title="FFmpeg as a Service for n8n",
|
| 30 |
description="A robust API to create full videos with crossfade transitions.",
|
| 31 |
+
version="6.2.1",
|
| 32 |
)
|
| 33 |
|
| 34 |
TEMP_DIR = "/tmp/ffmpeg_processing"
|
|
|
|
| 47 |
def cleanup_directory(directory_path: str):
|
| 48 |
"""
|
| 49 |
Safely removes a directory and its contents after processing.
|
|
|
|
| 50 |
"""
|
| 51 |
if os.path.exists(directory_path):
|
| 52 |
shutil.rmtree(directory_path, ignore_errors=True)
|
|
|
|
| 55 |
async def run_subprocess(command: str) -> (str, str):
|
| 56 |
"""
|
| 57 |
Executes a shell command asynchronously, capturing its output.
|
| 58 |
+
Raises RuntimeError on failure.
|
| 59 |
"""
|
| 60 |
print(f"--- EXECUTING COMMAND ---\n{command}\n-------------------------")
|
| 61 |
process = await asyncio.create_subprocess_shell(
|
|
|
|
| 67 |
|
| 68 |
if process.returncode != 0:
|
| 69 |
error_message = stderr.decode(errors='ignore').strip()
|
| 70 |
+
print(f"ERROR: Command failed.\nStderr: {error_message}")
|
| 71 |
raise RuntimeError(f"FFmpeg command failed: {error_message}")
|
| 72 |
|
| 73 |
print("SUCCESS: Command executed.")
|
| 74 |
return stdout.decode(errors='ignore'), stderr.decode(errors='ignore')
|
| 75 |
|
| 76 |
+
# >>>>> FUNÇÃO AUXILIAR QUE ESTAVA FALTANDO <<<<<
|
| 77 |
+
async def download_asset(session: aiohttp.ClientSession, url: str, path: str, asset_type: str = "image"):
|
| 78 |
+
"""
|
| 79 |
+
Downloads a single asset from a URL to a local path.
|
| 80 |
+
"""
|
| 81 |
+
print(f"Downloading {asset_type} from {url}...")
|
| 82 |
async with session.get(url) as resp:
|
| 83 |
if not resp.ok:
|
| 84 |
+
raise IOError(f"Failed to download {asset_type} from {url}, status: {resp.status}")
|
|
|
|
| 85 |
with open(path, "wb") as f:
|
| 86 |
f.write(await resp.read())
|
| 87 |
|
|
|
|
| 96 |
subtitle_file: UploadFile = File(...),
|
| 97 |
output_filename: str = Form("final_video.mp4")
|
| 98 |
):
|
|
|
|
|
|
|
|
|
|
| 99 |
unique_id = str(uuid.uuid4())
|
| 100 |
temp_processing_dir = os.path.join(TEMP_DIR, unique_id)
|
| 101 |
os.makedirs(temp_processing_dir)
|
|
|
|
| 108 |
print("--- STEP 1/6: ACQUIRING ASSETS ---")
|
| 109 |
with open(subtitle_path, "wb") as buffer:
|
| 110 |
shutil.copyfileobj(subtitle_file.file, buffer)
|
| 111 |
+
print("SUCCESS: Subtitle file saved.")
|
| 112 |
|
| 113 |
image_urls = json.loads(image_urls_json)
|
| 114 |
local_image_paths = []
|
| 115 |
|
| 116 |
async with aiohttp.ClientSession(headers=BROWSER_HEADERS) as session:
|
| 117 |
+
await download_asset(session, audio_url, audio_path, "audio")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
for i, url in enumerate(image_urls):
|
| 119 |
local_path = os.path.join(temp_processing_dir, f"image_{i:02d}.jpg")
|
| 120 |
await download_asset(session, url, local_path)
|
| 121 |
local_image_paths.append(local_path)
|
| 122 |
+
print(f"SUCCESS: All assets acquired.")
|
| 123 |
|
| 124 |
# ETAPA 2: Obter Duração do Áudio
|
| 125 |
print("--- STEP 2/6: GETTING AUDIO DURATION ---")
|
|
|
|
| 140 |
|
| 141 |
input_args = ""
|
| 142 |
for path in local_image_paths:
|
| 143 |
+
# -t agora define a duração de cada input de imagem, que é a duração por imagem
|
| 144 |
input_args += f"-loop 1 -t {image_duration} -i {shlex.quote(path)} "
|
| 145 |
|
| 146 |
filter_complex_chain = ""
|
| 147 |
last_stream = "0:v"
|
| 148 |
+
|
| 149 |
+
# >>>>> CORREÇÃO PRINCIPAL AQUI <<<<<
|
| 150 |
+
# O offset agora é CUMULATIVO.
|
| 151 |
for i in range(num_images - 1):
|
| 152 |
next_stream_idx = i + 1
|
| 153 |
output_stream_name = f"v{next_stream_idx}"
|
| 154 |
+
|
| 155 |
+
# O offset aumenta a cada iteração
|
| 156 |
+
# Offset da transição 1: (1 * image_duration) - transition_duration
|
| 157 |
+
# Offset da transição 2: (2 * image_duration) - (2 * transition_duration) ... etc
|
| 158 |
+
# Uma forma mais simples é calcular o offset com base no tempo de início da imagem
|
| 159 |
+
offset = i * (image_duration - transition_duration) + (image_duration - transition_duration)
|
| 160 |
+
|
| 161 |
filter_part = f"[{last_stream}][{next_stream_idx}:v]xfade=transition=fade:duration={transition_duration}:offset={offset}[{output_stream_name}];"
|
| 162 |
filter_complex_chain += filter_part
|
| 163 |
last_stream = output_stream_name
|
| 164 |
|
| 165 |
silent_video_path = os.path.join(temp_processing_dir, "silent_video_with_transitions.mp4")
|
| 166 |
|
| 167 |
+
# A sintaxe do filtro final permanece a mesma, agora alimentada pela cadeia correta
|
| 168 |
final_filter_chain = f"{filter_complex_chain}[{last_stream}]format=yuv420p,fps=25[final_v]"
|
| 169 |
|
| 170 |
cmd_video_transitions = (
|
| 171 |
f"ffmpeg {input_args} "
|
| 172 |
f"-filter_complex \"{final_filter_chain}\" "
|
| 173 |
+
f"-map \"[final_v]\" -an -y {shlex.quote(silent_video_path)}" # -an para garantir que não há áudio
|
| 174 |
)
|
| 175 |
|
| 176 |
await run_subprocess(cmd_video_transitions)
|
| 177 |
print("SUCCESS: Video with transitions created.")
|
| 178 |
|
| 179 |
+
# O resto do fluxo continua como antes...
|
| 180 |
# ETAPA 4: Combinar com áudio
|
| 181 |
print("--- STEP 4/6: COMBINING WITH AUDIO ---")
|
| 182 |
video_with_audio_path = os.path.join(temp_processing_dir, "video_with_audio.mp4")
|
|
|
|
| 198 |
await run_subprocess(cmd_subtitles)
|
| 199 |
print("--- STEP 6/6: FINAL VIDEO CREATED SUCCESSFULLY ---")
|
| 200 |
|
|
|
|
| 201 |
cleanup_task = BackgroundTask(cleanup_directory, directory_path=temp_processing_dir)
|
| 202 |
return FileResponse(
|
| 203 |
path=final_output_path,
|