Download app.py from Kompres/Web: direct link, hf CLI and curl.
- Browser
- Download file 44.9 kB
-
https://huggingface.co/spaces/Kompres/Web/resolve/main/app.py
- Command line
-
hf download hf://spaces/Kompres/Web/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Kompres/Web/resolve/main/app.py
44.9 kB
| """ | |
| Kompres - Kompresi dan optimasi video untuk platform sosial media. | |
| Mendukung WhatsApp, Instagram, Telegram dengan encoding H.264 High Profile | |
| dan kalibrasi warna BT.709. | |
| Copyright (C) 2026 Garden | |
| Licensed under GNU General Public License v3.0 | |
| """ | |
| import streamlit as st | |
| import ffmpeg | |
| import os | |
| import tempfile | |
| import pathlib | |
| import math | |
| import subprocess | |
| import re | |
| import atexit | |
| import base64 | |
| import json | |
| import time | |
| import uuid | |
| APP_VERSION = "7.0.0" | |
| APP_TITLE = "Kompres" | |
| APP_TAGLINE = "Kompresi Video Profesional" | |
| SUPPORTED_FORMATS = ["mp4", "mov", "mkv", "avi", "webm"] | |
| OUTPUT_FORMATS = {"MP4 (H.264)": "mp4", "WebM (VP9)": "webm", "GIF Animasi": "gif"} | |
| FFMPEG_THREADS = 0 | |
| SESSION_DIR = "/tmp/kompres_sessions" | |
| SESSION_MAX_AGE = 3600 | |
| _temp_files = [] | |
| RESOLUTION_MAP = { | |
| "1080p": 1080, | |
| "720p": 720, | |
| "480p": 480, | |
| "360p": 360, | |
| } | |
| SPEED_OPTIONS = ["ultrafast", "fast", "medium", "slow", "veryslow"] | |
| FRAMERATE_OPTIONS = { | |
| "Bawaan": None, | |
| "60 FPS": 60, | |
| "30 FPS": 30, | |
| "24 FPS": 24, | |
| } | |
| ASPECT_RATIOS = { | |
| "Bawaan": None, | |
| "16:9 Landscape": "16:9", | |
| "9:16 Portrait": "9:16", | |
| "1:1 Square": "1:1", | |
| "4:3 Klasik": "4:3", | |
| } | |
| PLATFORM_PRESETS = { | |
| "Custom": { | |
| "crf": 28, | |
| "preset": "medium", | |
| "resolution": "720p", | |
| "max_bitrate": None, | |
| "fps": None, | |
| "aspect": None, | |
| "description": "Atur semua parameter secara manual.", | |
| }, | |
| "WhatsApp": { | |
| "crf": 28, | |
| "preset": "medium", | |
| "resolution": "720p", | |
| "max_bitrate": "2M", | |
| "fps": 30, | |
| "aspect": None, | |
| "description": "Optimal untuk pengiriman via WhatsApp.", | |
| }, | |
| "Instagram Feed": { | |
| "crf": 23, | |
| "preset": "slow", | |
| "resolution": "1080p", | |
| "max_bitrate": "3.5M", | |
| "fps": 30, | |
| "aspect": "1:1", | |
| "description": "Square format untuk feed Instagram.", | |
| }, | |
| "Instagram Story": { | |
| "crf": 23, | |
| "preset": "slow", | |
| "resolution": "1080p", | |
| "max_bitrate": "4M", | |
| "fps": 30, | |
| "aspect": "9:16", | |
| "description": "Format vertikal untuk Story dan Reels.", | |
| }, | |
| "Telegram": { | |
| "crf": 26, | |
| "preset": "medium", | |
| "resolution": "720p", | |
| "max_bitrate": None, | |
| "fps": None, | |
| "aspect": None, | |
| "description": "Kualitas dan ukuran seimbang untuk Telegram.", | |
| }, | |
| "Email": { | |
| "crf": 32, | |
| "preset": "slow", | |
| "resolution": "480p", | |
| "max_bitrate": "1M", | |
| "fps": 24, | |
| "aspect": None, | |
| "description": "Ukuran file minimal untuk lampiran email.", | |
| }, | |
| } | |
| COLOR_PROFILE = { | |
| "pix_fmt": "yuv420p", | |
| "colorspace": "bt709", | |
| "color_primaries": "bt709", | |
| "color_trc": "bt709", | |
| "color_range": "tv", | |
| } | |
| PAGE_STYLES = """ | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); | |
| html, body, [class*="st-"] { | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; | |
| } | |
| .main .block-container { | |
| max-width: 780px; | |
| padding-top: 2rem; | |
| padding-bottom: 4rem; | |
| } | |
| header[data-testid="stHeader"] { | |
| background: rgba(14, 17, 23, 0.94); | |
| backdrop-filter: blur(16px); | |
| -webkit-backdrop-filter: blur(16px); | |
| } | |
| .app-hero { | |
| text-align: center; | |
| padding: 2rem 0 1.5rem; | |
| } | |
| .app-hero .brand { | |
| font-size: 2.6rem; | |
| font-weight: 800; | |
| letter-spacing: -0.03em; | |
| background: linear-gradient(135deg, #a78bfa 0%, #7c3aed 40%, #ec4899 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| margin-bottom: 0.2rem; | |
| } | |
| .app-hero .tagline { | |
| color: #94a3b8; | |
| font-size: 0.95rem; | |
| font-weight: 400; | |
| margin: 0; | |
| } | |
| .app-hero .version-badge { | |
| display: inline-block; | |
| background: rgba(124, 58, 237, 0.15); | |
| color: #a78bfa; | |
| font-size: 0.7rem; | |
| font-weight: 600; | |
| padding: 0.2rem 0.6rem; | |
| border-radius: 20px; | |
| margin-top: 0.5rem; | |
| letter-spacing: 0.04em; | |
| } | |
| .preset-info { | |
| background: rgba(99, 102, 241, 0.08); | |
| border-left: 3px solid #7c3aed; | |
| padding: 0.7rem 1rem; | |
| border-radius: 0 8px 8px 0; | |
| margin: 0.5rem 0; | |
| font-size: 0.85rem; | |
| color: #cbd5e1; | |
| } | |
| .result-panel { | |
| background: linear-gradient(145deg, rgba(16, 185, 129, 0.1), rgba(5, 150, 105, 0.03)); | |
| border: 1px solid rgba(16, 185, 129, 0.2); | |
| border-radius: 14px; | |
| padding: 1.4rem; | |
| margin: 1rem 0; | |
| } | |
| .result-panel h3 { | |
| color: #34d399; | |
| font-weight: 700; | |
| margin-bottom: 0.3rem; | |
| font-size: 1.15rem; | |
| } | |
| .result-panel .result-stats { | |
| color: #cbd5e1; | |
| font-size: 0.92rem; | |
| } | |
| .reduction-badge { | |
| display: inline-block; | |
| background: rgba(16, 185, 129, 0.2); | |
| color: #34d399; | |
| font-weight: 700; | |
| padding: 0.2rem 0.7rem; | |
| border-radius: 20px; | |
| font-size: 0.85rem; | |
| } | |
| .section-title { | |
| font-size: 0.85rem; | |
| font-weight: 700; | |
| color: #94a3b8; | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| margin: 1.2rem 0 0.6rem; | |
| padding-bottom: 0.4rem; | |
| border-bottom: 1px solid rgba(148, 163, 184, 0.1); | |
| } | |
| .footer-section { | |
| text-align: center; | |
| padding: 2rem 0 1rem; | |
| color: #64748b; | |
| font-size: 0.82rem; | |
| border-top: 1px solid rgba(148, 163, 184, 0.08); | |
| margin-top: 3rem; | |
| } | |
| .footer-section a { | |
| color: #a78bfa; | |
| text-decoration: none; | |
| font-weight: 500; | |
| } | |
| .footer-section a:hover { | |
| text-decoration: underline; | |
| } | |
| div[data-testid="stFileUploader"] { | |
| border: 2px dashed rgba(124, 58, 237, 0.35); | |
| border-radius: 16px; | |
| padding: 1.5rem 1rem; | |
| transition: all 0.3s ease; | |
| background: rgba(124, 58, 237, 0.04); | |
| min-height: 120px; | |
| } | |
| div[data-testid="stFileUploader"]:hover { | |
| border-color: rgba(124, 58, 237, 0.6); | |
| background: rgba(124, 58, 237, 0.08); | |
| box-shadow: 0 0 24px rgba(124, 58, 237, 0.12); | |
| } | |
| div[data-testid="stFileUploader"] section > button { | |
| background: linear-gradient(135deg, #7c3aed, #a78bfa) !important; | |
| color: white !important; | |
| border: none !important; | |
| border-radius: 8px !important; | |
| font-weight: 600 !important; | |
| } | |
| .stButton > button { | |
| width: 100%; | |
| background: linear-gradient(135deg, #7c3aed, #6d28d9); | |
| color: white; | |
| border: none; | |
| border-radius: 10px; | |
| padding: 0.8rem 1.5rem; | |
| font-weight: 600; | |
| font-size: 1rem; | |
| letter-spacing: 0.01em; | |
| transition: all 0.25s ease; | |
| box-shadow: 0 4px 16px rgba(109, 40, 217, 0.3); | |
| } | |
| .stButton > button:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 8px 24px rgba(109, 40, 217, 0.45); | |
| } | |
| .stButton > button:active { | |
| transform: translateY(0); | |
| } | |
| div[data-testid="stDownloadButton"] > button { | |
| width: 100%; | |
| background: linear-gradient(135deg, #059669, #047857); | |
| color: white; | |
| border: none; | |
| border-radius: 10px; | |
| padding: 0.75rem; | |
| font-weight: 600; | |
| transition: all 0.25s ease; | |
| box-shadow: 0 4px 14px rgba(5, 150, 105, 0.25); | |
| } | |
| div[data-testid="stDownloadButton"] > button:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 8px 20px rgba(5, 150, 105, 0.4); | |
| } | |
| </style> | |
| """ | |
| def format_filesize(size_bytes): | |
| if size_bytes == 0: | |
| return "0 B" | |
| units = ["B", "KB", "MB", "GB"] | |
| exp = min(int(math.log(size_bytes, 1024)), len(units) - 1) | |
| value = size_bytes / (1024 ** exp) | |
| return f"{value:.2f} {units[exp]}" | |
| def format_duration(seconds): | |
| total = int(seconds) | |
| hours, remainder = divmod(total, 3600) | |
| minutes, secs = divmod(remainder, 60) | |
| if hours > 0: | |
| return f"{hours:02d}:{minutes:02d}:{secs:02d}" | |
| return f"{minutes:02d}:{secs:02d}" | |
| def calculate_reduction(original, compressed): | |
| if original == 0: | |
| return 0.0 | |
| return ((original - compressed) / original) * 100 | |
| def get_clean_filename(uploaded_name, out_format="mp4"): | |
| p = pathlib.Path(uploaded_name) | |
| return "kompres_" + p.stem + "." + out_format | |
| def estimate_output_size(duration, crf, resolution, has_audio, out_format="mp4"): | |
| """Estimasi kasar ukuran output berdasarkan parameter.""" | |
| res_bitrate = {"1080p": 4000, "720p": 2000, "480p": 1000, "360p": 600} | |
| base_kbps = res_bitrate.get(resolution, 2500) | |
| crf_factor = 2.0 ** ((28 - crf) / 6.0) | |
| video_kbps = base_kbps * crf_factor | |
| if out_format == "gif": | |
| video_kbps = video_kbps * 3 | |
| elif out_format == "webm": | |
| video_kbps = video_kbps * 0.85 | |
| audio_kbps = 96 if has_audio and out_format != "gif" else 0 | |
| total_kbps = video_kbps + audio_kbps | |
| size_bytes = (total_kbps * duration * 1000) / 8 | |
| return max(int(size_bytes), 0) | |
| def calculate_target_bitrate(target_mb, duration, has_audio): | |
| """Hitung bitrate video untuk mencapai target ukuran file.""" | |
| if duration <= 0: | |
| return None | |
| target_bits = target_mb * 8 * 1024 * 1024 | |
| audio_bits = 96 * 1000 * duration if has_audio else 0 | |
| video_bits = target_bits - audio_bits | |
| if video_bits <= 0: | |
| return None | |
| video_kbps = int(video_bits / duration / 1000) | |
| return str(video_kbps) + "k" | |
| def extract_frame(video_path, timestamp=1.0): | |
| """Ambil satu frame dari video dan kembalikan sebagai base64 JPEG.""" | |
| try: | |
| out_path = video_path + "_thumb.jpg" | |
| _temp_files.append(out_path) | |
| ( | |
| ffmpeg | |
| .input(video_path, ss=timestamp) | |
| .output(out_path, vframes=1, format="image2", **{"q:v": 2}) | |
| .overwrite_output() | |
| .run(capture_stdout=True, capture_stderr=True) | |
| ) | |
| if os.path.exists(out_path): | |
| with open(out_path, "rb") as f: | |
| return base64.b64encode(f.read()).decode("utf-8") | |
| except Exception: | |
| pass | |
| return None | |
| def probe_video(file_path): | |
| try: | |
| info = ffmpeg.probe(file_path, analyzeduration="5000000", probesize="5000000") | |
| video_stream = next( | |
| (s for s in info.get("streams", []) if s.get("codec_type") == "video"), | |
| None, | |
| ) | |
| audio_stream = next( | |
| (s for s in info.get("streams", []) if s.get("codec_type") == "audio"), | |
| None, | |
| ) | |
| duration = float(info.get("format", {}).get("duration", 0)) | |
| bitrate = int(info.get("format", {}).get("bit_rate", 0)) | |
| result = { | |
| "duration": duration, | |
| "duration_text": format_duration(duration), | |
| "bitrate": bitrate, | |
| "bitrate_text": (str(bitrate // 1000) + " kbps") if bitrate else "N/A", | |
| "has_audio": audio_stream is not None, | |
| } | |
| if video_stream: | |
| fps_parts = video_stream.get("r_frame_rate", "0/1").split("/") | |
| if len(fps_parts) == 2 and int(fps_parts[1]) > 0: | |
| fps = round(int(fps_parts[0]) / int(fps_parts[1]), 1) | |
| else: | |
| fps = 0 | |
| result["width"] = int(video_stream.get("width", 0)) | |
| result["height"] = int(video_stream.get("height", 0)) | |
| result["codec"] = video_stream.get("codec_name", "unknown").upper() | |
| result["fps"] = fps | |
| result["resolution_text"] = str(video_stream.get("width", 0)) + "x" + str(video_stream.get("height", 0)) | |
| if audio_stream: | |
| result["audio_codec"] = audio_stream.get("codec_name", "unknown").upper() | |
| ab = audio_stream.get("bit_rate", 0) | |
| result["audio_bitrate"] = int(ab) // 1000 if ab else 0 | |
| return result | |
| except Exception: | |
| return None | |
| def compress_video( | |
| input_path, | |
| output_path, | |
| crf, | |
| preset, | |
| mute_audio, | |
| resolution, | |
| trim_start=None, | |
| trim_end=None, | |
| target_fps=None, | |
| aspect_ratio=None, | |
| max_bitrate=None, | |
| progress_callback=None, | |
| duration_seconds=0, | |
| out_format="mp4", | |
| ): | |
| try: | |
| input_args = {} | |
| if trim_start is not None and trim_start > 0: | |
| input_args["ss"] = trim_start | |
| if trim_end is not None and trim_end > 0: | |
| input_args["to"] = trim_end | |
| source = ffmpeg.input(input_path, **input_args) | |
| video = source.video | |
| audio = source.audio | |
| if resolution in RESOLUTION_MAP: | |
| target_h = RESOLUTION_MAP[resolution] | |
| video = ffmpeg.filter(video, "scale", "trunc(oh*a/2)*2", target_h) | |
| else: | |
| video = ffmpeg.filter(video, "scale", "trunc(iw/2)*2", "trunc(ih/2)*2") | |
| if aspect_ratio: | |
| ratio_map = {"16:9": "16/9", "9:16": "9/16", "1:1": "1", "4:3": "4/3"} | |
| if aspect_ratio in ratio_map: | |
| r = ratio_map[aspect_ratio] | |
| video = ffmpeg.filter( | |
| video, "crop", | |
| "if(gt(iw/ih," + r + "),ih*" + r + ",iw)", | |
| "if(gt(iw/ih," + r + "),ih,iw/(" + r + "))", | |
| ) | |
| video = ffmpeg.filter(video, "scale", "trunc(iw/2)*2", "trunc(ih/2)*2") | |
| if target_fps: | |
| video = ffmpeg.filter(video, "fps", fps=target_fps) | |
| # --- GIF output --- | |
| if out_format == "gif": | |
| if not target_fps: | |
| video = ffmpeg.filter(video, "fps", fps=15) | |
| palette_path = output_path + "_palette.png" | |
| _temp_files.append(palette_path) | |
| palette_gen = ffmpeg.output(video, palette_path, vf="palettegen=stats_mode=diff", an=None) | |
| ffmpeg.run(palette_gen, overwrite_output=True, capture_stdout=True, capture_stderr=True) | |
| source2 = ffmpeg.input(input_path, **input_args) | |
| vid2 = source2.video | |
| if resolution in RESOLUTION_MAP: | |
| vid2 = ffmpeg.filter(vid2, "scale", "trunc(oh*a/2)*2", RESOLUTION_MAP[resolution]) | |
| if target_fps: | |
| vid2 = ffmpeg.filter(vid2, "fps", fps=target_fps) | |
| else: | |
| vid2 = ffmpeg.filter(vid2, "fps", fps=15) | |
| palette_in = ffmpeg.input(palette_path) | |
| gif_out = ffmpeg.filter([vid2, palette_in], "paletteuse", dither="bayer", bayer_scale=3) | |
| output = ffmpeg.output(gif_out, output_path, an=None, loop=0) | |
| ffmpeg.run(output, overwrite_output=True, capture_stdout=True, capture_stderr=True) | |
| return True, None | |
| # --- WebM VP9 output --- | |
| if out_format == "webm": | |
| encoding_params = { | |
| "vcodec": "libvpx-vp9", | |
| "crf": crf, | |
| "b:v": "0", | |
| "threads": FFMPEG_THREADS, | |
| "row-mt": 1, | |
| } | |
| if max_bitrate: | |
| encoding_params["b:v"] = max_bitrate | |
| if mute_audio: | |
| output = ffmpeg.output(video, output_path, an=None, **encoding_params) | |
| else: | |
| audio_params = {"c:a": "libopus", "b:a": "96k", "ac": 2} | |
| output = ffmpeg.output(audio, video, output_path, **audio_params, **encoding_params) | |
| else: | |
| # --- MP4 H.264 output --- | |
| encoding_params = { | |
| "vcodec": "libx264", | |
| "crf": crf, | |
| "preset": preset, | |
| "movflags": "+faststart", | |
| "profile:v": "high", | |
| "tune": "film", | |
| "threads": FFMPEG_THREADS, | |
| } | |
| encoding_params.update(COLOR_PROFILE) | |
| if max_bitrate: | |
| encoding_params["maxrate"] = max_bitrate | |
| encoding_params["bufsize"] = max_bitrate | |
| if mute_audio: | |
| output = ffmpeg.output(video, output_path, an=None, **encoding_params) | |
| else: | |
| audio_params = {"c:a": "aac", "b:a": "96k", "ac": 2} | |
| output = ffmpeg.output(audio, video, output_path, **audio_params, **encoding_params) | |
| cmd = ffmpeg.compile(output, overwrite_output=True) | |
| if progress_callback and duration_seconds > 0: | |
| process = subprocess.Popen( | |
| cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True | |
| ) | |
| pattern = re.compile(r"time=(\d+):(\d+):(\d+\.\d+)") | |
| speed_pattern = re.compile(r"speed=\s*([0-9.]+)x") | |
| stderr_lines = [] | |
| start_time = time.time() | |
| if process.stderr is not None: | |
| for line in iter(process.stderr.readline, ""): | |
| stderr_lines.append(line) | |
| match = pattern.search(line) | |
| if match: | |
| h = float(match.group(1)) | |
| m = float(match.group(2)) | |
| s = float(match.group(3)) | |
| elapsed = h * 3600 + m * 60 + s | |
| pct = min(elapsed / duration_seconds, 1.0) | |
| wall = time.time() - start_time | |
| speed_m = speed_pattern.search(line) | |
| speed_txt = speed_m.group(1) + "x" if speed_m else "" | |
| eta = "" | |
| if pct > 0.01 and wall > 2: | |
| remaining = (wall / pct) * (1 - pct) | |
| eta = format_duration(remaining) | |
| progress_callback(pct, speed_txt, eta) | |
| process.wait() | |
| if process.returncode != 0: | |
| tail = stderr_lines[-50:] if len(stderr_lines) > 50 else stderr_lines | |
| return False, "".join(tail) | |
| else: | |
| ffmpeg.run(output, overwrite_output=True, capture_stdout=True, capture_stderr=True) | |
| return True, None | |
| except ffmpeg.Error as err: | |
| error_detail = err.stderr.decode("utf-8") if err.stderr else "Proses encoding gagal" | |
| return False, error_detail | |
| def save_upload_to_temp(uploaded_file, token): | |
| """Simpan file upload ke direktori sesi persisten dengan token unik.""" | |
| os.makedirs(SESSION_DIR, exist_ok=True) | |
| cleanup_old_sessions() | |
| suffix = pathlib.Path(uploaded_file.name).suffix or ".mp4" | |
| session_id = str(int(time.time() * 1000)) | |
| file_path = os.path.join(SESSION_DIR, session_id + suffix) | |
| meta_path = os.path.join(SESSION_DIR, session_id + ".json") | |
| with open(file_path, "wb") as f: | |
| f.write(uploaded_file.getbuffer()) | |
| meta = { | |
| "original_name": uploaded_file.name, | |
| "file_path": file_path, | |
| "file_size": uploaded_file.size, | |
| "timestamp": time.time(), | |
| "session_id": session_id, | |
| "token": token, | |
| } | |
| with open(meta_path, "w") as f: | |
| json.dump(meta, f) | |
| _temp_files.append(file_path) | |
| _temp_files.append(meta_path) | |
| return file_path | |
| def find_recent_session(token): | |
| """Cari sesi upload terakhir yang cocok dengan token browser ini.""" | |
| if not os.path.isdir(SESSION_DIR): | |
| return None | |
| if not token: | |
| return None | |
| now = time.time() | |
| best = None | |
| for name in os.listdir(SESSION_DIR): | |
| if not name.endswith(".json"): | |
| continue | |
| meta_path = os.path.join(SESSION_DIR, name) | |
| try: | |
| with open(meta_path, "r") as f: | |
| meta = json.load(f) | |
| if meta.get("token") != token: | |
| continue | |
| age = now - meta.get("timestamp", 0) | |
| if age > SESSION_MAX_AGE: | |
| continue | |
| if not os.path.exists(meta.get("file_path", "")): | |
| continue | |
| if best is None or meta["timestamp"] > best["timestamp"]: | |
| best = meta | |
| except (json.JSONDecodeError, OSError): | |
| continue | |
| return best | |
| def cleanup_old_sessions(): | |
| """Hapus file sesi yang lebih dari 1 jam.""" | |
| if not os.path.isdir(SESSION_DIR): | |
| return | |
| now = time.time() | |
| for name in os.listdir(SESSION_DIR): | |
| full = os.path.join(SESSION_DIR, name) | |
| try: | |
| if now - os.path.getmtime(full) > SESSION_MAX_AGE: | |
| os.unlink(full) | |
| except OSError: | |
| pass | |
| def cleanup_temp_files(): | |
| for path in _temp_files: | |
| try: | |
| if os.path.exists(path): | |
| os.unlink(path) | |
| except OSError: | |
| pass | |
| _temp_files.clear() | |
| atexit.register(cleanup_temp_files) | |
| def load_file_bytes(file_path): | |
| with open(file_path, "rb") as f: | |
| return f.read() | |
| def render_header(): | |
| # Sembunyikan sidebar toggle arrow | |
| hide_sidebar = """ | |
| <style> | |
| [data-testid="collapsedControl"] { display: none !important; } | |
| section[data-testid="stSidebar"] { display: none !important; } | |
| </style> | |
| """ | |
| st.markdown(hide_sidebar, unsafe_allow_html=True) | |
| st.markdown(PAGE_STYLES, unsafe_allow_html=True) | |
| st.markdown( | |
| '<div class="app-hero">' | |
| '<div class="brand">' + APP_TITLE + '</div>' | |
| '<p class="tagline">' + APP_TAGLINE + '</p>' | |
| '<span class="version-badge">v' + APP_VERSION + '</span>' | |
| '</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| def render_history(): | |
| """Tampilkan riwayat kompresi di main page.""" | |
| history = st.session_state.get("compression_history", []) | |
| if history: | |
| with st.expander("📋 Riwayat Kompresi (" + str(len(history)) + ")", expanded=False): | |
| for item in reversed(history[-10:]): | |
| st.caption( | |
| "**" + item["name"] + "** — " | |
| + item["original"] + " → " + item["result"] | |
| + " " + item["reduction"] | |
| ) | |
| def render_video_info(metadata): | |
| c1, c2, c3 = st.columns(3) | |
| c1.metric("Durasi", metadata.get("duration_text", "N/A")) | |
| c2.metric("Resolusi", metadata.get("resolution_text", "N/A")) | |
| c3.metric("Codec", metadata.get("codec", "N/A")) | |
| c4, c5, c6 = st.columns(3) | |
| c4.metric("FPS", str(metadata.get("fps", 0))) | |
| c5.metric("Bitrate", metadata.get("bitrate_text", "N/A")) | |
| c6.metric("Audio", metadata.get("audio_codec", "Tidak ada")) | |
| def render_platform_presets(): | |
| preset_name = st.selectbox( | |
| "Platform Target", | |
| list(PLATFORM_PRESETS.keys()), | |
| index=0, | |
| help="Pilih platform tujuan untuk mengatur parameter otomatis.", | |
| ) | |
| preset = PLATFORM_PRESETS[preset_name] | |
| if preset_name != "Custom": | |
| st.markdown( | |
| '<div class="preset-info">' + preset["description"] + '</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| return {"name": preset_name, **preset} | |
| def render_compression_controls(preset, video_metadata): | |
| is_custom = preset["name"] == "Custom" | |
| settings = {} | |
| st.markdown('<div class="section-title">Kompresi dan Kualitas</div>', unsafe_allow_html=True) | |
| # --- Format Output --- | |
| fmt_label = st.selectbox("Format Output", list(OUTPUT_FORMATS.keys()), index=0) | |
| settings["out_format"] = OUTPUT_FORMATS[fmt_label] | |
| # --- Smart Compression --- | |
| smart_mode = st.checkbox("Smart Compression (target ukuran file)", value=False) | |
| if smart_mode: | |
| target_mb = st.number_input( | |
| "Target ukuran (MB)", | |
| min_value=0.5, max_value=500.0, value=10.0, step=0.5, | |
| help="Masukkan target ukuran file hasil. Bitrate akan dihitung otomatis.", | |
| ) | |
| settings["smart_target_mb"] = target_mb | |
| else: | |
| settings["smart_target_mb"] = None | |
| if is_custom and not smart_mode: | |
| settings["crf"] = st.slider( | |
| "Level Kompresi (CRF)", | |
| min_value=18, | |
| max_value=36, | |
| value=preset["crf"], | |
| help="Nilai lebih tinggi = file lebih kecil. 18-22 hampir lossless, 28-32 ukuran minimal.", | |
| ) | |
| settings["preset"] = st.select_slider( | |
| "Kecepatan Encoding", | |
| options=SPEED_OPTIONS, | |
| value=preset["preset"], | |
| help="Encoding lambat menghasilkan kompresi lebih efisien.", | |
| ) | |
| res_options = ["Resolusi Asli"] + list(RESOLUTION_MAP.keys()) | |
| res_index = 0 | |
| if preset["resolution"] in RESOLUTION_MAP: | |
| res_index = list(RESOLUTION_MAP.keys()).index(preset["resolution"]) + 1 | |
| selected_res = st.selectbox("Resolusi Output", res_options, index=res_index) | |
| settings["resolution"] = "original" if selected_res == "Resolusi Asli" else selected_res | |
| elif not smart_mode: | |
| settings["crf"] = preset["crf"] | |
| settings["preset"] = preset["preset"] | |
| settings["resolution"] = preset["resolution"] | |
| st.info( | |
| "CRF: " + str(preset["crf"]) + " | Preset: " + preset["preset"] | |
| + " | Resolusi: " + preset["resolution"] | |
| ) | |
| else: | |
| settings["crf"] = 28 | |
| settings["preset"] = "medium" | |
| settings["resolution"] = preset.get("resolution", "720p") | |
| settings["mute_audio"] = st.checkbox("Nonaktifkan Audio") | |
| # --- Estimasi Ukuran --- | |
| if video_metadata and not smart_mode: | |
| duration = video_metadata.get("duration", 0) | |
| has_audio = video_metadata.get("has_audio", False) and not settings["mute_audio"] | |
| if duration > 0: | |
| est = estimate_output_size( | |
| duration, settings["crf"], settings["resolution"], | |
| has_audio, settings["out_format"], | |
| ) | |
| st.caption("Estimasi ukuran hasil: **" + format_filesize(est) + "**") | |
| return settings | |
| def render_advanced_controls(preset, video_metadata): | |
| advanced = {} | |
| st.markdown('<div class="section-title">Pengaturan Lanjutan</div>', unsafe_allow_html=True) | |
| max_duration = video_metadata.get("duration", 600) if video_metadata else 600 | |
| col_start, col_end = st.columns(2) | |
| with col_start: | |
| trim_start = st.number_input( | |
| "Potong mulai (detik)", | |
| min_value=0.0, | |
| max_value=float(max_duration), | |
| value=0.0, | |
| step=0.5, | |
| format="%.1f", | |
| ) | |
| with col_end: | |
| trim_end = st.number_input( | |
| "Potong sampai (detik)", | |
| min_value=0.0, | |
| max_value=float(max_duration), | |
| value=0.0, | |
| step=0.5, | |
| format="%.1f", | |
| help="Isi 0 untuk memproses sampai akhir video.", | |
| ) | |
| advanced["trim_start"] = trim_start if trim_start > 0 else None | |
| advanced["trim_end"] = trim_end if trim_end > 0 else None | |
| is_custom = preset["name"] == "Custom" | |
| col_fps, col_ratio = st.columns(2) | |
| with col_fps: | |
| if is_custom: | |
| fps_label = st.selectbox("Frame Rate", list(FRAMERATE_OPTIONS.keys()), index=0) | |
| advanced["target_fps"] = FRAMERATE_OPTIONS[fps_label] | |
| else: | |
| advanced["target_fps"] = preset.get("fps") | |
| fps_display = str(preset["fps"]) + " FPS" if preset.get("fps") else "Bawaan" | |
| st.text_input("Frame Rate", value=fps_display, disabled=True) | |
| with col_ratio: | |
| if is_custom: | |
| aspect_label = st.selectbox("Aspect Ratio", list(ASPECT_RATIOS.keys()), index=0) | |
| advanced["aspect_ratio"] = ASPECT_RATIOS[aspect_label] | |
| else: | |
| advanced["aspect_ratio"] = preset.get("aspect") | |
| aspect_display = preset.get("aspect") or "Bawaan" | |
| st.text_input("Aspect Ratio", value=aspect_display, disabled=True) | |
| advanced["max_bitrate"] = preset.get("max_bitrate") | |
| return advanced | |
| def render_comparison_slider(input_path, output_path, duration): | |
| """Render before/after image comparison slider menggunakan iframe component.""" | |
| import streamlit.components.v1 as components | |
| timestamp = min(duration * 0.3, 5.0) if duration > 0 else 1.0 | |
| before_b64 = extract_frame(input_path, timestamp) | |
| after_b64 = extract_frame(output_path, timestamp) | |
| if not before_b64 or not after_b64: | |
| col_a, col_b = st.columns(2) | |
| with col_a: | |
| st.caption("ASLI") | |
| st.video(input_path) | |
| with col_b: | |
| st.caption("HASIL") | |
| st.video(output_path) | |
| return | |
| html_code = """<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { background: transparent; overflow: hidden; font-family: 'Inter', sans-serif; } | |
| .wrap { | |
| position: relative; | |
| width: 100%; | |
| overflow: hidden; | |
| border-radius: 12px; | |
| cursor: col-resize; | |
| touch-action: none; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| } | |
| .wrap img.bg { | |
| display: block; | |
| width: 100%; | |
| height: auto; | |
| } | |
| .overlay { | |
| position: absolute; | |
| top: 0; left: 0; | |
| width: 50%; | |
| height: 100%; | |
| overflow: hidden; | |
| } | |
| .overlay img { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| display: block; | |
| height: 100%; | |
| max-width: none; | |
| } | |
| .line { | |
| position: absolute; | |
| top: 0; left: 50%; | |
| width: 3px; | |
| height: 100%; | |
| background: #fff; | |
| box-shadow: 0 0 6px rgba(0,0,0,0.6); | |
| z-index: 10; | |
| transform: translateX(-50%); | |
| pointer-events: none; | |
| } | |
| .handle { | |
| position: absolute; | |
| top: 50%; left: 50%; | |
| width: 38px; height: 38px; | |
| background: #fff; | |
| border-radius: 50%; | |
| z-index: 11; | |
| transform: translate(-50%, -50%); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.45); | |
| pointer-events: none; | |
| } | |
| .handle svg { width: 18px; height: 18px; } | |
| .tag { | |
| position: absolute; | |
| top: 8px; | |
| padding: 3px 10px; | |
| border-radius: 5px; | |
| font-size: 11px; | |
| font-weight: 700; | |
| letter-spacing: 0.05em; | |
| z-index: 12; | |
| text-transform: uppercase; | |
| pointer-events: none; | |
| } | |
| .tag-before { right: 8px; background: rgba(239,68,68,0.85); color: #fff; } | |
| .tag-after { left: 8px; background: rgba(16,185,129,0.85); color: #fff; } | |
| .hint { | |
| text-align: center; | |
| font-size: 12px; | |
| color: #94a3b8; | |
| padding: 8px 0 0; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap" id="wrap"> | |
| <span class="tag tag-before">Asli</span> | |
| <span class="tag tag-after">Hasil</span> | |
| <img class="bg" id="bg" src="data:image/jpeg;base64,__BEFORE__" /> | |
| <div class="overlay" id="overlay"> | |
| <img id="afterImg" src="data:image/jpeg;base64,__AFTER__" /> | |
| </div> | |
| <div class="line" id="line"></div> | |
| <div class="handle" id="handle"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2.5" stroke-linecap="round"> | |
| <path d="M8 4l-6 8 6 8"/><path d="M16 4l6 8-6 8"/> | |
| </svg> | |
| </div> | |
| </div> | |
| <div class="hint">Geser ke kiri/kanan untuk membandingkan</div> | |
| <script> | |
| (function(){ | |
| var wrap = document.getElementById('wrap'); | |
| var overlay = document.getElementById('overlay'); | |
| var afterImg = document.getElementById('afterImg'); | |
| var line = document.getElementById('line'); | |
| var handle = document.getElementById('handle'); | |
| var bg = document.getElementById('bg'); | |
| var dragging = false; | |
| function syncSize() { | |
| afterImg.style.width = wrap.offsetWidth + 'px'; | |
| } | |
| function getPos(e) { | |
| var r = wrap.getBoundingClientRect(); | |
| var cx = e.touches ? e.touches[0].clientX : e.clientX; | |
| var x = cx - r.left; | |
| return Math.max(0, Math.min(x / r.width * 100, 100)); | |
| } | |
| function moveTo(pct) { | |
| overlay.style.width = pct + '%'; | |
| line.style.left = pct + '%'; | |
| handle.style.left = pct + '%'; | |
| } | |
| function onStart(e) { | |
| dragging = true; | |
| moveTo(getPos(e)); | |
| e.preventDefault(); | |
| } | |
| function onMove(e) { | |
| if (!dragging) return; | |
| moveTo(getPos(e)); | |
| e.preventDefault(); | |
| } | |
| function onEnd() { dragging = false; } | |
| wrap.addEventListener('mousedown', onStart); | |
| wrap.addEventListener('mousemove', onMove); | |
| wrap.addEventListener('mouseup', onEnd); | |
| wrap.addEventListener('mouseleave', onEnd); | |
| wrap.addEventListener('touchstart', onStart, {passive:false}); | |
| wrap.addEventListener('touchmove', onMove, {passive:false}); | |
| wrap.addEventListener('touchend', onEnd); | |
| bg.onload = function() { | |
| syncSize(); | |
| var h = bg.offsetHeight + 40; | |
| window.parent.postMessage({type:'streamlit:setFrameHeight', height: h}, '*'); | |
| }; | |
| window.addEventListener('resize', function() { | |
| syncSize(); | |
| var h = bg.offsetHeight + 40; | |
| window.parent.postMessage({type:'streamlit:setFrameHeight', height: h}, '*'); | |
| }); | |
| function delayedSync() { | |
| syncSize(); | |
| if (bg.offsetHeight > 0) { | |
| var h = bg.offsetHeight + 40; | |
| window.parent.postMessage({type:'streamlit:setFrameHeight', height: h}, '*'); | |
| } | |
| } | |
| setTimeout(delayedSync, 200); | |
| setTimeout(delayedSync, 600); | |
| setTimeout(delayedSync, 1500); | |
| if (window.ResizeObserver) { | |
| new ResizeObserver(function() { | |
| syncSize(); | |
| var h = bg.offsetHeight + 40; | |
| window.parent.postMessage({type:'streamlit:setFrameHeight', height: h}, '*'); | |
| }).observe(bg); | |
| } | |
| })(); | |
| </script> | |
| </body> | |
| </html>""" | |
| html_final = html_code.replace("__BEFORE__", before_b64).replace("__AFTER__", after_b64) | |
| components.html(html_final, height=800, scrolling=False) | |
| def render_before_after(input_path, output_path, original_size, uploaded_name, video_metadata, out_format="mp4"): | |
| compressed_size = os.path.getsize(output_path) | |
| reduction = calculate_reduction(original_size, compressed_size) | |
| # Simpan ke riwayat | |
| history = st.session_state.get("compression_history", []) | |
| history.append({ | |
| "name": uploaded_name, | |
| "original": format_filesize(original_size), | |
| "result": format_filesize(compressed_size), | |
| "reduction": f"-{reduction:.1f}%", | |
| }) | |
| st.session_state["compression_history"] = history | |
| st.markdown( | |
| '<div class="result-panel">' | |
| '<h3>Kompresi Berhasil</h3>' | |
| '<div class="result-stats">' | |
| + format_filesize(original_size) + ' menjadi ' | |
| '<strong>' + format_filesize(compressed_size) + '</strong>' | |
| ' ' | |
| '<span class="reduction-badge">Hemat ' + f"{reduction:.1f}" + '%</span>' | |
| '</div>' | |
| '</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| duration = video_metadata.get("duration", 0) if video_metadata else 0 | |
| if out_format != "gif": | |
| render_comparison_slider(input_path, output_path, duration) | |
| st.markdown('<div class="section-title">Ukuran File</div>', unsafe_allow_html=True) | |
| col_s1, col_s2 = st.columns(2) | |
| col_s1.metric("Asli", format_filesize(original_size)) | |
| col_s2.metric("Hasil", format_filesize(compressed_size), delta="-" + f"{reduction:.1f}" + "%", delta_color="normal") | |
| if out_format != "gif": | |
| output_meta = probe_video(output_path) | |
| if video_metadata and output_meta: | |
| st.markdown('<div class="section-title">Detail Teknis</div>', unsafe_allow_html=True) | |
| col_h1, col_h2, col_h3 = st.columns(3) | |
| col_h1.write("**Parameter**") | |
| col_h2.write("**Asli**") | |
| col_h3.write("**Hasil**") | |
| params = [ | |
| ("Resolusi", video_metadata.get("resolution_text", "-"), output_meta.get("resolution_text", "-")), | |
| ("Codec", video_metadata.get("codec", "-"), output_meta.get("codec", "-")), | |
| ("FPS", str(video_metadata.get("fps", "-")), str(output_meta.get("fps", "-"))), | |
| ("Bitrate", video_metadata.get("bitrate_text", "-"), output_meta.get("bitrate_text", "-")), | |
| ] | |
| for label, val_before, val_after in params: | |
| c1, c2, c3 = st.columns(3) | |
| c1.write(label) | |
| c2.write(val_before) | |
| c3.write(val_after) | |
| st.write("") | |
| if out_format == "gif": | |
| st.image(output_path, caption="Hasil GIF") | |
| else: | |
| st.video(output_path) | |
| mime_map = {"mp4": "video/mp4", "webm": "video/webm", "gif": "image/gif"} | |
| download_name = get_clean_filename(uploaded_name, out_format) | |
| file_data = load_file_bytes(output_path) | |
| st.download_button( | |
| label="Download Hasil", | |
| data=file_data, | |
| file_name=download_name, | |
| mime=mime_map.get(out_format, "video/mp4"), | |
| ) | |
| def render_features(): | |
| c1, c2, c3 = st.columns(3) | |
| with c1: | |
| st.markdown("**Aman**") | |
| st.caption("Video tidak disimpan permanen di server.") | |
| with c2: | |
| st.markdown("**Tanpa Watermark**") | |
| st.caption("Hasil kompresi bersih tanpa tanda air.") | |
| with c3: | |
| st.markdown("**Gratis**") | |
| st.caption("Gunakan tanpa batas, tanpa biaya.") | |
| def render_footer(): | |
| st.markdown( | |
| '<div class="footer-section">' | |
| 'Dibuat oleh <a href="#">Garden</a> · ' + APP_TITLE + ' v' + APP_VERSION + '<br>' | |
| '<span style="font-size: 0.75rem; color: #475569;">' | |
| 'Dilisensikan di bawah GNU General Public License v3.0' | |
| '</span>' | |
| '</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| def main(): | |
| st.set_page_config( | |
| page_title=APP_TITLE + " - " + APP_TAGLINE, | |
| page_icon="https://em-content.zobj.net/source/twitter/408/film-frames_1f39e-fe0f.png", | |
| layout="centered", | |
| initial_sidebar_state="collapsed", | |
| ) | |
| render_header() | |
| render_features() | |
| st.divider() | |
| uploaded_file = st.file_uploader( | |
| "Pilih file video", | |
| type=SUPPORTED_FORMATS, | |
| help="Format: MP4, MOV, MKV, AVI, WebM. Maks 500MB.", | |
| ) | |
| # --- Session token: unik per browser/tab --- | |
| params = st.query_params | |
| session_token = params.get("sid", "") | |
| if not session_token: | |
| session_token = uuid.uuid4().hex[:16] | |
| st.query_params["sid"] = session_token | |
| if uploaded_file is None: | |
| has_session = ( | |
| "input_path" in st.session_state | |
| and os.path.exists(st.session_state["input_path"]) | |
| ) | |
| if has_session: | |
| input_path = st.session_state["input_path"] | |
| original_size = st.session_state["input_size"] | |
| else: | |
| recent = find_recent_session(session_token) | |
| if recent and os.path.exists(recent.get("file_path", "")): | |
| st.info("Kami menemukan sesi sebelumnya.") | |
| col_info, col_btn = st.columns([3, 1]) | |
| with col_info: | |
| st.markdown( | |
| "**" + recent["original_name"] + "** (" | |
| + format_filesize(recent["file_size"]) + ")" | |
| ) | |
| age_min = int((time.time() - recent["timestamp"]) / 60) | |
| st.caption(str(age_min) + " menit yang lalu") | |
| with col_btn: | |
| if st.button("Lanjutkan", use_container_width=True): | |
| st.session_state["input_path"] = recent["file_path"] | |
| st.session_state["input_name"] = recent["original_name"] | |
| st.session_state["input_size"] = recent["file_size"] | |
| st.session_state["input_size_raw"] = recent["file_size"] | |
| st.session_state.pop("video_metadata", None) | |
| st.rerun() | |
| else: | |
| st.info("Upload video untuk memulai kompresi.") | |
| render_footer() | |
| return | |
| else: | |
| file_changed = ( | |
| "input_name" not in st.session_state | |
| or st.session_state["input_name"] != uploaded_file.name | |
| or st.session_state.get("input_size_raw") != uploaded_file.size | |
| ) | |
| if file_changed: | |
| with st.spinner("Menyimpan video..."): | |
| input_path = save_upload_to_temp(uploaded_file, session_token) | |
| original_size = os.path.getsize(input_path) | |
| st.session_state["input_path"] = input_path | |
| st.session_state["input_name"] = uploaded_file.name | |
| st.session_state["input_size"] = original_size | |
| st.session_state["input_size_raw"] = uploaded_file.size | |
| st.session_state.pop("video_metadata", None) | |
| else: | |
| input_path = st.session_state["input_path"] | |
| original_size = st.session_state["input_size"] | |
| uploaded_name = st.session_state.get("input_name", "video.mp4") | |
| st.success("File terpilih: **" + uploaded_name + "** (**" + format_filesize(original_size) + "**)") | |
| video_metadata = st.session_state.get("video_metadata") | |
| show_info = st.checkbox("Tampilkan info video", value=False) | |
| if show_info: | |
| if video_metadata is None: | |
| with st.spinner("Membaca metadata..."): | |
| video_metadata = probe_video(input_path) | |
| st.session_state["video_metadata"] = video_metadata | |
| if video_metadata: | |
| render_video_info(video_metadata) | |
| preset = render_platform_presets() | |
| settings = render_compression_controls(preset, video_metadata) | |
| show_advanced = st.checkbox("Tampilkan pengaturan lanjutan", value=False) | |
| if show_advanced: | |
| advanced = render_advanced_controls(preset, video_metadata) | |
| else: | |
| advanced = { | |
| "trim_start": None, | |
| "trim_end": None, | |
| "target_fps": preset.get("fps"), | |
| "aspect_ratio": preset.get("aspect"), | |
| "max_bitrate": preset.get("max_bitrate"), | |
| } | |
| st.write("") | |
| if st.button("Mulai Kompresi", use_container_width=True): | |
| out_fmt = settings.get("out_format", "mp4") | |
| output_path = input_path + "_out." + out_fmt | |
| _temp_files.append(output_path) | |
| if video_metadata is None: | |
| with st.spinner("Membaca metadata..."): | |
| video_metadata = probe_video(input_path) | |
| st.session_state["video_metadata"] = video_metadata | |
| total_duration = 0 | |
| if video_metadata: | |
| total_duration = video_metadata.get("duration", 0) | |
| if advanced.get("trim_start") or advanced.get("trim_end"): | |
| start = advanced.get("trim_start") or 0 | |
| end = advanced.get("trim_end") or total_duration | |
| total_duration = max(end - start, 0) | |
| # Smart compression: hitung bitrate dari target ukuran | |
| effective_max_bitrate = advanced.get("max_bitrate") | |
| if settings.get("smart_target_mb") and total_duration > 0: | |
| has_audio = video_metadata.get("has_audio", False) if video_metadata else False | |
| smart_br = calculate_target_bitrate( | |
| settings["smart_target_mb"], total_duration, | |
| has_audio and not settings["mute_audio"], | |
| ) | |
| if smart_br: | |
| effective_max_bitrate = smart_br | |
| progress_bar = st.progress(0, text="Mempersiapkan encoding...") | |
| status_text = st.empty() | |
| def on_progress(pct, speed="", eta=""): | |
| progress_bar.progress( | |
| min(int(pct * 100), 99), | |
| text="Encoding: " + str(int(pct * 100)) + "%", | |
| ) | |
| info_parts = [] | |
| if speed: | |
| info_parts.append("Kecepatan: " + speed) | |
| if eta: | |
| info_parts.append("Sisa: ~" + eta) | |
| if info_parts: | |
| status_text.caption(" · ".join(info_parts)) | |
| success, error_msg = compress_video( | |
| input_path=input_path, | |
| output_path=output_path, | |
| crf=settings["crf"], | |
| preset=settings["preset"], | |
| mute_audio=settings["mute_audio"], | |
| resolution=settings["resolution"], | |
| trim_start=advanced.get("trim_start"), | |
| trim_end=advanced.get("trim_end"), | |
| target_fps=advanced.get("target_fps"), | |
| aspect_ratio=advanced.get("aspect_ratio"), | |
| max_bitrate=effective_max_bitrate, | |
| progress_callback=on_progress, | |
| duration_seconds=total_duration, | |
| out_format=out_fmt, | |
| ) | |
| if success: | |
| progress_bar.progress(100, text="Selesai!") | |
| status_text.empty() | |
| render_before_after(input_path, output_path, original_size, uploaded_name, video_metadata, out_fmt) | |
| else: | |
| progress_bar.empty() | |
| status_text.empty() | |
| st.error("Terjadi kesalahan saat memproses video.") | |
| with st.container(): | |
| st.code(error_msg, language="text") | |
| render_history() | |
| render_footer() | |
| if __name__ == "__main__": | |
| main() |