Spaces:
Running
Running
| from flask import Flask, request, jsonify, send_from_directory | |
| import subprocess | |
| import os | |
| import uuid | |
| import tempfile | |
| import urllib.request | |
| import urllib.parse | |
| app = Flask(__name__) | |
| # Folder untuk simpan hasil | |
| # OUTPUT_DIR = "downloads" | |
| OUTPUT_DIR = "/tmp/downloads" | |
| os.makedirs(OUTPUT_DIR, exist_ok=True) | |
| def run_cmd(args, shell=False): | |
| """Run command and return result""" | |
| try: | |
| result = subprocess.run(args, shell=shell, capture_output=True, text=True, timeout=300) | |
| return result | |
| except subprocess.TimeoutExpired: | |
| return subprocess.CompletedProcess(args, -1, "", "Command timed out") | |
| except Exception as e: | |
| return subprocess.CompletedProcess(args, -1, "", str(e)) | |
| def hms_to_seconds(hms): | |
| """Convert HH:MM:SS to seconds""" | |
| try: | |
| parts = hms.split(':') | |
| if len(parts) == 3: | |
| hours, minutes, seconds = map(float, parts) | |
| return hours * 3600 + minutes * 60 + seconds | |
| elif len(parts) == 2: | |
| minutes, seconds = map(float, parts) | |
| return minutes * 60 + seconds | |
| else: | |
| return float(parts[0]) | |
| except: | |
| return 0 | |
| def get_resolution_params(resolution): | |
| """Get width and height from resolution parameter""" | |
| if not resolution: | |
| return None, None | |
| resolution = resolution.lower() | |
| # Predefined portrait resolutions | |
| portrait_resolutions = { | |
| 'portrait': (1080, 1920), # 9:16 aspect ratio | |
| 'portrait_hd': (720, 1280), # 9:16 HD | |
| 'portrait_sd': (480, 854), # 9:16 SD | |
| 'square': (1080, 1080), # 1:1 square | |
| 'instagram': (1080, 1350), # 4:5 Instagram | |
| 'tiktok': (1080, 1920), # 9:16 TikTok | |
| 'story': (1080, 1920), # 9:16 Story | |
| } | |
| if resolution in portrait_resolutions: | |
| return portrait_resolutions[resolution] | |
| # Custom resolution (e.g., "720x1280") | |
| if 'x' in resolution: | |
| try: | |
| width, height = map(int, resolution.split('x')) | |
| return width, height | |
| except: | |
| return None, None | |
| return None, None | |
| def normalize_even(width, height): | |
| """Ensure width/height are even numbers for H.264 compatibility""" | |
| if width is None or height is None: | |
| return width, height | |
| if width % 2 != 0: | |
| width -= 1 | |
| if height % 2 != 0: | |
| height -= 1 | |
| return width, height | |
| def probe_media_duration_seconds(input_path): | |
| """Return media duration in seconds using ffprobe, or None if fails.""" | |
| try: | |
| cmd = [ | |
| "ffprobe", "-v", "error", | |
| "-show_entries", "format=duration", | |
| "-of", "default=nk=1:nw=1", | |
| input_path | |
| ] | |
| result = run_cmd(cmd) | |
| if result.returncode == 0 and result.stdout: | |
| val = float(result.stdout.strip()) | |
| if val > 0: | |
| return val | |
| except Exception: | |
| pass | |
| return None | |
| def _guess_extension_from_url_or_ct(url, content_type, fallback_ext): | |
| try: | |
| # Try from URL path | |
| path = urllib.parse.urlparse(url).path | |
| ext = os.path.splitext(path)[1] | |
| if ext: | |
| return ext | |
| except Exception: | |
| pass | |
| # Fallback from content-type | |
| if content_type: | |
| ct = content_type.lower().split(';')[0].strip() | |
| mapping = { | |
| 'image/jpeg': '.jpg', | |
| 'image/jpg': '.jpg', | |
| 'image/png': '.png', | |
| 'image/webp': '.webp', | |
| 'image/bmp': '.bmp', | |
| 'image/gif': '.gif', | |
| 'audio/mpeg': '.mp3', | |
| 'audio/mp3': '.mp3', | |
| 'audio/aac': '.aac', | |
| 'audio/wav': '.wav', | |
| 'audio/x-wav': '.wav', | |
| 'audio/flac': '.flac', | |
| 'audio/ogg': '.ogg', | |
| 'audio/webm': '.webm', | |
| } | |
| if ct in mapping: | |
| return mapping[ct] | |
| return fallback_ext | |
| def download_url_to_file(src_url, default_ext): | |
| """Download a URL to a temp file under OUTPUT_DIR, return the file path. | |
| This avoids extra deps; uses urllib with a sensible timeout. | |
| """ | |
| try: | |
| req = urllib.request.Request(src_url, headers={ | |
| 'User-Agent': 'Mozilla/5.0 (compatible; image-audio-fetcher)' | |
| }) | |
| with urllib.request.urlopen(req, timeout=60) as resp: | |
| content_type = resp.headers.get('Content-Type', '') | |
| ext = _guess_extension_from_url_or_ct(src_url, content_type, default_ext) | |
| temp_name = f"dl_{uuid.uuid4().hex}{ext}" | |
| out_path = os.path.join(OUTPUT_DIR, temp_name) | |
| with open(out_path, 'wb') as f: | |
| while True: | |
| chunk = resp.read(1024 * 64) | |
| if not chunk: | |
| break | |
| f.write(chunk) | |
| if not os.path.exists(out_path) or os.path.getsize(out_path) == 0: | |
| raise RuntimeError('Downloaded file is empty') | |
| return out_path | |
| except Exception as e: | |
| raise RuntimeError(str(e)) | |
| def image_to_video(): | |
| """Create a video from a still image and an audio file. | |
| Form-data fields: | |
| - image: uploaded image file (required) | |
| - audio: uploaded audio/music file (required) | |
| - duration: target duration in seconds or HH:MM:SS (required unless audio_start/audio_end provided) | |
| - resolution: preset (e.g., portrait) or custom WIDTHxHEIGHT (optional) | |
| - fit: 'pad' (default) or 'crop' when resolution is provided (optional) | |
| """ | |
| image_file = request.files.get('image') | |
| audio_file = request.files.get('audio') | |
| duration_str = request.form.get('duration') | |
| audio_start_str = request.form.get('audio_start') # HH:MM:SS or seconds (optional) | |
| audio_end_str = request.form.get('audio_end') # HH:MM:SS or seconds (optional) | |
| resolution = request.form.get('resolution') | |
| fit = (request.form.get('fit') or 'pad').lower() | |
| if not image_file or not audio_file: | |
| return jsonify({"error": "Harap unggah file image dan audio"}), 400 | |
| # Resolve timing parameters | |
| audio_start_sec = None | |
| audio_end_sec = None | |
| duration_sec = None | |
| if audio_start_str: | |
| audio_start_sec = int(float(hms_to_seconds(audio_start_str))) | |
| if audio_start_sec < 0: | |
| audio_start_sec = 0 | |
| if audio_end_str: | |
| audio_end_sec = int(float(hms_to_seconds(audio_end_str))) | |
| if audio_end_sec < 0: | |
| audio_end_sec = 0 | |
| if audio_start_sec is not None or audio_end_sec is not None: | |
| # Use audio window; compute duration from it if possible | |
| if audio_start_sec is None: | |
| audio_start_sec = 0 | |
| if audio_end_sec is not None and audio_end_sec > audio_start_sec: | |
| duration_sec = audio_end_sec - audio_start_sec | |
| elif duration_str: | |
| # Fallback to provided duration if end not given | |
| tmp = int(float(hms_to_seconds(duration_str))) | |
| duration_sec = tmp if tmp > 0 else None | |
| if duration_sec is None or duration_sec <= 0: | |
| return jsonify({"error": "Harap berikan rentang audio yang valid (audio_start < audio_end) atau sertakan duration"}), 400 | |
| else: | |
| # No audio window provided; duration is required | |
| if not duration_str: | |
| return jsonify({"error": "Parameter duration wajib diisi (detik atau HH:MM:SS)"}), 400 | |
| tmp = int(float(hms_to_seconds(duration_str))) | |
| duration_sec = tmp if tmp > 0 else None | |
| if duration_sec is None: | |
| return jsonify({"error": "Duration tidak valid"}), 400 | |
| width, height = get_resolution_params(resolution) | |
| if (fit in ['crop', 'pad']) and (resolution is not None) and (width is None or height is None): | |
| return jsonify({"error": "Format resolution tidak valid. Gunakan preset (mis. portrait) atau WIDTHxHEIGHT"}), 400 | |
| # Ensure even dimensions when provided | |
| width, height = normalize_even(width, height) | |
| # Save uploads to temporary paths | |
| temp_image_name = f"img_{uuid.uuid4().hex}" | |
| temp_audio_name = f"aud_{uuid.uuid4().hex}" | |
| # Try to infer extensions from filenames | |
| img_ext = os.path.splitext(image_file.filename or '')[1] or '.png' | |
| aud_ext = os.path.splitext(audio_file.filename or '')[1] or '.mp3' | |
| image_path = os.path.join(OUTPUT_DIR, temp_image_name + img_ext) | |
| audio_path = os.path.join(OUTPUT_DIR, temp_audio_name + aud_ext) | |
| try: | |
| image_file.save(image_path) | |
| audio_file.save(audio_path) | |
| except Exception as e: | |
| return jsonify({"error": f"Gagal menyimpan file upload: {str(e)}"}), 500 | |
| # Prepare output (pakai file temporary) | |
| import tempfile | |
| from flask import send_file | |
| temp_out = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') | |
| temp_out.close() | |
| output_path = temp_out.name | |
| # Build FFmpeg command | |
| ffmpeg_cmd = [ | |
| "ffmpeg", "-y", | |
| "-loop", "1", "-i", image_path, | |
| ] | |
| if audio_start_sec is not None: | |
| ffmpeg_cmd += ["-ss", str(audio_start_sec)] | |
| ffmpeg_cmd += ["-i", audio_path] | |
| stream_loop_audio = (audio_start_sec is None and audio_end_sec is None) | |
| if stream_loop_audio: | |
| pass | |
| ffmpeg_cmd += ["-r", "30"] | |
| if width and height: | |
| if fit == 'crop': | |
| vf = f"scale={width}:{height}:force_original_aspect_ratio=increase,crop={width}:{height}" | |
| else: | |
| vf = f"scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black" | |
| ffmpeg_cmd += ["-vf", vf] | |
| ffmpeg_cmd += [ | |
| "-t", str(duration_sec), | |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", | |
| "-c:a", "aac", "-b:a", "192k", | |
| "-shortest", | |
| "-movflags", "+faststart", | |
| output_path | |
| ] | |
| result = run_cmd(ffmpeg_cmd) | |
| # Cleanup uploads | |
| try: | |
| if os.path.exists(image_path): | |
| os.remove(image_path) | |
| if os.path.exists(audio_path): | |
| os.remove(audio_path) | |
| except Exception: | |
| pass | |
| if result.returncode != 0: | |
| try: | |
| os.remove(output_path) | |
| except Exception: | |
| pass | |
| return jsonify({ | |
| "error": "Gagal membuat video dari image dan audio", | |
| "details": result.stderr | |
| }), 500 | |
| if not os.path.exists(output_path) or os.path.getsize(output_path) == 0: | |
| try: | |
| os.remove(output_path) | |
| except Exception: | |
| pass | |
| return jsonify({"error": "File hasil kosong atau tidak ada"}), 500 | |
| response = send_file(output_path, as_attachment=True, download_name=f"image2video_{uuid.uuid4().hex}.mp4") | |
| def cleanup(): | |
| try: | |
| os.remove(output_path) | |
| except Exception: | |
| pass | |
| return response | |
| def image_to_video_url(): | |
| """Create a video from image_url + audio_url (JSON body). | |
| JSON fields: | |
| - image_url (required) | |
| - audio_url (required) | |
| - duration (required unless audio_start/audio_end provided) seconds or HH:MM:SS | |
| - audio_start (optional) seconds or HH:MM:SS | |
| - audio_end (optional) seconds or HH:MM:SS | |
| - resolution (optional) preset or WIDTHxHEIGHT | |
| - fit (optional) 'pad' (default) or 'crop' | |
| """ | |
| try: | |
| data = request.get_json(silent=True) or {} | |
| except Exception: | |
| data = {} | |
| # Support JSON body and/or query params as fallback | |
| image_url = (data.get('image_url') or request.args.get('image_url') or '').strip() | |
| audio_url = (data.get('audio_url') or request.args.get('audio_url') or '').strip() | |
| duration_str = data.get('duration') if 'duration' in data else request.args.get('duration') | |
| audio_start_str = data.get('audio_start') if 'audio_start' in data else request.args.get('audio_start') | |
| audio_end_str = data.get('audio_end') if 'audio_end' in data else request.args.get('audio_end') | |
| resolution = data.get('resolution') if 'resolution' in data else request.args.get('resolution') | |
| fit = (data.get('fit') if 'fit' in data else request.args.get('fit') or 'pad').lower() | |
| if not image_url or not audio_url: | |
| return jsonify({"error": "Harap kirim image_url dan audio_url"}), 400 | |
| # Resolve timing parameters | |
| audio_start_sec = None | |
| audio_end_sec = None | |
| duration_sec = None | |
| if audio_start_str: | |
| audio_start_sec = int(float(hms_to_seconds(str(audio_start_str)))) | |
| if audio_start_sec < 0: | |
| audio_start_sec = 0 | |
| if audio_end_str: | |
| audio_end_sec = int(float(hms_to_seconds(str(audio_end_str)))) | |
| if audio_end_sec < 0: | |
| audio_end_sec = 0 | |
| if audio_start_sec is not None or audio_end_sec is not None: | |
| if audio_start_sec is None: | |
| audio_start_sec = 0 | |
| if audio_end_sec is not None and audio_end_sec > audio_start_sec: | |
| duration_sec = audio_end_sec - audio_start_sec | |
| elif duration_str: | |
| tmp = int(float(hms_to_seconds(str(duration_str)))) | |
| duration_sec = tmp if tmp > 0 else None | |
| if duration_sec is None or duration_sec <= 0: | |
| return jsonify({"error": "Harap berikan rentang audio yang valid (audio_start < audio_end) atau sertakan duration"}), 400 | |
| else: | |
| if not duration_str: | |
| return jsonify({"error": "Parameter duration wajib diisi (detik atau HH:MM:SS)"}), 400 | |
| tmp = int(float(hms_to_seconds(str(duration_str)))) | |
| duration_sec = tmp if tmp > 0 else None | |
| if duration_sec is None: | |
| return jsonify({"error": "Duration tidak valid"}), 400 | |
| width, height = get_resolution_params(resolution) | |
| if (fit in ['crop', 'pad']) and (resolution is not None) and (width is None or height is None): | |
| return jsonify({"error": "Format resolution tidak valid. Gunakan preset (mis. portrait) atau WIDTHxHEIGHT"}), 400 | |
| width, height = normalize_even(width, height) | |
| # Download inputs | |
| try: | |
| image_path = download_url_to_file(image_url, '.png') | |
| audio_path = download_url_to_file(audio_url, '.mp3') | |
| except Exception as e: | |
| return jsonify({"error": f"Gagal download file dari URL: {str(e)}"}), 400 | |
| # Prepare output temp file | |
| temp_out = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') | |
| temp_out.close() | |
| output_path = temp_out.name | |
| # Build FFmpeg command (same as upload variant) | |
| ffmpeg_cmd = [ | |
| "ffmpeg", "-y", | |
| "-loop", "1", "-i", image_path, | |
| ] | |
| if audio_start_sec is not None: | |
| ffmpeg_cmd += ["-ss", str(audio_start_sec)] | |
| ffmpeg_cmd += ["-i", audio_path] | |
| ffmpeg_cmd += ["-r", "30"] | |
| if width and height: | |
| if fit == 'crop': | |
| vf = f"scale={width}:{height}:force_original_aspect_ratio=increase,crop={width}:{height}" | |
| else: | |
| vf = f"scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black" | |
| ffmpeg_cmd += ["-vf", vf] | |
| ffmpeg_cmd += [ | |
| "-t", str(duration_sec), | |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", | |
| "-c:a", "aac", "-b:a", "192k", | |
| "-shortest", | |
| "-movflags", "+faststart", | |
| output_path | |
| ] | |
| result = run_cmd(ffmpeg_cmd) | |
| # Cleanup downloads | |
| try: | |
| if os.path.exists(image_path): | |
| os.remove(image_path) | |
| if os.path.exists(audio_path): | |
| os.remove(audio_path) | |
| except Exception: | |
| pass | |
| if result.returncode != 0: | |
| try: | |
| os.remove(output_path) | |
| except Exception: | |
| pass | |
| return jsonify({ | |
| "error": "Gagal membuat video dari image dan audio", | |
| "details": result.stderr | |
| }), 500 | |
| if not os.path.exists(output_path) or os.path.getsize(output_path) == 0: | |
| try: | |
| os.remove(output_path) | |
| except Exception: | |
| pass | |
| return jsonify({"error": "File hasil kosong atau tidak ada"}), 500 | |
| from flask import send_file | |
| response = send_file(output_path, as_attachment=True, download_name=f"image2video_{uuid.uuid4().hex}.mp4") | |
| def cleanup(): | |
| try: | |
| os.remove(output_path) | |
| except Exception: | |
| pass | |
| return response | |
| def download_and_split(): | |
| """Download a full YouTube video and split it into fixed-duration parts. | |
| Query params: | |
| - url (required): YouTube URL | |
| - chunk (required): chunk duration in seconds or HH:MM:SS | |
| - resolution (optional): preset (portrait, etc.) or WIDTHxHEIGHT | |
| - fit (optional): 'pad' (default) or 'crop' when resolution is used | |
| """ | |
| url = request.args.get('url') | |
| chunk_str = request.args.get('chunk') | |
| resolution = request.args.get('resolution') | |
| fit = (request.args.get('fit') or 'pad').lower() | |
| if not url or not chunk_str: | |
| return jsonify({"error": "Harap masukkan url dan chunk (detik atau HH:MM:SS)"}), 400 | |
| # Parse chunk duration | |
| try: | |
| chunk_sec = int(float(hms_to_seconds(chunk_str))) | |
| except Exception: | |
| chunk_sec = 0 | |
| if chunk_sec <= 0: | |
| return jsonify({"error": "Nilai chunk tidak valid"}), 400 | |
| # Resolve resolution params | |
| width, height = get_resolution_params(resolution) | |
| if (fit in ['crop', 'pad']) and (width is None or height is None) and (resolution is not None): | |
| return jsonify({"error": "Format resolution tidak valid. Gunakan preset (mis. portrait) atau WIDTHxHEIGHT"}), 400 | |
| if (fit in ['crop', 'pad']) and (width is None and height is None) and (resolution is None) and request.args.get('fit') is not None: | |
| # fit diberikan tanpa resolution -> default portrait | |
| width, height = 1080, 1920 | |
| width, height = normalize_even(width, height) | |
| # Temp download path | |
| temp_filename = f"temp_{uuid.uuid4().hex}.mp4" | |
| temp_path = os.path.join(OUTPUT_DIR, temp_filename) | |
| try: | |
| # Try multiple format strategies to ensure we get video with audio | |
| ytdlp_strategies = [ | |
| ["yt-dlp", "-f", "best[ext=mp4]/best", "-o", temp_path, "--no-playlist", url], | |
| ["yt-dlp", "-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio", "--merge-output-format", "mp4", "-o", temp_path, "--no-playlist", url], | |
| ["yt-dlp", "-f", "best", "-o", temp_path, "--no-playlist", url], | |
| ] | |
| ytdlp_success = False | |
| for strategy in ytdlp_strategies: | |
| ytdlp_result = run_cmd(strategy) | |
| if ytdlp_result.returncode == 0 and os.path.exists(temp_path) and os.path.getsize(temp_path) > 0: | |
| ytdlp_success = True | |
| break | |
| if not ytdlp_success: | |
| return jsonify({ | |
| "error": "Gagal download video", | |
| "details": "Semua strategi download gagal" | |
| }), 500 | |
| # Probe total duration | |
| total_duration = probe_media_duration_seconds(temp_path) | |
| if not total_duration or total_duration <= 0: | |
| return jsonify({"error": "Gagal membaca durasi video"}), 500 | |
| # Compute chunks | |
| parts = [] | |
| num_parts = int((total_duration + chunk_sec - 1) // chunk_sec) | |
| base_id = uuid.uuid4().hex | |
| for idx in range(num_parts): | |
| start_sec = idx * chunk_sec | |
| remaining = max(0, int(total_duration - start_sec)) | |
| if remaining <= 0: | |
| break | |
| current_dur = min(chunk_sec, remaining) | |
| out_name = f"{base_id}_{idx + 1:02d}.mp4" | |
| out_path = os.path.join(OUTPUT_DIR, out_name) | |
| ffmpeg_cmd = [ | |
| "ffmpeg", "-y", | |
| "-ss", str(start_sec), | |
| "-i", temp_path, | |
| "-t", str(current_dur), | |
| ] | |
| # Add scaling/pad/crop if requested | |
| if width and height: | |
| if fit == 'crop': | |
| vf = f"scale={width}:{height}:force_original_aspect_ratio=increase,crop={width}:{height}" | |
| else: | |
| vf = f"scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black" | |
| ffmpeg_cmd += ["-vf", vf] | |
| ffmpeg_cmd += [ | |
| "-c:v", "libx264", "-c:a", "aac", | |
| "-preset", "fast", "-crf", "23", | |
| "-movflags", "+faststart", | |
| out_path | |
| ] | |
| result = run_cmd(ffmpeg_cmd) | |
| if result.returncode != 0 or (not os.path.exists(out_path)) or os.path.getsize(out_path) == 0: | |
| # Cleanup already created parts on failure | |
| for p in parts: | |
| try: | |
| os.remove(os.path.join(OUTPUT_DIR, p["filename"])) | |
| except Exception: | |
| pass | |
| return jsonify({ | |
| "error": "Gagal memotong salah satu bagian", | |
| "details": result.stderr | |
| }), 500 | |
| part_info = { | |
| "index": idx + 1, | |
| "start": start_sec, | |
| "duration": current_dur, | |
| "filename": out_name, | |
| "file_url": f"http://localhost:5000/{OUTPUT_DIR}/{out_name}", | |
| "size": os.path.getsize(out_path) | |
| } | |
| parts.append(part_info) | |
| # Success response | |
| return jsonify({ | |
| "status": "success", | |
| "total_duration": int(total_duration), | |
| "chunk_seconds": chunk_sec, | |
| "total_parts": len(parts), | |
| "files": parts | |
| }) | |
| except Exception as e: | |
| return jsonify({"error": f"Terjadi kesalahan: {str(e)}"}), 500 | |
| finally: | |
| try: | |
| if os.path.exists(temp_path): | |
| os.remove(temp_path) | |
| except Exception: | |
| pass | |
| def download_segment(): | |
| url = request.args.get('url') | |
| start = request.args.get('start') # HH:MM:SS | |
| end = request.args.get('end') # HH:MM:SS | |
| resolution = request.args.get('resolution') # portrait, portrait_hd, 720x1280, etc. | |
| fit = (request.args.get('fit') or 'pad').lower() # 'crop' atau 'pad' (default) | |
| if not url or not start or not end: | |
| return jsonify({"error": "Harap masukkan url, start, dan end"}), 400 | |
| # Validate time format | |
| start_sec = hms_to_seconds(start) | |
| end_sec = hms_to_seconds(end) | |
| if start_sec >= end_sec: | |
| return jsonify({"error": "Waktu end harus lebih besar dari start"}), 400 | |
| # Get resolution parameters | |
| width, height = get_resolution_params(resolution) | |
| # Jika user set fit tapi tidak set resolution, default ke 1080x1920 | |
| if (fit in ['crop', 'pad']) and (width is None or height is None) and (resolution is not None): | |
| # resolution diberikan tapi tidak valid -> error | |
| return jsonify({"error": "Format resolution tidak valid. Gunakan preset (mis. portrait) atau WIDTHxHEIGHT"}), 400 | |
| if (fit in ['crop', 'pad']) and (width is None and height is None) and (resolution is None): | |
| # fit tanpa resolution -> pakai default portrait | |
| width, height = 1080, 1920 | |
| # Pastikan genap | |
| width, height = normalize_even(width, height) | |
| filename = f"{uuid.uuid4().hex}.mp4" | |
| output_path = os.path.join(OUTPUT_DIR, filename) | |
| try: | |
| # Step 1: Download video with yt-dlp to temporary file | |
| temp_filename = f"temp_{uuid.uuid4().hex}.mp4" | |
| temp_path = os.path.join(OUTPUT_DIR, temp_filename) | |
| # Try multiple format strategies to ensure we get video with audio | |
| ytdlp_strategies = [ | |
| # Strategy 1: Best progressive format | |
| ["yt-dlp", "-f", "best[ext=mp4]/best", "-o", temp_path, "--no-playlist", url], | |
| # Strategy 2: Best video + best audio | |
| ["yt-dlp", "-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio", "--merge-output-format", "mp4", "-o", temp_path, "--no-playlist", url], | |
| # Strategy 3: Any format that works | |
| ["yt-dlp", "-f", "best", "-o", temp_path, "--no-playlist", url] | |
| ] | |
| ytdlp_success = False | |
| for strategy in ytdlp_strategies: | |
| ytdlp_result = run_cmd(strategy) | |
| if ytdlp_result.returncode == 0 and os.path.exists(temp_path) and os.path.getsize(temp_path) > 0: | |
| ytdlp_success = True | |
| break | |
| if not ytdlp_success: | |
| return jsonify({ | |
| "error": "Gagal download video", | |
| "details": "Semua strategi download gagal" | |
| }), 500 | |
| # Step 2: Cut video segment with ffmpeg | |
| duration = end_sec - start_sec | |
| # Build ffmpeg command based on resolution requirements | |
| ffmpeg_cmd = [ | |
| "ffmpeg", "-y", | |
| "-ss", str(start_sec), | |
| "-i", temp_path, | |
| "-t", str(duration), | |
| ] | |
| # Add resolution scaling if specified | |
| if width and height: | |
| if fit == 'crop': | |
| # Isi bingkai lalu crop ke target (tanpa black bars) | |
| vf = f"scale={width}:{height}:force_original_aspect_ratio=increase,crop={width}:{height}" | |
| else: | |
| # Sesuaikan dalam bingkai lalu pad (dengan black bars bila perlu) | |
| vf = f"scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black" | |
| ffmpeg_cmd.extend([ | |
| "-vf", vf, | |
| "-c:v", "libx264", | |
| "-c:a", "aac", | |
| "-preset", "fast", | |
| "-crf", "23", | |
| "-movflags", "+faststart", | |
| output_path | |
| ]) | |
| else: | |
| # Default encoding without resolution change | |
| ffmpeg_cmd.extend([ | |
| "-c:v", "libx264", | |
| "-c:a", "aac", | |
| "-preset", "fast", | |
| "-crf", "23", | |
| "-movflags", "+faststart", | |
| output_path | |
| ]) | |
| ffmpeg_result = run_cmd(ffmpeg_cmd) | |
| if ffmpeg_result.returncode != 0: | |
| # Clean up temp file | |
| try: | |
| os.remove(temp_path) | |
| except: | |
| pass | |
| return jsonify({ | |
| "error": "Gagal memotong video", | |
| "details": ffmpeg_result.stderr | |
| }), 500 | |
| # Clean up temp file | |
| try: | |
| os.remove(temp_path) | |
| except: | |
| pass | |
| # Check if output file exists and has content | |
| if not os.path.exists(output_path) or os.path.getsize(output_path) == 0: | |
| return jsonify({"error": "File hasil kosong atau tidak ada"}), 500 | |
| file_url = f"http://localhost:5000/{OUTPUT_DIR}/{filename}" | |
| response_data = { | |
| "status": "success", | |
| "file_url": file_url, | |
| "filename": filename, | |
| "size": os.path.getsize(output_path) | |
| } | |
| if width and height: | |
| response_data["resolution"] = f"{width}x{height}" | |
| response_data["fit"] = fit | |
| return jsonify(response_data) | |
| except Exception as e: | |
| return jsonify({"error": f"Terjadi kesalahan: {str(e)}"}), 500 | |
| # Serve file statis dengan send_from_directory | |
| def serve_file(filename): | |
| try: | |
| return send_from_directory(OUTPUT_DIR, filename) | |
| except FileNotFoundError: | |
| return jsonify({"error": "File tidak ditemukan"}), 404 | |
| def index(): | |
| return """ | |
| <h1>YouTube Video Segment Downloader</h1> | |
| <p>Gunakan endpoint /download dengan parameter:</p> | |
| <ul> | |
| <li>url: URL YouTube video</li> | |
| <li>start: Waktu mulai (HH:MM:SS)</li> | |
| <li>end: Waktu selesai (HH:MM:SS)</li> | |
| <li>resolution: Resolusi output (opsional)</li> | |
| <li>fit: 'pad' (default) atau 'crop' untuk isi penuh tanpa black bars</li> | |
| </ul> | |
| <h2>Resolusi Portrait yang Tersedia:</h2> | |
| <ul> | |
| <li><code>portrait</code> - 1080x1920 (9:16, TikTok/Story)</li> | |
| <li><code>portrait_hd</code> - 720x1280 (9:16 HD)</li> | |
| <li><code>portrait_sd</code> - 480x854 (9:16 SD)</li> | |
| <li><code>square</code> - 1080x1080 (1:1, Instagram)</li> | |
| <li><code>instagram</code> - 1080x1350 (4:5, Instagram)</li> | |
| <li><code>tiktok</code> - 1080x1920 (9:16, TikTok)</li> | |
| <li><code>story</code> - 1080x1920 (9:16, Story)</li> | |
| <li><code>custom</code> - Format: WIDTHxHEIGHT (contoh: 720x1280)</li> | |
| </ul> | |
| <h2>Contoh:</h2> | |
| <p><code>/download?url=https://youtube.com/watch?v=VIDEO_ID&start=00:00:10&end=00:00:30&resolution=portrait&fit=crop</code></p> | |
| <p><code>/download?url=https://youtube.com/watch?v=VIDEO_ID&start=00:00:10&end=00:00:30&resolution=720x1280&fit=pad</code></p> | |
| """ | |
| # Download full YouTube video and return the video file directly | |
| def download_direct(): | |
| # Samakan parameter dengan endpoint /download | |
| url = request.args.get('url') | |
| start = request.args.get('start') # HH:MM:SS | |
| end = request.args.get('end') # HH:MM:SS | |
| resolution = request.args.get('resolution') # portrait, portrait_hd, 720x1280, etc. | |
| fit = (request.args.get('fit') or 'pad').lower() # 'crop' atau 'pad' (default) | |
| if not url or not start or not end: | |
| return jsonify({"error": "Harap masukkan url, start, dan end"}), 400 | |
| # Validate time format | |
| start_sec = hms_to_seconds(start) | |
| end_sec = hms_to_seconds(end) | |
| if start_sec >= end_sec: | |
| return jsonify({"error": "Waktu end harus lebih besar dari start"}), 400 | |
| # Get resolution parameters | |
| width, height = get_resolution_params(resolution) | |
| if (fit in ['crop', 'pad']) and (width is None or height is None) and (resolution is not None): | |
| return jsonify({"error": "Format resolution tidak valid. Gunakan preset (mis. portrait) atau WIDTHxHEIGHT"}), 400 | |
| if (fit in ['crop', 'pad']) and (width is None and height is None) and (resolution is None): | |
| width, height = 1080, 1920 | |
| width, height = normalize_even(width, height) | |
| import tempfile | |
| from flask import send_file | |
| temp_ytdlp = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') | |
| temp_ffmpeg = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') | |
| temp_ytdlp.close() | |
| temp_ffmpeg.close() | |
| temp_path = temp_ytdlp.name | |
| output_path = temp_ffmpeg.name | |
| try: | |
| # Download source | |
| strategies = [ | |
| ["yt-dlp", "--force-overwrites", "--merge-output-format", "mp4", "-o", temp_path, "--no-playlist", url], | |
| ["yt-dlp", "--force-overwrites", "-f", "b", "--merge-output-format", "mp4", "-o", temp_path, "--no-playlist", url], | |
| ["yt-dlp", "--force-overwrites", "-f", "bv*+ba/best", "--merge-output-format", "mp4", "-o", temp_path, "--no-playlist", url], | |
| ] | |
| ok = False | |
| last_err = "" | |
| for cmd in strategies: | |
| res = run_cmd(cmd) | |
| if res.returncode == 0 and os.path.exists(temp_path) and os.path.getsize(temp_path) > 0: | |
| ok = True | |
| break | |
| last_err = res.stderr or res.stdout or "Unknown error" | |
| if not ok: | |
| if os.path.exists(temp_path): | |
| os.remove(temp_path) | |
| if os.path.exists(output_path): | |
| os.remove(output_path) | |
| return jsonify({"error": "Gagal download video", "details": last_err[-2000:]}), 500 | |
| # Cut and optionally scale | |
| duration = end_sec - start_sec | |
| ffmpeg_cmd = [ | |
| "ffmpeg", "-y", | |
| "-ss", str(start_sec), | |
| "-i", temp_path, | |
| "-t", str(duration), | |
| ] | |
| if width and height: | |
| if fit == 'crop': | |
| vf = f"scale={width}:{height}:force_original_aspect_ratio=increase,crop={width}:{height}" | |
| else: | |
| vf = f"scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black" | |
| ffmpeg_cmd += [ | |
| "-vf", vf, | |
| "-c:v", "libx264", | |
| "-c:a", "aac", | |
| "-preset", "fast", | |
| "-crf", "23", | |
| "-movflags", "+faststart", | |
| output_path | |
| ] | |
| else: | |
| ffmpeg_cmd += [ | |
| "-c:v", "libx264", | |
| "-c:a", "aac", | |
| "-preset", "fast", | |
| "-crf", "23", | |
| "-movflags", "+faststart", | |
| output_path | |
| ] | |
| enc_res = run_cmd(ffmpeg_cmd) | |
| if os.path.exists(temp_path): | |
| os.remove(temp_path) | |
| if enc_res.returncode != 0 or not os.path.exists(output_path) or os.path.getsize(output_path) == 0: | |
| if os.path.exists(output_path): | |
| os.remove(output_path) | |
| return jsonify({"error": "Gagal memproses video", "details": enc_res.stderr[-2000:] if enc_res.stderr else ""}), 500 | |
| # Return final cut/scaled file as attachment, then delete after send | |
| response = send_file(output_path, as_attachment=True, download_name=f"video_{uuid.uuid4().hex}.mp4") | |
| def cleanup(): | |
| if os.path.exists(output_path): | |
| os.remove(output_path) | |
| return response | |
| except Exception as e: | |
| if os.path.exists(temp_path): | |
| os.remove(temp_path) | |
| if os.path.exists(output_path): | |
| os.remove(output_path) | |
| return jsonify({"error": f"Terjadi kesalahan: {str(e)}"}), 500 | |
| if __name__ == '__main__': | |
| import os | |
| port = int(os.environ.get("PORT", 7860)) | |
| app.run(host="0.0.0.0", port=port, debug=False) | |