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()