Spaces:
Sleeping
Sleeping
File size: 9,780 Bytes
46bdb10 8c05c55 c64b325 d7332ba 8c05c55 46bdb10 c0b7fb8 8c05c55 4ff4cc1 c64b325 4ff4cc1 8c05c55 46bdb10 d7332ba 46bdb10 d7332ba c0b7fb8 d7332ba c0b7fb8 d7332ba 46bdb10 e174367 c64b325 e174367 d7332ba 46bdb10 e174367 46bdb10 d7332ba 46bdb10 d7332ba 46bdb10 a3c7146 46bdb10 4ff4cc1 46bdb10 c0b7fb8 46bdb10 4ff4cc1 e174367 b95ac21 e174367 4ff4cc1 d7332ba 4ff4cc1 c64b325 46bdb10 c64b325 c0b7fb8 c64b325 c0b7fb8 c64b325 c0b7fb8 822e0d5 c0b7fb8 d7332ba c0b7fb8 c64b325 4ff4cc1 c64b325 46bdb10 c64b325 4ff4cc1 46bdb10 b95ac21 c0b7fb8 4ff4cc1 c64b325 bf60d22 8c05c55 b95ac21 5dd9ede e174367 8c05c55 d7332ba 46bdb10 5dd9ede 46bdb10 5dd9ede e174367 46bdb10 e174367 46bdb10 e174367 46bdb10 e174367 c0b7fb8 46bdb10 4ff4cc1 c64b325 4ff4cc1 c64b325 46bdb10 c467f81 4ff4cc1 e174367 4ff4cc1 46bdb10 8c05c55 e1ddc2d 46bdb10 e1ddc2d c64b325 8c05c55 46bdb10 |
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 |
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, BackgroundTasks
from fastapi.responses import StreamingResponse
import tempfile
import shutil
import os
import logging
import traceback
from moviepy.editor import (
VideoFileClip,
concatenate_videoclips,
AudioFileClip,
CompositeAudioClip,
concatenate_audioclips,
vfx,
)
import io
from PIL import Image
# Configure logging
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger("main")
# Enable MoviePy verbose logging
from moviepy.config import change_settings
change_settings({"VERBOSE": True})
# Safe video clip loader
def safe_video_clip(path, target_width=1280, reference_aspect_ratio=None):
try:
clip = VideoFileClip(path)
if clip.reader is None or clip.duration is None or clip.duration <= 0:
clip.close()
raise ValueError(f"Invalid or empty video file: {os.path.basename(path)}")
logger.debug(f"Loaded video: {path}, duration: {clip.duration}s, resolution: {clip.size}, aspect ratio: {clip.w / clip.h}")
# Get video aspect ratio
current_aspect_ratio = clip.w / clip.h
# Use reference aspect ratio if provided, else use current video's aspect ratio
target_aspect_ratio = reference_aspect_ratio if reference_aspect_ratio else current_aspect_ratio
# Check if aspect ratios are compatible (within a small tolerance)
if reference_aspect_ratio and abs(current_aspect_ratio - target_aspect_ratio) > 0.05:
logger.warning(f"Video {path} has different aspect ratio ({current_aspect_ratio:.2f}) than reference ({target_aspect_ratio:.2f}). Adjusting to match.")
# Calculate target resolution to preserve aspect ratio
target_height = int(target_width / target_aspect_ratio)
# Ensure height is even (required by libx264 codec)
target_height = target_height if target_height % 2 == 0 else target_height + 1
try:
clip = clip.resize((target_width, target_height))
logger.debug(f"Resized video {path} to {target_width}x{target_height}, aspect ratio: {target_width / target_height}")
except Exception as e:
logger.warning(f"Failed to resize video {path}: {e}")
# Continue without resizing if it fails (may cause concatenation issues)
return clip, target_aspect_ratio
except Exception as e:
raise ValueError(f"Failed to load video file '{os.path.basename(path)}': {str(e)}")
# Safe audio clip loader
def safe_audio_clip(path):
try:
clip = AudioFileClip(path)
if clip.reader is None or clip.duration is None or clip.duration <= 0:
clip.close()
raise ValueError(f"Invalid or empty audio file: {os.path.basename(path)}")
logger.debug(f"Loaded audio: {path}, duration: {clip.duration}s")
return clip
except Exception as e:
raise ValueError(f"Failed to load audio file '{os.path.basename(path)}': {str(e)}")
def merge_videos_and_audios(video_paths, audio_paths, output_path, temp_dir, orig_vol=1.0, music_vol=0.5, audio_mode="concatenate"):
logger.debug(f"Merging {len(video_paths)} videos and {len(audio_paths)} audios to {output_path}")
# Load video clips
video_clips = []
reference_aspect_ratio = None
for vp in video_paths:
try:
clip, aspect_ratio = safe_video_clip(vp, reference_aspect_ratio=reference_aspect_ratio)
if reference_aspect_ratio is None:
reference_aspect_ratio = aspect_ratio # Set reference from first valid video
video_clips.append(clip)
except Exception as e:
logger.warning(f"Skipping invalid video file {vp}: {e}")
if not video_clips:
raise ValueError("No valid video files provided")
# Load audio clips
audio_clips = []
for ap in audio_paths:
try:
clip = safe_audio_clip(ap)
clip = clip.volumex(music_vol)
audio_clips.append(clip)
except Exception as e:
logger.warning(f"Skipping invalid audio file {ap}: {e}")
# Concatenate video clips
final_video = concatenate_videoclips(video_clips, method="compose")
logger.debug(f"Concatenated video duration: {final_video.duration}s, resolution: {final_video.size}")
# Handle original video audio
original_audio = None
if final_video.audio:
original_audio = final_video.audio.volumex(orig_vol)
logger.debug(f"Original video audio preserved, duration: {original_audio.duration}s")
# Combine audio clips
final_audio = None
if audio_clips:
if audio_mode == "concatenate":
final_audio = concatenate_audioclips(audio_clips)
logger.debug(f"Concatenated audio duration: {final_audio.duration}s")
else:
final_audio = CompositeAudioClip(audio_clips)
logger.debug(f"Composite audio duration: {final_audio.duration}s")
# Match audio duration to video
if final_audio and final_audio.duration < final_video.duration:
logger.debug(f"Looping audio to match video duration: {final_video.duration}s")
final_audio = final_audio.fx(vfx.loop, duration=final_video.duration)
elif final_audio and final_audio.duration > final_video.duration:
logger.debug(f"Trimming audio to match video duration: {final_video.duration}s")
final_audio = final_audio.subclip(0, final_video.duration)
if original_audio:
final_audio = CompositeAudioClip([final_audio, original_audio])
logger.debug(f"Combined audio (original + uploaded) duration: {final_audio.duration}s")
# Set the audio for the final video
if final_audio:
final_video = final_video.set_audio(final_audio)
elif original_audio:
final_video = final_video.set_audio(original_audio)
else:
logger.warning("No audio (uploaded or original) available for the final video")
# Write output
logger.debug(f"Writing output to {output_path}")
try:
final_video.write_videofile(
output_path,
codec="libx264",
audio_codec="aac",
temp_audiofile=os.path.join(temp_dir, "temp-audio.m4a"),
remove_temp=True,
verbose=True,
logger='bar'
)
except Exception as e:
logger.error(f"MoviePy failed to write output: {str(e)}")
raise
# Close clips
final_video.close()
for vc in video_clips:
vc.close()
for ac in audio_clips:
ac.close()
if original_audio:
original_audio.close()
app = FastAPI()
@app.post("/merge")
async def merge_endpoint(
files: list[UploadFile] = File(...),
orig_vol: float = Form(1.0),
music_vol: float = Form(0.5),
audio_mode: str = Form("concatenate"),
background_tasks: BackgroundTasks = BackgroundTasks(),
):
temp_dir = tempfile.mkdtemp()
logger.debug(f"Created temporary directory: {temp_dir}")
try:
saved_files = []
for uploaded_file in files:
if not uploaded_file.filename:
logger.warning("Skipping file with no filename")
continue
file_path = os.path.join(temp_dir, uploaded_file.filename)
with open(file_path, "wb") as out_file:
content = await uploaded_file.read()
if not content:
logger.warning(f"Empty file uploaded: {uploaded_file.filename}")
continue
out_file.write(content)
saved_files.append(file_path)
logger.debug(f"Saved file: {file_path}")
video_files = [f for f in saved_files if f.lower().endswith(".mp4")]
audio_files = [f for f in saved_files if f.lower().endswith((".mp3", ".wav", ".aac", ".m4a", ".ogg"))]
logger.debug(f"Video files: {video_files}")
logger.debug(f"Audio files: {audio_files}")
if len(video_files) < 1:
raise HTTPException(status_code=400, detail="Please upload at least one valid video file")
output_path = os.path.join(temp_dir, "merged_output.mp4")
merge_videos_and_audios(video_files, audio_files, output_path, temp_dir, orig_vol, music_vol, audio_mode)
if not os.path.exists(output_path):
logger.error(f"Output file not found: {output_path}")
raise HTTPException(status_code=500, detail="Failed to create merged video file")
logger.debug(f"Reading output file: {output_path}")
with open(output_path, "rb") as file:
content = file.read()
if not content:
logger.error(f"Output file is empty: {output_path}")
raise HTTPException(status_code=500, detail="Output file is empty")
background_tasks.add_task(shutil.rmtree, temp_dir, ignore_errors=True)
logger.debug("Sending streaming response")
return StreamingResponse(
io.BytesIO(content),
media_type="video/mp4",
headers={"Content-Disposition": 'attachment; filename="merged_output.mp4"'}
)
except Exception as e:
error_msg = f"Error: {str(e)}\n\n{traceback.format_exc()}"
logger.error(error_msg)
raise HTTPException(status_code=500, detail=error_msg)
finally:
logger.debug(f"Cleaning up temporary directory: {temp_dir}")
shutil.rmtree(temp_dir, ignore_errors=True)
# Log public URL
def log_api_url():
url = os.getenv("SPACE_PUBLIC_URL")
if url:
logger.info(f"API is available at: {url}/merge")
else:
logger.info("SPACE_PUBLIC_URL environment variable not found")
log_api_url() |