# app.py # Unified single-file version with Anti-Repetition Upgrades 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 # ===================================================== # [1] CONFIGURATION (config.py) # ===================================================== 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" # VIDEO SETTINGS (Dynamic Variation Upgrades) TARGET_FPS = 30 MIN_SOURCE_DURATION = 15 # [UPGRADE] Durasi klip lebih dinamis (3 - 12 detik) MIN_CLIP_DURATION = 3 MAX_CLIP_DURATION = 12 # [UPGRADE] Ekstrak 1 varian setiap 10 detik video sumber (Unlimited Variants) VARIANT_INTERVAL = 10 # ENCODING VIDEO_CODEC = "libx264" CRF = 28 # SCHEDULER DEFAULT_COOLDOWN = 20 MAX_URLS = 300 BATCH_SIZE = 150 # CREATE FOLDERS for folder in [DOWNLOAD_DIR, CLIPS_DIR, OUTPUT_DIR, TEMP_DIR]: folder.mkdir(parents=True, exist_ok=True) # ===================================================== # [2] PROBE UTILS (probe.py) # ===================================================== 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) # ===================================================== # [3] UTILS (utils.py) # ===================================================== def parse_urls(text): urls = [] for line in text.splitlines(): line = line.strip() if not line: continue urls.append(line) return urls # ===================================================== # [4] DOWNLOADER (downloader.py) # ===================================================== 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) # ===================================================== # [5] VARIANTS GENERATOR (variants.py) # ===================================================== def random_clip_duration(): return round(random.uniform(MIN_CLIP_DURATION, MAX_CLIP_DURATION), 2) # [UPGRADE] Unlimited Variants based on duration 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 multiplier 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): # Penamaan unlimited (V001, V002, dst) variant_name = f"V{idx+1:03d}" target_clip_duration = random_clip_duration() # [UPGRADE] Micro-Speed Variation (0.5 - 1.0) dikombinasikan dengan global speed 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) # [UPGRADE] Randomize HFlip 50% 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 # ===================================================== # [6] USAGE TRACKER (usage_tracker.py) # ===================================================== 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 # ===================================================== # [7] SCHEDULER (scheduler.py) # ===================================================== 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) # [UPGRADE] Micro Cooldown - Exact Variant # Jangan pakai file yang persis sama sampai separuh isi pool terpakai 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 = [] # [UPGRADE] Tracking 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"] # [UPGRADE] Double Constraint 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)) # Fallback Strategy: Jika candidate habis (terlalu banyak di-block oleh cooldown) if not candidates: # Kosongkan blokir secara bertahap 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"] # Update trackers 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 # ===================================================== # [8] RENDERER (renderer.py & batch_renderer.py) # ===================================================== 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)) # Cleanup used variants to save disk space 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) # ===================================================== # [9] AUDIO MUXER (audio_mux.py) # ===================================================== 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) # ===================================================== # [10] APP / STATE MANAGER / UI (app.py) # ===================================================== 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 # --- GLOBAL STATE FOR BACKGROUND TASK --- TASK_STATE = { "status": "idle", # "idle", "running", "done", "error" "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 Memory/Disk 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)] # ===================================================== # [11] GRADIO UI LAYOUT # ===================================================== 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()