Spaces:
Sleeping
Sleeping
| """ | |
| Video Export API | |
| Handles merging multiple video clips into a single output video | |
| """ | |
| from fastapi import APIRouter, HTTPException, UploadFile, File, Form | |
| from fastapi.responses import FileResponse, StreamingResponse | |
| from typing import List, Optional, Tuple | |
| import os | |
| import tempfile | |
| import subprocess | |
| import json | |
| from pathlib import Path | |
| router = APIRouter() | |
| def get_video_dimensions(video_path: Path) -> Tuple[int, int]: | |
| """Get video width and height using ffprobe""" | |
| try: | |
| cmd = [ | |
| 'ffprobe', | |
| '-v', 'error', | |
| '-select_streams', 'v:0', | |
| '-show_entries', 'stream=width,height', | |
| '-of', 'json', | |
| str(video_path) | |
| ] | |
| result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) | |
| if result.returncode == 0: | |
| data = json.loads(result.stdout) | |
| streams = data.get('streams', []) | |
| if streams: | |
| width = streams[0].get('width', 1080) | |
| height = streams[0].get('height', 1920) | |
| return (width, height) | |
| except Exception as e: | |
| print(f"β οΈ Could not detect video dimensions: {e}") | |
| # Default to 9:16 portrait if detection fails | |
| return (1080, 1920) | |
| async def merge_videos( | |
| clips_data: str = Form(...), # JSON string with clip metadata | |
| files: List[UploadFile] = File(...) | |
| ): | |
| """ | |
| Merge multiple video clips into a single output video | |
| clips_data: JSON string containing array of clip objects with: | |
| - index: order in timeline | |
| - startTime: start time in clip (seconds) | |
| - endTime: end time in clip (seconds) | |
| - type: 'video' or 'image' | |
| - duration: duration for images (seconds) | |
| files: Video/image files in the same order as clips_data | |
| """ | |
| try: | |
| # Parse clips data | |
| clips = json.loads(clips_data) | |
| if len(clips) != len(files): | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Mismatch: {len(clips)} clips but {len(files)} files" | |
| ) | |
| if len(clips) == 0: | |
| raise HTTPException(status_code=400, detail="No clips to merge") | |
| # Create temporary directory for processing | |
| with tempfile.TemporaryDirectory() as temp_dir: | |
| temp_path = Path(temp_dir) | |
| # Save all uploaded files | |
| file_paths = [] | |
| for i, file in enumerate(files): | |
| clip = clips[i] | |
| file_path = temp_path / f"input_{i}.{file.filename.split('.')[-1] if '.' in file.filename else 'mp4'}" | |
| with open(file_path, 'wb') as f: | |
| content = await file.read() | |
| f.write(content) | |
| file_paths.append(file_path) | |
| # Detect dimensions from first video to preserve aspect ratio | |
| target_width, target_height = get_video_dimensions(file_paths[0]) | |
| print(f"π Detected video dimensions: {target_width}x{target_height}") | |
| # Build FFmpeg command | |
| output_path = temp_path / "output.mp4" | |
| # Helper function to check if video has audio stream | |
| def has_audio_stream(video_path: Path) -> bool: | |
| """Check if video file has an audio stream""" | |
| try: | |
| cmd = [ | |
| 'ffprobe', | |
| '-v', 'error', | |
| '-select_streams', 'a', | |
| '-show_entries', 'stream=codec_type', | |
| '-of', 'json', | |
| str(video_path) | |
| ] | |
| result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) | |
| if result.returncode == 0: | |
| import json as json_lib | |
| data = json_lib.loads(result.stdout) | |
| streams = data.get('streams', []) | |
| return len(streams) > 0 | |
| return False | |
| except Exception: | |
| return False | |
| # Build filter complex - process clips in order | |
| filter_parts = [] | |
| input_args = [] | |
| concat_inputs = [] | |
| # Process all clips in order | |
| input_index = 0 | |
| for clip_idx, clip in enumerate(clips): | |
| file_path = file_paths[clip_idx] | |
| if clip['type'] == 'video': | |
| clip_duration = clip['endTime'] - clip['startTime'] | |
| input_args.extend(['-i', str(file_path)]) | |
| # Check if video has audio | |
| has_audio = has_audio_stream(file_path) | |
| # Trim video and scale to match first video's dimensions | |
| # Using scale with force_original_aspect_ratio to handle any size differences | |
| filter_parts.append( | |
| f"[{input_index}:v]trim=start={clip['startTime']}:end={clip['endTime']}," | |
| f"setpts=PTS-STARTPTS," | |
| f"scale={target_width}:{target_height}:force_original_aspect_ratio=decrease," | |
| f"pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2," | |
| f"setsar=1[v{clip_idx}];" | |
| ) | |
| if has_audio: | |
| # Use existing audio stream | |
| filter_parts.append( | |
| f"[{input_index}:a]atrim=start={clip['startTime']}:end={clip['endTime']}," | |
| f"asetpts=PTS-STARTPTS[a{clip_idx}];" | |
| ) | |
| else: | |
| # Generate silent audio for videos without audio | |
| filter_parts.append( | |
| f"anullsrc=channel_layout=stereo:sample_rate=44100,atrim=0:{clip_duration}," | |
| f"asetpts=PTS-STARTPTS[a{clip_idx}];" | |
| ) | |
| input_index += 1 | |
| else: | |
| # Image clip | |
| clip_duration = clip.get('duration', 3.0) # Default 3 seconds for images | |
| input_args.extend(['-loop', '1', '-t', str(clip_duration), '-i', str(file_path)]) | |
| # Scale image to match video dimensions | |
| filter_parts.append( | |
| f"[{input_index}:v]scale={target_width}:{target_height}:force_original_aspect_ratio=decrease," | |
| f"pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2," | |
| f"setsar=1,format=yuv420p[v{clip_idx}];" | |
| ) | |
| # Generate silent audio | |
| filter_parts.append( | |
| f"anullsrc=channel_layout=stereo:sample_rate=44100,atrim=0:{clip_duration}," | |
| f"asetpts=PTS-STARTPTS[a{clip_idx}];" | |
| ) | |
| input_index += 1 | |
| # Add to concat inputs in order | |
| concat_inputs.append(f"[v{clip_idx}][a{clip_idx}]") | |
| # Build complete filter complex | |
| filter_complex = ''.join(filter_parts) | |
| filter_complex += f"{''.join(concat_inputs)}concat=n={len(clips)}:v=1:a=1[outv][outa]" | |
| # Build FFmpeg command | |
| ffmpeg_cmd = [ | |
| 'ffmpeg', | |
| *input_args, | |
| '-filter_complex', filter_complex, | |
| '-map', '[outv]', | |
| '-map', '[outa]', | |
| '-c:v', 'libx264', | |
| '-c:a', 'aac', | |
| '-movflags', '+faststart', | |
| '-y', # Overwrite output | |
| str(output_path) | |
| ] | |
| print(f"π¬ Running FFmpeg merge with dimensions: {target_width}x{target_height}") | |
| # Run FFmpeg | |
| result = subprocess.run( | |
| ffmpeg_cmd, | |
| capture_output=True, | |
| text=True, | |
| timeout=300 # 5 minute timeout | |
| ) | |
| if result.returncode != 0: | |
| print(f"β FFmpeg error: {result.stderr}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"FFmpeg failed: {result.stderr[:500]}" | |
| ) | |
| if not output_path.exists(): | |
| raise HTTPException(status_code=500, detail="Output file was not created") | |
| # Read the entire file into memory before temp directory is deleted | |
| print(f"π¦ Reading merged video file ({output_path.stat().st_size / 1024 / 1024:.2f} MB)...") | |
| with open(output_path, 'rb') as f: | |
| video_content = f.read() | |
| print(f"β Video merged successfully: {target_width}x{target_height}") | |
| # Return the merged video file | |
| def generate(): | |
| # Yield in chunks to avoid loading entire file in memory at once | |
| chunk_size = 8192 | |
| for i in range(0, len(video_content), chunk_size): | |
| yield video_content[i:i + chunk_size] | |
| return StreamingResponse( | |
| generate(), | |
| media_type="video/mp4", | |
| headers={ | |
| "Content-Disposition": "attachment; filename=exported-video.mp4", | |
| "Content-Type": "video/mp4", | |
| "Content-Length": str(len(video_content)) | |
| } | |
| ) | |
| except json.JSONDecodeError as e: | |
| raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}") | |
| except subprocess.TimeoutExpired: | |
| raise HTTPException(status_code=504, detail="Video processing timed out") | |
| except Exception as e: | |
| print(f"β Export error: {str(e)}") | |
| raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}") | |