| |
| |
|
|
| import os |
| import re |
| import json |
| import random |
| import traceback |
| import subprocess |
| import threading |
| import shutil |
| from pathlib import Path |
| from collections import Counter |
|
|
| import gradio as gr |
| import requests |
|
|
| |
| |
| |
|
|
| PEXELS_API_KEY = os.getenv("PEXELS_API_KEY", "") |
| PIXABAY_API_KEY = os.getenv("PIXABAY_API_KEY", "") |
|
|
| ROOT_DIR = Path(__file__).parent |
| DOWNLOAD_DIR = ROOT_DIR / "downloads" |
| CLIPS_DIR = ROOT_DIR / "clips" |
| OUTPUT_DIR = ROOT_DIR / "output" |
| TEMP_DIR = ROOT_DIR / "temp" |
|
|
| |
| TARGET_FPS = 30 |
| MIN_SOURCE_DURATION = 15 |
|
|
| |
| MIN_CLIP_DURATION = 3 |
| MAX_CLIP_DURATION = 12 |
|
|
| |
| VARIANT_INTERVAL = 10 |
|
|
| |
| VIDEO_CODEC = "libx264" |
| CRF = 28 |
|
|
| |
| DEFAULT_COOLDOWN = 20 |
| MAX_URLS = 300 |
| BATCH_SIZE = 150 |
|
|
| |
| for folder in [DOWNLOAD_DIR, CLIPS_DIR, OUTPUT_DIR, TEMP_DIR]: |
| folder.mkdir(parents=True, exist_ok=True) |
|
|
|
|
| |
| |
| |
|
|
| def ffprobe(filepath): |
| cmd = [ |
| "ffprobe", |
| "-v", "quiet", |
| "-print_format", "json", |
| "-show_format", |
| "-show_streams", |
| str(filepath), |
| ] |
| result = subprocess.run(cmd, capture_output=True, text=True, check=True) |
| return json.loads(result.stdout) |
|
|
| def get_duration(filepath): |
| data = ffprobe(filepath) |
| return float(data["format"]["duration"]) |
|
|
| def get_video_stream(filepath): |
| data = ffprobe(filepath) |
| for stream in data["streams"]: |
| if stream["codec_type"] == "video": |
| return stream |
| return None |
|
|
| def get_resolution(filepath): |
| stream = get_video_stream(filepath) |
| if stream is None: |
| return None |
| return (stream["width"], stream["height"]) |
|
|
| def get_fps(filepath): |
| stream = get_video_stream(filepath) |
| if stream is None: |
| return None |
| fps_raw = stream["r_frame_rate"] |
| num, den = fps_raw.split("/") |
| return float(num) / float(den) |
|
|
|
|
| |
| |
| |
|
|
| def parse_urls(text): |
| urls = [] |
| for line in text.splitlines(): |
| line = line.strip() |
| if not line: |
| continue |
| urls.append(line) |
| return urls |
|
|
|
|
| |
| |
| |
|
|
| def is_pexels_url(url: str) -> bool: |
| return "pexels.com" in url.lower() |
|
|
| def is_pixabay_url(url: str) -> bool: |
| return "pixabay.com" in url.lower() |
|
|
| def extract_pexels_id(url: str): |
| m = re.search(r"-(\d+)/?$", url) |
| if not m: |
| raise ValueError(f"Cannot extract Pexels ID from {url}") |
| return m.group(1) |
|
|
| def extract_pixabay_id(url: str): |
| m = re.search(r"-(\d+)/?$", url) |
| if not m: |
| raise ValueError(f"Cannot extract Pixabay ID from {url}") |
| return m.group(1) |
|
|
| def get_pexels_video_url(video_id): |
| endpoint = f"https://api.pexels.com/videos/videos/{video_id}" |
| headers = {"Authorization": PEXELS_API_KEY} |
| r = requests.get(endpoint, headers=headers, timeout=60) |
| r.raise_for_status() |
| data = r.json() |
| files = data.get("video_files", []) |
| if not files: |
| raise RuntimeError(f"No video files found: {video_id}") |
| |
| files = sorted( |
| files, |
| key=lambda x: (x.get("width", 0) * x.get("height", 0)), |
| reverse=True |
| ) |
| return files[0]["link"] |
|
|
| def get_pixabay_video_url(video_id): |
| endpoint = "https://pixabay.com/api/videos/" |
| params = {"key": PIXABAY_API_KEY, "id": video_id} |
| r = requests.get(endpoint, params=params, timeout=60) |
| r.raise_for_status() |
| data = r.json() |
| hits = data.get("hits", []) |
| if not hits: |
| raise RuntimeError(f"Pixabay video not found: {video_id}") |
| |
| videos = hits[0]["videos"] |
| candidates = [] |
| for v in videos.values(): |
| candidates.append((v.get("width", 0) * v.get("height", 0), v["url"])) |
| candidates.sort(reverse=True) |
| return candidates[0][1] |
|
|
| def save_video(url, output_path): |
| with requests.get(url, stream=True, timeout=120) as r: |
| r.raise_for_status() |
| with open(output_path, "wb") as f: |
| for chunk in r.iter_content(chunk_size=1024 * 1024): |
| if chunk: |
| f.write(chunk) |
| return str(output_path) |
|
|
| def download_video(source_url, index=1): |
| if is_pexels_url(source_url): |
| video_id = extract_pexels_id(source_url) |
| direct_url = get_pexels_video_url(video_id) |
| elif is_pixabay_url(source_url): |
| video_id = extract_pixabay_id(source_url) |
| direct_url = get_pixabay_video_url(video_id) |
| else: |
| raise ValueError(f"Unsupported URL: {source_url}") |
| |
| output_file = DOWNLOAD_DIR / f"source_{index:04d}.mp4" |
| return save_video(direct_url, output_file) |
|
|
|
|
| |
| |
| |
|
|
| def random_clip_duration(): |
| return round(random.uniform(MIN_CLIP_DURATION, MAX_CLIP_DURATION), 2) |
|
|
| |
| def get_variant_count(duration): |
| return max(1, int(duration // VARIANT_INTERVAL)) |
|
|
| def build_dynamic_zones(duration, count): |
| zone_size = duration / count |
| zones = [] |
| current = 0 |
| for _ in range(count): |
| zones.append((current, current + zone_size)) |
| current += zone_size |
| return zones |
|
|
| def random_start_in_zone(zone_start, zone_end, clip_duration): |
| available = zone_end - zone_start - clip_duration |
| if available <= 0: |
| return zone_start |
| return round(random.uniform(zone_start, zone_start + available), 2) |
|
|
| def render_variant(source_file, output_file, start_time, source_duration, target_clip_duration, width, height, preset, final_speed, hflip=False): |
| |
| pts_mult = 1.0 / final_speed |
| |
| filters = [ |
| f"setpts={pts_mult}*PTS", |
| f"scale={width}:{height}:force_original_aspect_ratio=increase", |
| f"crop={width}:{height}", |
| f"fps={TARGET_FPS}", |
| "fade=t=in:st=0:d=0.2", |
| f"fade=t=out:st={max(0, target_clip_duration - 0.2)}:d=0.2", |
| ] |
| if hflip: |
| filters.append("hflip") |
| |
| vf = ",".join(filters) |
| cmd = [ |
| "ffmpeg", "-y", |
| "-ss", str(start_time), |
| "-i", str(source_file), |
| "-t", str(source_duration), |
| "-an", |
| "-vf", vf, |
| "-c:v", VIDEO_CODEC, |
| "-preset", preset, |
| "-crf", str(CRF), |
| "-pix_fmt", "yuv420p", |
| "-t", str(target_clip_duration), |
| str(output_file), |
| ] |
| subprocess.run(cmd, check=True, capture_output=True) |
| return str(output_file) |
|
|
| def generate_variants(source_file, source_id, target_width, target_height, preset, global_speed): |
| duration = get_duration(source_file) |
| if duration < MIN_SOURCE_DURATION: |
| return [] |
| |
| variant_count = get_variant_count(duration) |
| zones = build_dynamic_zones(duration, variant_count) |
| scene_pool = [] |
| |
| for idx, zone in enumerate(zones): |
| |
| variant_name = f"V{idx+1:03d}" |
| |
| target_clip_duration = random_clip_duration() |
| |
| |
| micro_speed = round(random.uniform(0.5, 1.0), 2) |
| final_speed = global_speed * micro_speed |
| |
| source_dur_needed = target_clip_duration * final_speed |
| start_time = random_start_in_zone(zone[0], zone[1], source_dur_needed) |
| |
| |
| hflip = random.choice([True, False]) |
| output_file = CLIPS_DIR / f"{source_id}_{variant_name}.mp4" |
| |
| render_variant( |
| source_file=source_file, |
| output_file=output_file, |
| start_time=start_time, |
| source_duration=source_dur_needed, |
| target_clip_duration=target_clip_duration, |
| width=target_width, |
| height=target_height, |
| preset=preset, |
| final_speed=final_speed, |
| hflip=hflip |
| ) |
| |
| scene_pool.append({ |
| "source_id": source_id, |
| "family": source_id, |
| "variant": variant_name, |
| "path": str(output_file), |
| "duration": target_clip_duration, |
| }) |
| return scene_pool |
|
|
|
|
| |
| |
| |
|
|
| def build_usage_map(timeline): |
| usage = Counter() |
| for scene in timeline: |
| usage[scene["path"]] += 1 |
| return dict(usage) |
|
|
| def consume_scene(usage_map, scene): |
| path = scene["path"] |
| if path not in usage_map: |
| return False |
| usage_map[path] -= 1 |
| return usage_map[path] <= 0 |
|
|
| def remaining_files(usage_map): |
| count = 0 |
| for value in usage_map.values(): |
| if value > 0: |
| count += 1 |
| return count |
|
|
|
|
| |
| |
| |
|
|
| def timeline_duration(timeline): |
| return round(sum(scene["duration"] for scene in timeline), 2) |
|
|
| def get_family_count(scene_pool): |
| return len(set(scene["family"] for scene in scene_pool)) |
|
|
| def calculate_cooldown(scene_pool): |
| family_count = get_family_count(scene_pool) |
| cooldown = min(max(10, family_count // 2), 50) |
| return cooldown |
|
|
| def build_timeline(scene_pool, target_duration, cooldown=None): |
| if not scene_pool: |
| return [] |
| |
| if cooldown is None: |
| cooldown = calculate_cooldown(scene_pool) |
| |
| |
| |
| exact_cooldown = max(10, len(scene_pool) // 2) |
| |
| print(f"[Scheduler] Families={get_family_count(scene_pool)} | Fam_Cooldown={cooldown} | Exact_Cooldown={exact_cooldown}") |
| |
| timeline = [] |
| total_duration = 0 |
| current_position = 0 |
| |
| recent_families = [] |
| recent_exact_clips = [] |
| |
| last_seen = {} |
| |
| for scene in scene_pool: |
| scene["_usage_count"] = 0 |
| |
| safety_counter = 0 |
| while total_duration < target_duration: |
| safety_counter += 1 |
| if safety_counter > 100000: |
| print("[Warning] Scheduler Safety Counter Limit Reached!") |
| break |
| |
| candidates = [] |
| for scene in scene_pool: |
| family = scene["family"] |
| exact_path = scene["path"] |
| |
| |
| if family in recent_families: |
| continue |
| if exact_path in recent_exact_clips: |
| continue |
| |
| family_score = current_position - last_seen.get(family, -999999) |
| usage_penalty = scene["_usage_count"] * 1000 |
| score = family_score - usage_penalty |
| |
| candidates.append((score, random.random(), scene)) |
| |
| |
| if not candidates: |
| |
| if recent_families: |
| recent_families.pop(0) |
| elif recent_exact_clips: |
| recent_exact_clips.pop(0) |
| continue |
| |
| candidates.sort(reverse=True) |
| chosen = candidates[0][2] |
| chosen["_usage_count"] += 1 |
| |
| timeline.append(chosen) |
| total_duration += chosen["duration"] |
| |
| |
| family = chosen["family"] |
| exact_path = chosen["path"] |
| |
| last_seen[family] = current_position |
| current_position += 1 |
| |
| recent_families.append(family) |
| if len(recent_families) > cooldown: |
| recent_families.pop(0) |
| |
| recent_exact_clips.append(exact_path) |
| if len(recent_exact_clips) > exact_cooldown: |
| recent_exact_clips.pop(0) |
| |
| print(f"[Scheduler] Timeline Clips={len(timeline)} | Duration={round(total_duration,2)}s") |
| return timeline |
|
|
|
|
| |
| |
| |
|
|
| def build_concat_file(timeline, concat_file=None): |
| if concat_file is None: |
| concat_file = TEMP_DIR / "concat.txt" |
| concat_file = Path(concat_file) |
| with open(concat_file, "w", encoding="utf-8") as f: |
| for scene in timeline: |
| clip_path = Path(scene["path"]).resolve().as_posix() |
| f.write(f"file '{clip_path}'\n") |
| return str(concat_file) |
|
|
| def render_timeline(timeline, output_file=None): |
| if not timeline: raise ValueError("Timeline is empty") |
| if output_file is None: output_file = OUTPUT_DIR / "video_only.mp4" |
| output_file = Path(output_file) |
| concat_file = build_concat_file(timeline) |
| |
| cmd = [ |
| "ffmpeg", "-y", |
| "-f", "concat", |
| "-safe", "0", |
| "-i", concat_file, |
| "-c", "copy", |
| str(output_file) |
| ] |
| subprocess.run(cmd, check=True, capture_output=True) |
| return str(output_file) |
|
|
| def render_timeline_safe(timeline, preset, output_file=None): |
| if not timeline: raise ValueError("Timeline is empty") |
| if output_file is None: output_file = OUTPUT_DIR / "video_only.mp4" |
| output_file = Path(output_file) |
| concat_file = build_concat_file(timeline) |
| |
| cmd = [ |
| "ffmpeg", "-y", |
| "-f", "concat", |
| "-safe", "0", |
| "-i", concat_file, |
| "-c:v", "libx264", |
| "-preset", preset, |
| "-crf", "28", |
| "-pix_fmt", "yuv420p", |
| str(output_file) |
| ] |
| subprocess.run(cmd, check=True, capture_output=True) |
| return str(output_file) |
|
|
| def render(timeline, preset, output_file=None): |
| try: |
| return render_timeline(timeline, output_file) |
| except Exception as e: |
| print("[Renderer] Fast concat failed:\n", e) |
| print(f"[Renderer] Falling back to re-encode (preset: {preset})...") |
| return render_timeline_safe(timeline, preset, output_file) |
|
|
| def chunk_list(items, size): |
| for i in range(0, len(items), size): |
| yield items[i:i+size] |
|
|
| def build_part_path(index): |
| return OUTPUT_DIR / f"part_{index:03d}.mp4" |
|
|
| def render_parts(timeline, preset, batch_size=50): |
| if not timeline: |
| raise ValueError("Timeline is empty") |
| |
| usage_map = build_usage_map(timeline) |
| part_files = [] |
| batches = list(chunk_list(timeline, batch_size)) |
| total_batches = len(batches) |
| |
| for idx, batch in enumerate(batches, start=1): |
| part_path = build_part_path(idx) |
| |
| TASK_STATE["progress_desc"] = f"Rendering batch {idx}/{total_batches}" |
| |
| render(batch, preset, part_path) |
| part_files.append(str(part_path)) |
| |
| |
| deleted = 0 |
| for scene in batch: |
| delete_file = consume_scene(usage_map, scene) |
| if not delete_file: |
| continue |
| try: |
| if os.path.exists(scene["path"]): |
| os.remove(scene["path"]) |
| deleted += 1 |
| except Exception as e: |
| pass |
| |
| return part_files |
|
|
| def concat_parts(part_files): |
| if not part_files: |
| raise ValueError("No part files") |
| |
| concat_txt = OUTPUT_DIR / "parts.txt" |
| with open(concat_txt, "w", encoding="utf-8") as f: |
| for part in part_files: |
| path = Path(part).resolve().as_posix() |
| f.write(f"file '{path}'\n") |
| |
| final_video = OUTPUT_DIR / "video_only.mp4" |
| cmd = [ |
| "ffmpeg", "-y", |
| "-f", "concat", |
| "-safe", "0", |
| "-i", str(concat_txt), |
| "-c", "copy", |
| str(final_video) |
| ] |
| subprocess.run(cmd, check=True, capture_output=True) |
| |
| for part in part_files: |
| try: os.remove(part) |
| except: pass |
| try: os.remove(concat_txt) |
| except: pass |
| |
| return str(final_video) |
|
|
| def render_final_video(timeline, preset, batch_size=50): |
| parts = render_parts(timeline, preset, batch_size) |
| return concat_parts(parts) |
|
|
|
|
| |
| |
| |
|
|
| def mux_audio(video_file, audio_file, output_file=None): |
| if output_file is None: |
| output_file = OUTPUT_DIR / "final_with_audio.mp4" |
| output_file = Path(output_file) |
| |
| cmd = [ |
| "ffmpeg", "-y", |
| "-i", str(video_file), |
| "-i", str(audio_file), |
| "-c:v", "copy", |
| "-c:a", "aac", |
| "-b:a", "192k", |
| "-shortest", |
| str(output_file) |
| ] |
| subprocess.run(cmd, check=True, capture_output=True) |
| return str(output_file) |
|
|
|
|
| |
| |
| |
|
|
| def cleanup_folder(folder): |
| if not os.path.exists(folder): return |
| for item in os.listdir(folder): |
| path = os.path.join(folder, item) |
| try: |
| if os.path.isfile(path): |
| os.remove(path) |
| except: pass |
|
|
| |
| TASK_STATE = { |
| "status": "idle", |
| "progress_percent": 0.0, |
| "progress_desc": "", |
| "error_msg": "", |
| "output_file": None |
| } |
|
|
| def custom_progress(percent, desc=""): |
| TASK_STATE["progress_percent"] = percent |
| TASK_STATE["progress_desc"] = desc |
|
|
| def background_worker(urls, audio_file_path, resolution_str, duration_mins, preset, global_speed): |
| try: |
| TASK_STATE["status"] = "running" |
| TASK_STATE["error_msg"] = "" |
| TASK_STATE["output_file"] = None |
|
|
| if "720p" in resolution_str: |
| t_width, t_height = 1280, 720 |
| elif "1080p" in resolution_str: |
| t_width, t_height = 1920, 1080 |
| elif "2K" in resolution_str: |
| t_width, t_height = 2560, 1440 |
| else: |
| t_width, t_height = 1280, 720 |
|
|
| has_audio = bool(audio_file_path and os.path.exists(audio_file_path)) |
| if has_audio: |
| custom_progress(0.05, "Reading audio duration...") |
| target_duration = get_duration(audio_file_path) |
| print(f"[Info] Menggunakan durasi audio: {target_duration}s") |
| else: |
| target_duration = float(duration_mins) * 60.0 |
| print(f"[Info] Tanpa audio. Menggunakan durasi slider: {target_duration}s") |
|
|
| scene_pool = [] |
| total_urls = len(urls) |
| |
| for idx, url in enumerate(urls, start=1): |
| percent = 0.05 + (idx / total_urls) * 0.45 |
| custom_progress(percent, f"Processing URL {idx}/{total_urls} ...") |
| |
| try: |
| source_file = download_video(url, idx) |
| scenes = generate_variants(source_file, idx, t_width, t_height, preset, global_speed) |
| scene_pool.extend(scenes) |
| try: os.remove(source_file) |
| except: pass |
| except Exception as e: |
| print(f"[Error] Memproses URL {idx}\n", e) |
|
|
| if not scene_pool: |
| raise Exception("Tidak ada scene valid yang berhasil diproses.") |
|
|
| custom_progress(0.55, "Building timeline (Anti-Repetition)...") |
| timeline = build_timeline(scene_pool, target_duration) |
|
|
| custom_progress(0.75, f"Rendering video chunks (Preset: {preset})...") |
| video_file = render_final_video(timeline, preset, batch_size=BATCH_SIZE) |
|
|
| final_file = OUTPUT_DIR / "final.mp4" |
| if has_audio: |
| custom_progress(0.95, "Muxing final audio...") |
| final_file = mux_audio(video_file, audio_file_path, output_file=str(final_file)) |
| else: |
| custom_progress(0.95, "Menyimpan video (tanpa audio)...") |
| shutil.move(video_file, str(final_file)) |
|
|
| |
| cleanup_folder(DOWNLOAD_DIR) |
| cleanup_folder(CLIPS_DIR) |
| cleanup_folder(TEMP_DIR) |
| |
| TASK_STATE["output_file"] = str(final_file) |
| TASK_STATE["status"] = "done" |
| custom_progress(1.0, "Selesai!") |
|
|
| except Exception as e: |
| traceback.print_exc() |
| TASK_STATE["error_msg"] = str(e) |
| TASK_STATE["status"] = "error" |
|
|
| def trigger_process(url_text, audio_filepath, resolution, duration_mins, preset, speed): |
| if TASK_STATE["status"] == "running": |
| return gr.update(value="β οΈ Task sudah berjalan di background! Tunggu hingga selesai.") |
|
|
| urls = parse_urls(url_text) |
| if not urls: |
| return gr.update(value="β Error: URL kosong.") |
| if len(urls) > MAX_URLS: |
| return gr.update(value=f"β Error: Maksimal {MAX_URLS} URL.") |
|
|
| safe_audio_path = None |
| if audio_filepath: |
| safe_audio_path = str(TEMP_DIR / "safe_audio_input.mp3") |
| shutil.copy(audio_filepath, safe_audio_path) |
|
|
| t = threading.Thread( |
| target=background_worker, |
| args=(urls, safe_audio_path, resolution, duration_mins, preset, speed) |
| ) |
| t.daemon = True |
| t.start() |
|
|
| return gr.update(value="π Memulai proses di background... (Anti-Repetition Aktif)") |
|
|
| def delete_output(): |
| file_to_del = TASK_STATE.get("output_file") |
| if file_to_del and os.path.exists(file_to_del): |
| try: os.remove(file_to_del) |
| except: pass |
| |
| TASK_STATE["status"] = "idle" |
| TASK_STATE["progress_percent"] = 0.0 |
| TASK_STATE["progress_desc"] = "" |
| TASK_STATE["error_msg"] = "" |
| TASK_STATE["output_file"] = None |
|
|
| def refresh_ui(): |
| s = TASK_STATE["status"] |
| if s == "idle": |
| return ["**Status:** Menunggu input...", gr.update(visible=False), gr.update(interactive=True), gr.update(visible=False)] |
| elif s == "running": |
| pct = int(TASK_STATE["progress_percent"] * 100) |
| desc = TASK_STATE["progress_desc"] |
| return [f"**Status:** β³ {desc} ({pct}%)", gr.update(visible=False), gr.update(interactive=False), gr.update(visible=False)] |
| elif s == "done": |
| return ["**Status:** β
Video Berhasil Dibuat!", gr.update(value=TASK_STATE["output_file"], visible=True), gr.update(interactive=True), gr.update(visible=True)] |
| elif s == "error": |
| err = TASK_STATE["error_msg"] |
| return [f"**Status:** β Error - {err}", gr.update(visible=False), gr.update(interactive=True), gr.update(visible=False)] |
|
|
|
|
| |
| |
| |
|
|
| with gr.Blocks(title="Background Video Mixer") as demo: |
| gr.Markdown( |
| """ |
| # π¬ Pixabay + Pexels Video Mixer (Long-Format / Anti-Repetition) |
| Generator video otomatis. Mendukung render di *background*. Kamu bisa menutup browser dengan aman setelah menekan Generate! UI akan terupdate ketika browser dibuka kembali. |
| """ |
| ) |
|
|
| with gr.Row(): |
| with gr.Column(scale=1): |
| |
| urls = gr.Textbox( |
| label="Video URLs", |
| lines=8, |
| placeholder="Paste URL Pixabay/Pexels di sini (Sangat disarankan 30+ URL untuk durasi 2 Jam)" |
| ) |
| |
| with gr.Accordion("βοΈ Pengaturan Render & Efek", open=True): |
| resolution_dd = gr.Dropdown( |
| choices=["1280x720 (720p)", "1920x1080 (1080p)", "2560x1440 (2K)"], |
| value="1280x720 (720p)", |
| label="Resolusi Video" |
| ) |
| |
| preset_dd = gr.Dropdown( |
| choices=["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"], |
| value="ultrafast", |
| label="FFmpeg Preset (Kecepatan vs Kualitas)" |
| ) |
|
|
| speed_slider = gr.Slider( |
| minimum=0.5, |
| maximum=2.0, |
| step=0.1, |
| value=1.0, |
| label="Global Speed Video (Akan di-mix dengan Micro-Speed 0.5x - 1.0x otomatis)" |
| ) |
| |
| with gr.Accordion("π΅ Pengaturan Audio & Durasi", open=True): |
| audio = gr.Audio(label="Audio Background (Opsional)", type="filepath") |
| gr.Markdown("*Jika audio diupload, durasi video akan menyesuaikan panjang audio secara otomatis.*") |
| |
| duration_slider = gr.Slider( |
| minimum=10, |
| maximum=180, |
| step=1, |
| value=10, |
| label="Target Durasi (Menit)", |
| info="Berlaku JIKA Audio tidak di-upload." |
| ) |
|
|
| generate_btn = gr.Button("π Generate Video", variant="primary") |
| |
| with gr.Column(scale=1): |
| status_text = gr.Markdown("**Status:** Menunggu input...") |
| output_video = gr.File(label="Final Video", visible=False) |
| delete_btn = gr.Button("ποΈ Hapus Hasil Render", variant="stop", visible=False) |
|
|
| timer = gr.Timer(value=2, active=True) |
|
|
| generate_btn.click( |
| fn=trigger_process, |
| inputs=[urls, audio, resolution_dd, duration_slider, preset_dd, speed_slider], |
| outputs=[status_text] |
| ) |
|
|
| delete_btn.click( |
| fn=delete_output, |
| inputs=[], |
| outputs=[] |
| ) |
|
|
| timer.tick( |
| fn=refresh_ui, |
| inputs=[], |
| outputs=[status_text, output_video, generate_btn, delete_btn] |
| ) |
|
|
| if __name__ == "__main__": |
| demo.launch() |