Spaces:
Sleeping
Sleeping
Update app.py
Browse filesNova tentativa de unificar tudo
app.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
| 1 |
# -*- coding: utf-8 -*-
|
|
|
|
| 2 |
"""
|
| 3 |
-
FFmpeg API Service for n8n Video Automation
|
| 4 |
|
| 5 |
-
This FastAPI application provides a single,
|
| 6 |
-
|
|
|
|
| 7 |
|
| 8 |
-
Version: 9.0.0 (
|
| 9 |
"""
|
| 10 |
# --- Core Imports ---
|
| 11 |
import asyncio
|
|
@@ -27,9 +29,9 @@ from starlette.background import BackgroundTask
|
|
| 27 |
# 1. APPLICATION CONFIGURATION & SETUP
|
| 28 |
# ==============================================================================
|
| 29 |
app = FastAPI(
|
| 30 |
-
title="
|
| 31 |
description="A single endpoint to create full videos with music, transitions, and subtitles.",
|
| 32 |
-
version="
|
| 33 |
)
|
| 34 |
|
| 35 |
TEMP_DIR = "/tmp/ffmpeg_processing"
|
|
@@ -49,28 +51,30 @@ def cleanup_directory(directory_path: str):
|
|
| 49 |
shutil.rmtree(directory_path, ignore_errors=True)
|
| 50 |
print(f"--- CLEANUP: Successfully removed {directory_path} ---")
|
| 51 |
|
| 52 |
-
async def run_subprocess(command: str) ->
|
| 53 |
print(f"--- EXECUTING COMMAND ---\n{command}\n-------------------------")
|
| 54 |
process = await asyncio.create_subprocess_shell(
|
| 55 |
command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
| 56 |
)
|
| 57 |
stdout, stderr = await process.communicate()
|
|
|
|
| 58 |
if process.returncode != 0:
|
| 59 |
error_message = stderr.decode(errors='ignore').strip()
|
| 60 |
print(f"ERROR: Command failed.\nStderr: {error_message}")
|
| 61 |
raise RuntimeError(f"FFmpeg command failed: {error_message}")
|
|
|
|
| 62 |
print("SUCCESS: Command executed.")
|
| 63 |
-
return stdout.decode(errors='ignore')
|
| 64 |
|
| 65 |
async def download_asset(session: aiohttp.ClientSession, url: str, path: str, asset_type: str):
|
| 66 |
print(f"Downloading {asset_type} from {url}...")
|
| 67 |
async with session.get(url) as resp:
|
| 68 |
-
resp.raise_for_status()
|
| 69 |
with open(path, "wb") as f:
|
| 70 |
f.write(await resp.read())
|
| 71 |
|
| 72 |
# ==============================================================================
|
| 73 |
-
# 3. THE ALL-IN-ONE ENDPOINT
|
| 74 |
# ==============================================================================
|
| 75 |
|
| 76 |
@app.post("/generate-complete-video/")
|
|
@@ -91,7 +95,6 @@ async def generate_complete_video(
|
|
| 91 |
narration_path = os.path.join(temp_dir, "narration.mp3")
|
| 92 |
subtitle_path = os.path.join(temp_dir, "subtitle.ass")
|
| 93 |
|
| 94 |
-
# Salva legenda e músicas
|
| 95 |
with open(subtitle_path, "wb") as f: shutil.copyfileobj(subtitle_file.file, f)
|
| 96 |
|
| 97 |
local_music_paths = []
|
|
@@ -100,7 +103,6 @@ async def generate_complete_video(
|
|
| 100 |
with open(path, "wb") as f: shutil.copyfileobj(music_file.file, f)
|
| 101 |
local_music_paths.append(path)
|
| 102 |
|
| 103 |
-
# Baixa narração e imagens
|
| 104 |
image_urls = json.loads(image_urls_json)
|
| 105 |
local_image_paths = []
|
| 106 |
async with aiohttp.ClientSession(headers=BROWSER_HEADERS) as session:
|
|
@@ -113,17 +115,16 @@ async def generate_complete_video(
|
|
| 113 |
|
| 114 |
# --- ETAPA 2: PROCESSAR MÚSICA DE FUNDO ---
|
| 115 |
print("--- STEP 2/8: PROCESSING BACKGROUND MUSIC ---")
|
| 116 |
-
combined_music_path = os.path.join(temp_dir, "
|
| 117 |
if len(local_music_paths) > 1:
|
| 118 |
inputs = "".join([f"-i {shlex.quote(p)} " for p in local_music_paths])
|
| 119 |
-
filter_complex = "".join([f"[{i}:a]" for i in range(len(local_music_paths))]) + f"concat=n={len(local_music_paths)}:v=0:a=1[a]"
|
| 120 |
cmd_concat_music = f"ffmpeg {inputs} -filter_complex \"{filter_complex}\" -map \"[a]\" -y {shlex.quote(combined_music_path)}"
|
| 121 |
await run_subprocess(cmd_concat_music)
|
| 122 |
else:
|
| 123 |
shutil.copy(local_music_paths[0], combined_music_path)
|
| 124 |
|
| 125 |
-
|
| 126 |
-
low_volume_music_path = os.path.join(temp_dir, "background_music_low.mp3")
|
| 127 |
cmd_volume = f"ffmpeg -i {shlex.quote(combined_music_path)} -filter:a \"volume={music_volume}\" -y {shlex.quote(low_volume_music_path)}"
|
| 128 |
await run_subprocess(cmd_volume)
|
| 129 |
print("SUCCESS: Background music processed.")
|
|
@@ -131,63 +132,75 @@ async def generate_complete_video(
|
|
| 131 |
# --- ETAPA 3: ANALISAR NARRAÇÃO ---
|
| 132 |
print("--- STEP 3/8: GETTING NARRATION DURATION ---")
|
| 133 |
cmd_ffprobe = f"ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {shlex.quote(narration_path)}"
|
| 134 |
-
stdout
|
| 135 |
-
total_duration = float(stdout
|
| 136 |
print(f"SUCCESS: Narration duration is {total_duration} seconds.")
|
| 137 |
|
| 138 |
# --- ETAPA 4: CRIAR VÍDEO COM TRANSIÇÕES ---
|
| 139 |
print("--- STEP 4/8: CREATING VIDEO WITH TRANSITIONS ---")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
transition_duration = 1.0
|
| 141 |
num_images = len(local_image_paths)
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
| 144 |
|
|
|
|
|
|
|
| 145 |
input_args = ""
|
| 146 |
for path in local_image_paths:
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
| 150 |
last_stream = "0:v"
|
| 151 |
for i in range(num_images - 1):
|
| 152 |
-
offset = (i + 1) *
|
| 153 |
-
|
| 154 |
last_stream = f"v{i+1}"
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
cmd_transitions = f"ffmpeg {input_args} -filter_complex \"{final_filter}\" -map \"[final_v]\" -an -y {shlex.quote(silent_video_path)}"
|
| 159 |
await run_subprocess(cmd_transitions)
|
| 160 |
-
print("SUCCESS:
|
| 161 |
-
|
| 162 |
-
#
|
| 163 |
-
print("--- STEP 5/8: MIXING
|
| 164 |
-
mixed_audio_path = os.path.join(temp_dir, "
|
| 165 |
cmd_mix_audio = (f"ffmpeg -i {shlex.quote(narration_path)} -i {shlex.quote(low_volume_music_path)} "
|
| 166 |
f"-filter_complex \"[0:a][1:a]amix=inputs=2:duration=first:dropout_transition=3\" -y {shlex.quote(mixed_audio_path)}")
|
| 167 |
await run_subprocess(cmd_mix_audio)
|
| 168 |
print("SUCCESS: Audio tracks mixed.")
|
| 169 |
|
| 170 |
-
#
|
| 171 |
-
print("--- STEP 6/8: COMBINING VIDEO
|
| 172 |
-
|
| 173 |
-
cmd_combine = (f"ffmpeg -i {shlex.quote(
|
| 174 |
-
f"-c:v copy -c:a aac -shortest -y {shlex.quote(
|
| 175 |
await run_subprocess(cmd_combine)
|
|
|
|
| 176 |
|
| 177 |
-
#
|
| 178 |
print("--- STEP 7/8: BURNING SUBTITLES ---")
|
| 179 |
-
final_video_path = os.path.join(temp_dir, "
|
| 180 |
escaped_subtitle_path = subtitle_path.replace("'", "'\\''")
|
| 181 |
-
cmd_subtitles = (f"ffmpeg -i {shlex.quote(
|
| 182 |
f"-c:v libx264 -preset ultrafast -c:a copy -y {shlex.quote(final_video_path)}")
|
| 183 |
await run_subprocess(cmd_subtitles)
|
| 184 |
-
|
|
|
|
| 185 |
# --- ETAPA 8: RETORNAR VÍDEO E LIMPAR ---
|
| 186 |
print("--- STEP 8/8: FINAL VIDEO CREATED. PREPARING RESPONSE. ---")
|
| 187 |
cleanup_task = BackgroundTask(cleanup_directory, directory_path=temp_dir)
|
| 188 |
-
return FileResponse(
|
| 189 |
-
path=final_video_path, filename="video_completo.mp4", media_type="video/mp4", background=cleanup_task
|
| 190 |
-
)
|
| 191 |
|
| 192 |
except Exception as e:
|
| 193 |
cleanup_directory(directory_path=temp_dir)
|
|
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
"""
|
| 4 |
+
FFmpeg API Service for n8n Video Automation
|
| 5 |
|
| 6 |
+
This FastAPI application provides a single, all-in-one endpoint to generate a
|
| 7 |
+
complete video with narration, background music, transitions, and subtitles,
|
| 8 |
+
by orchestrating a sequence of FFmpeg operations.
|
| 9 |
|
| 10 |
+
Version: 9.0.0 (Unified & Production-Ready)
|
| 11 |
"""
|
| 12 |
# --- Core Imports ---
|
| 13 |
import asyncio
|
|
|
|
| 29 |
# 1. APPLICATION CONFIGURATION & SETUP
|
| 30 |
# ==============================================================================
|
| 31 |
app = FastAPI(
|
| 32 |
+
title="Vid Automator API",
|
| 33 |
description="A single endpoint to create full videos with music, transitions, and subtitles.",
|
| 34 |
+
version="9.0.0",
|
| 35 |
)
|
| 36 |
|
| 37 |
TEMP_DIR = "/tmp/ffmpeg_processing"
|
|
|
|
| 51 |
shutil.rmtree(directory_path, ignore_errors=True)
|
| 52 |
print(f"--- CLEANUP: Successfully removed {directory_path} ---")
|
| 53 |
|
| 54 |
+
async def run_subprocess(command: str) -> str:
|
| 55 |
print(f"--- EXECUTING COMMAND ---\n{command}\n-------------------------")
|
| 56 |
process = await asyncio.create_subprocess_shell(
|
| 57 |
command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
| 58 |
)
|
| 59 |
stdout, stderr = await process.communicate()
|
| 60 |
+
|
| 61 |
if process.returncode != 0:
|
| 62 |
error_message = stderr.decode(errors='ignore').strip()
|
| 63 |
print(f"ERROR: Command failed.\nStderr: {error_message}")
|
| 64 |
raise RuntimeError(f"FFmpeg command failed: {error_message}")
|
| 65 |
+
|
| 66 |
print("SUCCESS: Command executed.")
|
| 67 |
+
return stdout.decode(errors='ignore').strip()
|
| 68 |
|
| 69 |
async def download_asset(session: aiohttp.ClientSession, url: str, path: str, asset_type: str):
|
| 70 |
print(f"Downloading {asset_type} from {url}...")
|
| 71 |
async with session.get(url) as resp:
|
| 72 |
+
resp.raise_for_status(f"Failed to download {asset_type} from {url}")
|
| 73 |
with open(path, "wb") as f:
|
| 74 |
f.write(await resp.read())
|
| 75 |
|
| 76 |
# ==============================================================================
|
| 77 |
+
# 3. THE ALL-IN-ONE ENDPOINT (UNIFIED LOGIC)
|
| 78 |
# ==============================================================================
|
| 79 |
|
| 80 |
@app.post("/generate-complete-video/")
|
|
|
|
| 95 |
narration_path = os.path.join(temp_dir, "narration.mp3")
|
| 96 |
subtitle_path = os.path.join(temp_dir, "subtitle.ass")
|
| 97 |
|
|
|
|
| 98 |
with open(subtitle_path, "wb") as f: shutil.copyfileobj(subtitle_file.file, f)
|
| 99 |
|
| 100 |
local_music_paths = []
|
|
|
|
| 103 |
with open(path, "wb") as f: shutil.copyfileobj(music_file.file, f)
|
| 104 |
local_music_paths.append(path)
|
| 105 |
|
|
|
|
| 106 |
image_urls = json.loads(image_urls_json)
|
| 107 |
local_image_paths = []
|
| 108 |
async with aiohttp.ClientSession(headers=BROWSER_HEADERS) as session:
|
|
|
|
| 115 |
|
| 116 |
# --- ETAPA 2: PROCESSAR MÚSICA DE FUNDO ---
|
| 117 |
print("--- STEP 2/8: PROCESSING BACKGROUND MUSIC ---")
|
| 118 |
+
combined_music_path = os.path.join(temp_dir, "background_music_raw.mp3")
|
| 119 |
if len(local_music_paths) > 1:
|
| 120 |
inputs = "".join([f"-i {shlex.quote(p)} " for p in local_music_paths])
|
| 121 |
+
filter_complex = "".join([f"[{i}:a:0]" for i in range(len(local_music_paths))]) + f"concat=n={len(local_music_paths)}:v=0:a=1[a]"
|
| 122 |
cmd_concat_music = f"ffmpeg {inputs} -filter_complex \"{filter_complex}\" -map \"[a]\" -y {shlex.quote(combined_music_path)}"
|
| 123 |
await run_subprocess(cmd_concat_music)
|
| 124 |
else:
|
| 125 |
shutil.copy(local_music_paths[0], combined_music_path)
|
| 126 |
|
| 127 |
+
low_volume_music_path = os.path.join(temp_dir, "background_music.mp3")
|
|
|
|
| 128 |
cmd_volume = f"ffmpeg -i {shlex.quote(combined_music_path)} -filter:a \"volume={music_volume}\" -y {shlex.quote(low_volume_music_path)}"
|
| 129 |
await run_subprocess(cmd_volume)
|
| 130 |
print("SUCCESS: Background music processed.")
|
|
|
|
| 132 |
# --- ETAPA 3: ANALISAR NARRAÇÃO ---
|
| 133 |
print("--- STEP 3/8: GETTING NARRATION DURATION ---")
|
| 134 |
cmd_ffprobe = f"ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {shlex.quote(narration_path)}"
|
| 135 |
+
stdout = await run_subprocess(cmd_ffprobe)
|
| 136 |
+
total_duration = float(stdout)
|
| 137 |
print(f"SUCCESS: Narration duration is {total_duration} seconds.")
|
| 138 |
|
| 139 |
# --- ETAPA 4: CRIAR VÍDEO COM TRANSIÇÕES ---
|
| 140 |
print("--- STEP 4/8: CREATING VIDEO WITH TRANSITIONS ---")
|
| 141 |
+
# Esta lógica está correta, mas a matemática do tempo precisa ser perfeita.
|
| 142 |
+
video_with_transitions_path = os.path.join(temp_dir, "video_with_transitions.mp4")
|
| 143 |
+
# (Lógica de transições, combinação e legendas será unificada abaixo)
|
| 144 |
+
|
| 145 |
+
# --- REFORMULAÇÃO DAS ETAPAS 4, 5, 6, 7 PARA MÁXIMA ROBUSTEZ ---
|
| 146 |
+
|
| 147 |
+
# ETAPA 4 (Revisada): Criar slideshow silencioso com transições
|
| 148 |
transition_duration = 1.0
|
| 149 |
num_images = len(local_image_paths)
|
| 150 |
+
if num_images <= 1: raise ValueError("Need at least 2 images for transitions.")
|
| 151 |
+
|
| 152 |
+
# Fórmula correta para a duração de cada "cena" (imagem estática + transição)
|
| 153 |
+
total_display_time = total_duration - transition_duration
|
| 154 |
+
display_duration_per_image = total_display_time / num_images
|
| 155 |
|
| 156 |
+
if display_duration_per_image <= 0: raise ValueError("Audio too short for this many images/transitions.")
|
| 157 |
+
|
| 158 |
input_args = ""
|
| 159 |
for path in local_image_paths:
|
| 160 |
+
# Duração total de cada input de imagem
|
| 161 |
+
input_args += f"-loop 1 -t {display_duration_per_image + transition_duration} -i {shlex.quote(path)} "
|
| 162 |
+
|
| 163 |
+
filter_chain = ""
|
| 164 |
last_stream = "0:v"
|
| 165 |
for i in range(num_images - 1):
|
| 166 |
+
offset = (i + 1) * display_duration_per_image
|
| 167 |
+
filter_chain += f"[{last_stream}][{i+1}:v]xfade=transition=fade:duration={transition_duration}:offset={offset}[v{i+1}];"
|
| 168 |
last_stream = f"v{i+1}"
|
| 169 |
+
|
| 170 |
+
final_video_filter = f"{filter_chain}[{last_stream}]format=yuv420p,fps=25[final_v]"
|
| 171 |
+
cmd_transitions = f"ffmpeg {input_args} -filter_complex \"{final_video_filter}\" -map \"[final_v]\" -an -y {shlex.quote(video_with_transitions_path)}"
|
|
|
|
| 172 |
await run_subprocess(cmd_transitions)
|
| 173 |
+
print("SUCCESS: Silent video with transitions created.")
|
| 174 |
+
|
| 175 |
+
# ETAPA 5 (Revisada): Mixar narração e música de fundo
|
| 176 |
+
print("--- STEP 5/8: MIXING AUDIO TRACKS ---")
|
| 177 |
+
mixed_audio_path = os.path.join(temp_dir, "final_audio_mix.mp3")
|
| 178 |
cmd_mix_audio = (f"ffmpeg -i {shlex.quote(narration_path)} -i {shlex.quote(low_volume_music_path)} "
|
| 179 |
f"-filter_complex \"[0:a][1:a]amix=inputs=2:duration=first:dropout_transition=3\" -y {shlex.quote(mixed_audio_path)}")
|
| 180 |
await run_subprocess(cmd_mix_audio)
|
| 181 |
print("SUCCESS: Audio tracks mixed.")
|
| 182 |
|
| 183 |
+
# ETAPA 6 (Revisada): Combinar o vídeo (com transições) e o áudio final (mixado)
|
| 184 |
+
print("--- STEP 6/8: COMBINING VIDEO AND FINAL AUDIO ---")
|
| 185 |
+
video_with_final_audio_path = os.path.join(temp_dir, "video_with_audio.mp4")
|
| 186 |
+
cmd_combine = (f"ffmpeg -i {shlex.quote(video_with_transitions_path)} -i {shlex.quote(mixed_audio_path)} "
|
| 187 |
+
f"-c:v copy -c:a aac -shortest -y {shlex.quote(video_with_final_audio_path)}")
|
| 188 |
await run_subprocess(cmd_combine)
|
| 189 |
+
print("SUCCESS: Video and final audio combined.")
|
| 190 |
|
| 191 |
+
# ETAPA 7 (Revisada): Adicionar Legendas ao vídeo final
|
| 192 |
print("--- STEP 7/8: BURNING SUBTITLES ---")
|
| 193 |
+
final_video_path = os.path.join(temp_dir, "final_video_com_legenda.mp4")
|
| 194 |
escaped_subtitle_path = subtitle_path.replace("'", "'\\''")
|
| 195 |
+
cmd_subtitles = (f"ffmpeg -i {shlex.quote(video_with_final_audio_path)} -vf \"ass='{escaped_subtitle_path}'\" "
|
| 196 |
f"-c:v libx264 -preset ultrafast -c:a copy -y {shlex.quote(final_video_path)}")
|
| 197 |
await run_subprocess(cmd_subtitles)
|
| 198 |
+
print("SUCCESS: Subtitles burned into final video.")
|
| 199 |
+
|
| 200 |
# --- ETAPA 8: RETORNAR VÍDEO E LIMPAR ---
|
| 201 |
print("--- STEP 8/8: FINAL VIDEO CREATED. PREPARING RESPONSE. ---")
|
| 202 |
cleanup_task = BackgroundTask(cleanup_directory, directory_path=temp_dir)
|
| 203 |
+
return FileResponse(path=final_video_path, filename="video_completo.mp4", media_type="video/mp4", background=cleanup_task)
|
|
|
|
|
|
|
| 204 |
|
| 205 |
except Exception as e:
|
| 206 |
cleanup_directory(directory_path=temp_dir)
|