mot / app.py
digidau's picture
Update app.py
fc8be32 verified
Raw
History Blame Contribute Delete
26.2 kB
# 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()