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)) @app.route('/image-to-video', methods=['POST']) 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") @response.call_on_close def cleanup(): try: os.remove(output_path) except Exception: pass return response @app.route('/image-to-video-url', methods=['POST']) 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") @response.call_on_close def cleanup(): try: os.remove(output_path) except Exception: pass return response @app.route('/download-split', methods=['GET']) 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 @app.route('/download', methods=['GET']) 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 @app.route(f'/{OUTPUT_DIR}/') def serve_file(filename): try: return send_from_directory(OUTPUT_DIR, filename) except FileNotFoundError: return jsonify({"error": "File tidak ditemukan"}), 404 @app.route('/') def index(): return """

YouTube Video Segment Downloader

Gunakan endpoint /download dengan parameter:

Resolusi Portrait yang Tersedia:

Contoh:

/download?url=https://youtube.com/watch?v=VIDEO_ID&start=00:00:10&end=00:00:30&resolution=portrait&fit=crop

/download?url=https://youtube.com/watch?v=VIDEO_ID&start=00:00:10&end=00:00:30&resolution=720x1280&fit=pad

""" # Download full YouTube video and return the video file directly @app.route('/download-direct', methods=['GET']) 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") @response.call_on_close 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)