ffmpeg / app.py
abangopera's picture
update
c1ef87c verified
Raw
History Blame Contribute Delete
33.9 kB
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}/<path:filename>')
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 """
<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
@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)