|
import os |
|
import subprocess |
|
import shlex |
|
import tempfile |
|
import uuid |
|
from typing import List |
|
import shutil |
|
import logging |
|
|
|
from fastapi import FastAPI, File, UploadFile, Form, HTTPException |
|
from fastapi.responses import FileResponse, JSONResponse |
|
from starlette.background import BackgroundTask |
|
|
|
|
|
logging.basicConfig(level=logging.INFO) |
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
def run_ffmpeg_concatenation(input_files: List[str], output_file: str, ffmpeg_executable: str = "ffmpeg"): |
|
""" |
|
Runs the FFmpeg concatenation process. |
|
Returns (success: bool, message: str, stderr: str) |
|
""" |
|
if not input_files: |
|
return False, "Error: Cannot concatenate, file list is empty.", "" |
|
|
|
logger.info(f"Starting re-encode concatenation of {len(input_files)} videos into {output_file}...") |
|
logger.info("This will re-encode the video and may take some time.") |
|
|
|
input_args = [] |
|
for video_file in input_files: |
|
input_args.extend(['-i', video_file]) |
|
|
|
filter_parts = [] |
|
for i in range(len(input_files)): |
|
filter_parts.append(f"[{i}:v]") |
|
filter_parts.append(f"[{i}:a]") |
|
filter_string = "".join(filter_parts) + f"concat=n={len(input_files)}:v=1:a=1[outv][outa]" |
|
|
|
command = [ |
|
ffmpeg_executable, |
|
*input_args, |
|
'-filter_complex', filter_string, |
|
'-map', '[outv]', |
|
'-map', '[outa]', |
|
'-vsync', 'vfr', |
|
'-movflags', '+faststart', |
|
'-y', |
|
output_file |
|
] |
|
|
|
logger.info("\nRunning FFmpeg command:") |
|
try: |
|
cmd_str = shlex.join(command) |
|
logger.info(cmd_str) |
|
except AttributeError: |
|
cmd_str = ' '.join(f'"{arg}"' if ' ' in arg else arg for arg in command) |
|
logger.info(cmd_str) |
|
|
|
logger.info("\n--- FFmpeg Execution Start ---") |
|
stderr_output = "" |
|
try: |
|
process = subprocess.run( |
|
command, |
|
check=True, |
|
capture_output=True, |
|
text=True, |
|
encoding='utf-8', |
|
errors='replace' |
|
) |
|
stderr_output = process.stderr |
|
logger.info("--- FFmpeg Execution End ---") |
|
logger.info("\nSTDERR (Progress/Info):\n" + stderr_output) |
|
msg = f"Successfully concatenated videos into {os.path.basename(output_file)}" |
|
logger.info(msg) |
|
return True, msg, stderr_output |
|
except subprocess.CalledProcessError as e: |
|
stderr_output = e.stderr + "\n" + e.stdout |
|
logger.error("--- FFmpeg Execution End ---") |
|
logger.error(f"\nError during FFmpeg execution (return code {e.returncode}):") |
|
logger.error("\nSTDERR/STDOUT:\n" + stderr_output) |
|
return False, f"FFmpeg failed with return code {e.returncode}.", stderr_output |
|
except FileNotFoundError: |
|
err_msg = f"Error: '{ffmpeg_executable}' command not found on the server." |
|
logger.error(err_msg) |
|
return False, err_msg, "" |
|
except Exception as e: |
|
err_msg = f"An unexpected server error occurred during ffmpeg processing: {e}" |
|
logger.exception(err_msg) |
|
return False, err_msg, stderr_output |
|
|
|
|
|
def cleanup_temp_dir(temp_dir: str): |
|
"""Removes a temporary directory.""" |
|
try: |
|
logger.info(f"Cleaning up temporary directory: {temp_dir}") |
|
shutil.rmtree(temp_dir) |
|
logger.info(f"Successfully cleaned up temporary directory: {temp_dir}") |
|
except Exception as e: |
|
logger.error(f"Error cleaning up temporary directory {temp_dir}: {e}") |
|
|
|
|
|
app = FastAPI() |
|
|
|
@app.post("/concatenate/") |
|
async def concatenate_videos_api( |
|
files: List[UploadFile] = File(..., description="List of video files to concatenate"), |
|
output_filename: str = Form("concatenated_video.mp4", description="Desired output filename (e.g., final.mp4)") |
|
): |
|
""" |
|
API endpoint to concatenate uploaded video files using FFmpeg re-encoding. |
|
Cleans up temporary files after response is sent. |
|
""" |
|
if not files: |
|
raise HTTPException(status_code=400, detail="No files were uploaded.") |
|
if not output_filename.lower().endswith(('.mp4', '.mov', '.avi', '.mkv')): |
|
raise HTTPException(status_code=400, detail="Output filename must have a common video extension (mp4, mov, avi, mkv).") |
|
|
|
logger.info(f"Received {len(files)} files for concatenation. Output name: {output_filename}") |
|
|
|
|
|
temp_dir = tempfile.mkdtemp() |
|
logger.info(f"Created temporary directory: {temp_dir}") |
|
|
|
input_file_paths = [] |
|
original_filenames = [] |
|
|
|
try: |
|
|
|
for uploaded_file in files: |
|
original_filenames.append(uploaded_file.filename) |
|
_, ext = os.path.splitext(uploaded_file.filename) |
|
|
|
temp_input_path = os.path.join(temp_dir, f"{uuid.uuid4()}{ext}") |
|
logger.info(f"Saving uploaded file '{uploaded_file.filename}' to '{temp_input_path}'") |
|
try: |
|
with open(temp_input_path, "wb") as buffer: |
|
buffer.write(await uploaded_file.read()) |
|
input_file_paths.append(temp_input_path) |
|
finally: |
|
await uploaded_file.close() |
|
|
|
logger.info(f"Saved files: {original_filenames}") |
|
|
|
|
|
temp_output_path = os.path.join(temp_dir, output_filename) |
|
|
|
|
|
success, message, ffmpeg_stderr = run_ffmpeg_concatenation(input_file_paths, temp_output_path) |
|
|
|
if success: |
|
logger.info(f"Concatenation successful. Preparing file response for: {temp_output_path}") |
|
|
|
return FileResponse( |
|
path=temp_output_path, |
|
filename=output_filename, |
|
media_type='video/mp4', |
|
background=BackgroundTask(cleanup_temp_dir, temp_dir) |
|
) |
|
else: |
|
logger.error(f"Concatenation failed: {message}") |
|
|
|
cleanup_temp_dir(temp_dir) |
|
|
|
return JSONResponse( |
|
status_code=500, |
|
content={ |
|
"detail": f"Video concatenation failed: {message}", |
|
"ffmpeg_stderr": ffmpeg_stderr, |
|
"input_files": original_filenames |
|
} |
|
) |
|
|
|
except Exception as e: |
|
logger.exception("An unexpected error occurred in the API endpoint.") |
|
|
|
cleanup_temp_dir(temp_dir) |
|
raise HTTPException(status_code=500, detail=f"Internal server error: {e}") |
|
|
|
@app.get("/") |
|
async def read_root(): |
|
return {"message": "Video Concatenation API is running. Use the /concatenate/ endpoint (POST) to process videos."} |