| import os |
| import sys |
| import re |
| import random |
| import math |
|
|
| |
| os.environ['GRADIO_SUPPRESS_PROGRESS'] = 'true' |
| |
| import logging |
| logging.getLogger('gradio').setLevel(logging.ERROR) |
|
|
|
|
| import cv2 |
| import numpy as np |
| import gradio as gr |
| from gradio import Progress |
| import shutil |
| import subprocess |
| from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageEnhance |
| from datetime import datetime |
| from threading import Lock |
| import base64 |
| import io |
|
|
| |
| try: |
| from controlnet_aux import ( |
| CannyDetector, MLSDdetector, HEDdetector, |
| LineartDetector, OpenposeDetector, NormalBaeDetector |
| ) |
| from gradio_client import Client |
| from rembg import remove |
| import librosa |
| |
| from pydub import AudioSegment |
| from pydub.silence import split_on_silence |
| except ImportError as e: |
| print("="*80) |
| print(f"ERROR: Missing dependency -> {e}") |
| print("Please install all required packages by running:") |
| print("pip install -r requirements.txt") |
| print("(Note: The new feature requires 'pydub'. Make sure it's in your requirements file.)") |
| print("="*80) |
| sys.exit(1) |
|
|
| |
| try: |
| import whisper |
| except ImportError: |
| print("="*80) |
| print("WARNING: 'openai-whisper' not installed. The Transcription tab will be disabled.") |
| print("To enable it, run: pip install -U openai-whisper") |
| print("="*80) |
| whisper = None |
|
|
| |
| try: |
| from rife_ncnn_vulkan_python import Rife |
| ENHANCE_AI_AVAILABLE = True |
| except ImportError: |
| print("="*80) |
| print("WARNING: 'rife-ncnn-vulkan-python' not found.") |
| print("The AI-Enhanced option in 'Slo-Mo & Enhance' will be disabled.") |
| print("To enable it, run: pip install rife-ncnn-vulkan-python") |
| print("="*80) |
| Rife = None |
| ENHANCE_AI_AVAILABLE = False |
|
|
|
|
| |
| TEMP_DIR = "temp_gradio" |
| os.makedirs(TEMP_DIR, exist_ok=True) |
| model_load_lock = Lock() |
| loaded_detectors = {} |
| whisper_model = None |
| whisper_model_name = "" |
| rife_model = None |
| |
|
|
|
|
| |
| DEFAULT_LINK_PRESETS = { |
| |
| "FLUX.1-schnell (black-forest-labs)": "https://huggingface.co/spaces/black-forest-labs/FLUX.1-schnell", |
| "FLUX.1-schnell (Rooc)": "https://huggingface.co/spaces/Rooc/FLUX.1-schnell", |
| "FLUX.1-schnell (evalstate)": "https://huggingface.co/spaces/evalstate/flux1_schnell", |
| "FLUX.1-schnell (hysts-mcp)": "https://huggingface.co/spaces/hysts-mcp/FLUX.1-schnell", |
| "FLUX.1-schnell (cbensimon)": "https://huggingface.co/spaces/cbensimon/FLUX-1-schnell-mcp", |
| "FLUX.1-dev": "https://huggingface.co/spaces/black-forest-labs/FLUX.1-dev", |
| "FLUX.1-dev-quantized": "https://huggingface.co/spaces/multimodalart/FLUX.1-dev-quantized", |
| "FLUX.1-dev_NotASI": "https://huggingface.co/spaces/NotASI/FLUX.1-dev", |
| "FLUX.1-dev_hysts": "https://huggingface.co/spaces/hysts-mcp/FLUX.1-dev", |
| "HiDream-I1-Dev": "https://huggingface.co/spaces/HiDream-ai/HiDream-I1-Dev", |
| "UnfilteredAI-NSFW-gen-v2": "https://huggingface.co/spaces/armen425221356/UnfilteredAI-NSFW-gen-v2_self_parms", |
| "InfiniteYou-FLUX": "https://huggingface.co/spaces/ByteDance/InfiniteYou-FLUX", |
| "Stable Diffusion 3.5 Large (arad1367)": "https://huggingface.co/spaces/arad1367/Stable_Diffusion_3_5_Large_Customized", |
| "Stable Diffusion 3.5 Large Turbo (doevent)": "https://huggingface.co/spaces/doevent/stable-diffusion-3.5-large-turbo", |
|
|
| |
| "OutfitAnyone": "https://huggingface.co/spaces/HumanAIGC/OutfitAnyone", |
| "Kolors Virtual Try-On": "https://huggingface.co/spaces/Kwai-Kolors/Kolors-Virtual-Try-On", |
| "Miragic Virtual Try-On": "https://huggingface.co/spaces/Miragic-AI/Miragic-Virtual-Try-On", |
| "OutfitAnyway": "https://huggingface.co/spaces/selfit-camera/OutfitAnyway", |
| "IDM-VTON": "https://huggingface.co/spaces/yisol/IDM-VTON", |
| "InstantCharacter": "https://huggingface.co/spaces/InstantX/InstantCharacter", |
| "InstantID": "https://huggingface.co/spaces/InstantX/InstantID", |
|
|
| |
| "LivePortrait": "https://huggingface.co/spaces/Han-123/LivePortrait", |
| "LivePortrait (CPU)": "https://huggingface.co/spaces/K00B404/LivePortrait_cpu", |
| "D-ID Live Portrait AI": "https://www.d-id.com/liveportrait-4/", |
| "Synthesia Avatars": "https://www.synthesia.io/features/avatars", |
| "Papercup": "https://www.papercup.com/", |
| "Hedra": "https://www.hedra.com", |
| "LemonSlice": "https://lemonslice.com", |
| "Vozo AI": "https://www.vozo.ai/lip-sync", |
| "Gooey AI Lipsync": "https://gooey.ai/Lipsync", |
| "Sync.so": "https://sync.so", |
| "LipDub AI": "https://www.lipdub.ai", |
| "Magic Hour": "https://magichour.ai", |
| "Lifelike AI": "https://www.lifelikeai.io", |
| "DeepMotion": "https://www.deepmotion.com", |
| "Elai.io": "https://elai.io", |
| "Rephrase.ai": "https://www.rephrase.ai", |
| "Colossyan": "https://www.colossyan.com", |
| "HeyGen (Movio)": "https://www.heygen.com", |
| "Murf Studio": "https://murf.ai", |
|
|
| |
| "FLUX Fill/Outpaint": "https://huggingface.co/spaces/multimodalart/flux-fill-outpaint", |
| "ReSize Image Outpainting": "https://huggingface.co/spaces/VIDraft/ReSize-Image-Outpainting", |
| "IC-Light (Relighting)": "https://huggingface.co/spaces/lllyasviel/IC-Light", |
| "IC-Light v2-vary": "https://huggingface.co/spaces/lllyasviel/iclight-v2-vary", |
| "Kontext Relight": "https://huggingface.co/spaces/kontext-community/kontext-relight", |
| "SUPIR Upscaler": "https://huggingface.co/spaces/Fabrice-TIERCELIN/SUPIR", |
|
|
| |
| "Framepacks (atunc29)": "https://huggingface.co/spaces/atunc29/Framepacks", |
| "Framepack i2v (ginigen)": "https://huggingface.co/spaces/ginigen/framepack-i2v", |
| "Framepack i2v (beowcow)": "https://huggingface.co/spaces/beowcow/framepack-i2v", |
| "Framepack i2v (lisonallen)": "https://huggingface.co/spaces/lisonallen/framepack-i2v", |
| "FramePack F1 (Latyrine)": "https://huggingface.co/spaces/Latyrine/FramePack-F1", |
| "FramePack F1 (linoyts)": "https://huggingface.co/spaces/linoyts/FramePack-F1", |
| "FramePack Rotate (tori29umai)": "https://huggingface.co/spaces/tori29umai/FramePack_rotate_landscape", |
| "FramePack Rotate (bep40)": "https://huggingface.co/spaces/bep40/FramePack_rotate_landscape", |
| "FramePack Rotate (VIDraft)": "https://huggingface.co/spaces/VIDraft/FramePack_rotate_landscape", |
| "Framepack-H111 (rahul7star)": "https://huggingface.co/spaces/rahul7star/Framepack-H111", |
| "FLUX.1 Kontext Dev": "https://huggingface.co/spaces/black-forest-labs/FLUX.1-Kontext-Dev", |
| "Wan2-1-fast": "https://huggingface.co/spaces/multimodalart/wan2-1-fast", |
| "LTX-video-distilled": "https://huggingface.co/spaces/Lightricks/ltx-video-distilled", |
| "RunwayML": "https://app.runwayml.com/video-tools/teams/rinaabdine1/ai-tools/generate", |
| "Pika Labs": "https://pika.art/", |
| "Kling AI": "https://app.klingai.com/global/image-to-video/frame-mode", |
|
|
| |
| "RIFE (remzloev)": "https://huggingface.co/spaces/remzloev/Rife", |
| "VFI Converter (Agung1453)": "https://huggingface.co/spaces/Agung1453/Video-Frame-Interpolation-Converter", |
| "ZeroGPU Upscaler/Interpolation": "https://huggingface.co/spaces/inoculatemedia/zerogpu-upscaler-interpolation", |
| "Frame Interpolation (meta-artem)": "https://huggingface.co/spaces/meta-artem/frame-interpolation", |
| "Video Frame Interpolation (guardiancc)": "https://huggingface.co/spaces/guardiancc/video_frame_interpolation", |
| "Video Frame Interpolation (freealise)": "https://huggingface.co/spaces/freealise/video_frame_interpolation", |
| "Framer (wwen1997)": "https://huggingface.co/spaces/wwen1997/Framer", |
| "Inter4k VideoInterpolator": "https://huggingface.co/spaces/vimleshc57/Inter4k_VideoInterpolator", |
|
|
| |
| "AnimateDiff Lightning (ByteDance)": "https://huggingface.co/spaces/ByteDance/AnimateDiff-Lightning", |
| "AnimateDiff Lightning (SahaniJi)": "https://huggingface.co/spaces/SahaniJi/AnimateDiff-Lightning", |
| "AnimateDiff (fatima14)": "https://huggingface.co/spaces/fatima14/AnimateDiff", |
| "AnimateDiff Video Gen (faizanR)": "https://huggingface.co/spaces/faizanR/animatediff-video-generator", |
| "Text-to-Animation Fast (MisterProton)": "https://huggingface.co/spaces/MisterProton/text-to-Animation-Fast-AnimateDiff", |
| "Text-to-Animation Fast (Rowdy013)": "https://huggingface.co/spaces/Rowdy013/text-to-Animation-Fast", |
|
|
| |
| "StyleGAN-Human Interpolation (hysts)": "https://huggingface.co/spaces/hysts/StyleGAN-Human-Interpolation", |
| "StyleGAN-Human (Gradio-Blocks)": "https://huggingface.co/spaces/Gradio-Blocks/StyleGAN-Human", |
|
|
| |
| "MGM-Film-Diffusion (tonyassi)": "https://huggingface.co/spaces/tonyassi/MGM-Film-Diffusion", |
| "CineDiffusion (takarajordan)": "https://huggingface.co/spaces/takarajordan/CineDiffusion", |
| "FLUX Film Foto (MartsoBodziu1994)": "https://huggingface.co/spaces/MartsoBodziu1994/alvdansen-flux_film_foto", |
| "FLUX Style Shaping": "https://huggingface.co/spaces/multimodalart/flux-style-shaping", |
| "Film (Stijnijzelenberg)": "https://huggingface.co/spaces/Stijnijzelenberg/film", |
| "Film Eras (abbiewoodbridge)": "https://huggingface.co/spaces/abbiewoodbridge/Film_Eras", |
| "Film Genre Classifier (Rezuwan)": "https://huggingface.co/spaces/Rezuwan/film_genre_classifier", |
| "RunwayML (Faizbulbul)": "https://huggingface.co/spaces/Faizbulbul/Runwaymlfaiz", |
|
|
| |
| "Step1X-3D": "https://huggingface.co/spaces/stepfun-ai/Step1X-3D", |
| "TRELLIS TextTo3D (PUM4CH3N)": "https://huggingface.co/spaces/PUM4CH3N/TRELLIS_TextTo3D", |
| "TRELLIS TextTo3D (cavargas10)": "https://huggingface.co/spaces/cavargas10/TRELLIS-Texto3D", |
| "TRELLIS TextTo3D (dkatz2391)": "https://huggingface.co/spaces/dkatz2391/TRELLIS_TextTo3D_Try2", |
| "Sparc3D": "https://huggingface.co/spaces/ilcve21/Sparc3D", |
| "Hunyuan3D-2.1": "https://huggingface.co/spaces/tencent/Hunyuan3D-2.1", |
|
|
| |
| "BLIP-2 (hysts)": "https://huggingface.co/spaces/hysts/BLIP2", |
| "BLIP-3o": "https://huggingface.co/spaces/BLIP3o/blip-3o", |
| "Blip-Dalle3 (DarwinAnim8or)": "https://huggingface.co/spaces/DarwinAnim8or/Blip-Dalle3", |
| "BLIP API (Jonu1)": "https://huggingface.co/spaces/Jonu1/blip-image-captioning-api", |
| "BLIP API (muxiddin19)": "https://huggingface.co/spaces/muxiddin19/blip-image-captioning-api", |
|
|
| |
| "DiffSketcher (SVGRender)": "https://huggingface.co/spaces/SVGRender/DiffSketcher", |
| "Diffusion WikiArt (kaupane)": "https://huggingface.co/spaces/kaupane/diffusion-wikiart", |
| "Diffusers Image Fill (OzzyGT)": "https://huggingface.co/spaces/OzzyGT/diffusers-image-fill", |
| "Diffusers Fast Inpaint (OzzyGT)": "https://huggingface.co/spaces/OzzyGT/diffusers-fast-inpaint", |
| |
| |
| "ThinkSound (FunAudioLLM)": "https://huggingface.co/spaces/FunAudioLLM/ThinkSound", |
| "TTS Unlimited (NihalGazi)": "https://huggingface.co/spaces/NihalGazi/Text-To-Speech-Unlimited", |
| "Voice Clon (tonyassi)": "https://huggingface.co/spaces/tonyassi/voice-clon", |
|
|
| |
| "SKRIPTZ (skylinkd)": "https://huggingface.co/spaces/skylinkd/SKRIPTZ", |
|
|
| |
| "Hugging Face Hub": "https://huggingface.co", |
| "Hugging Face Transformers": "https://huggingface.co/docs/transformers/en/index", |
| "Hugging Face Inference API": "https://huggingface.co/inference-api/", |
|
|
| |
| "SpatialTrackerV2 (Yuxihenry)": "https://huggingface.co/spaces/Yuxihenry/SpatialTrackerV2", |
| "MTVCraft (BAAI)": "https://huggingface.co/spaces/BAAI/MTVCraft", |
|
|
| |
| "EBSynth (NihalGazi)": "https://huggingface.co/spaces/NihalGazi/EBSynth", |
| "MoodSpace (huzey)": "https://huggingface.co/spaces/huzey/MoodSpace", |
| "TR0N (Layer6)": "https://huggingface.co/spaces/Layer6/TR0N", |
| "TUTOR (nathannarrik)": "https://huggingface.co/spaces/nathannarrik/TUTOR", |
| "Sport Model 1 (CHEN11102)": "https://huggingface.co/spaces/CHEN11102/sportmodel1", |
| "VBench Leaderboard (Vchitect)": "https://huggingface.co/spaces/Vchitect/VBench_Leaderboard", |
| } |
|
|
|
|
| |
| DETECTOR_CONFIG = { |
| "Canny": {"class": CannyDetector, "args": {}}, |
| "Lineart": {"class": LineartDetector, "args": {"pretrained_model_or_path": "lllyasviel/Annotators"}}, |
| "MLSD": {"class": MLSDdetector, "args": {"pretrained_model_or_path": "lllyasviel/Annotators"}}, |
| "OpenPose": {"class": OpenposeDetector, "args": {"pretrained_model_or_path": "lllyasviel/Annotators"}}, |
| "NormalBAE": {"class": NormalBaeDetector, "args": {"pretrained_model_or_path": "lllyasviel/Annotators"}}, |
| "SoftEdge (HED)": {"class": HEDdetector, "args": {"pretrained_model_or_path": "lllyasviel/Annotators"}}, |
| } |
|
|
| def get_detector(name): |
| with model_load_lock: |
| if name not in loaded_detectors: |
| print(f"Loading {name} model...") |
| config = DETECTOR_CONFIG[name] |
| if "pretrained_model_or_path" in config["args"]: |
| detector_class = config["class"] |
| loaded_detectors[name] = detector_class.from_pretrained(**config["args"]) |
| else: |
| loaded_detectors[name] = config["class"](**config["args"]) |
| print(f"{name} model loaded.") |
| return loaded_detectors[name] |
|
|
| def load_whisper_model(model_name="base"): |
| global whisper_model, whisper_model_name |
| if whisper: |
| with model_load_lock: |
| if whisper_model is None or whisper_model_name != model_name: |
| print(f"Loading Whisper model '{model_name}'... (This may download files on first run)") |
| whisper_model = whisper.load_model(model_name, device="cpu") |
| whisper_model_name = model_name |
| print("Whisper model loaded.") |
| return whisper_model |
| return None |
|
|
| def load_enhance_ai_models(): |
| """Load RIFE model if it is not already loaded.""" |
| global rife_model |
| if not ENHANCE_AI_AVAILABLE: |
| return |
|
|
| with model_load_lock: |
| if rife_model is None: |
| print("Loading RIFE model for frame interpolation...") |
| rife_model = Rife(gpuid=0, model="rife-v4.6", num_threads=4, tta_mode=False) |
| print("RIFE model loaded.") |
|
|
|
|
| get_detector("Canny") |
|
|
|
|
| |
| def parse_color(color_str): |
| """ |
| Parses a color string from Gradio's ColorPicker. |
| It can handle hex strings ('#RRGGBB') or the problematic |
| rgba float format ('rgba(r,g,b,a)'). |
| Returns a tuple (r, g, b) for PIL. |
| """ |
| if not isinstance(color_str, str): |
| return color_str |
|
|
| if color_str.startswith('rgba'): |
| parts = re.findall(r"[\d\.]+", color_str) |
| if len(parts) >= 3: |
| return (int(float(parts[0])), int(float(parts[1])), int(float(parts[2]))) |
| |
| |
| if color_str.startswith('#'): |
| hex_color = color_str.lstrip('#') |
| return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) |
| |
| return color_str |
|
|
|
|
| def rotate_image(image, rotation): |
| if rotation == "90 Degrees Clockwise": |
| return cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE) |
| elif rotation == "90 Degrees Counter-Clockwise": |
| return cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE) |
| elif rotation == "180 Degrees": |
| return cv2.rotate(image, cv2.ROTATE_180) |
| return image |
|
|
| def manipulate_image(image, operation): |
| if image is None: |
| raise gr.Error("Please upload an image first.") |
| |
| if operation == "Invert Colors": |
| return cv2.bitwise_not(image) |
| elif operation == "Flip Horizontal": |
| return cv2.flip(image, 1) |
| elif operation == "Flip Vertical": |
| return cv2.flip(image, 0) |
| elif operation == "Rotate 90Β° Right": |
| return cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE) |
| elif operation == "Rotate 90Β° Left": |
| return cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE) |
| else: |
| return image |
| |
| def manipulate_video(video_path, operation): |
| if not video_path: |
| raise gr.Error("Please upload a video first.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_video_path = os.path.join(TEMP_DIR, f"manipulated_video_{timestamp}.mp4") |
|
|
| cap = cv2.VideoCapture(video_path) |
| if not cap.isOpened(): |
| raise gr.Error("Error opening video file.") |
|
|
| width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) |
| height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) |
| fps = cap.get(cv2.CAP_PROP_FPS) |
| if fps == 0: fps = 30 |
| frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) |
| fourcc = cv2.VideoWriter_fourcc(*'mp4v') |
|
|
| out_width, out_height = width, height |
| if operation in ["Rotate 90Β° Right", "Rotate 90Β° Left"]: |
| out_width, out_height = height, width |
|
|
| writer = cv2.VideoWriter(output_video_path, fourcc, fps, (out_width, out_height)) |
|
|
| for _ in range(frame_count): |
| ret, frame = cap.read() |
| if not ret: |
| break |
|
|
| processed_frame = manipulate_image(frame, operation) |
| writer.write(processed_frame) |
|
|
| cap.release() |
| writer.release() |
|
|
| return output_video_path |
|
|
| def get_media_duration(media_path): |
| if not media_path or not os.path.exists(media_path): |
| return 0.0 |
| |
| |
| try: |
| cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", media_path] |
| result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=10) |
| return float(result.stdout.strip()) |
| except Exception: |
| |
| pass |
|
|
| |
| print(f"Warning: Fast duration check failed for {os.path.basename(media_path)}. Performing robust scan (this may take a moment)...") |
| try: |
| cmd = ["ffmpeg", "-i", media_path, "-f", "null", "-"] |
| |
| result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) |
| |
| |
| matches = re.findall(r"time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})", result.stderr) |
| if matches: |
| last_match = matches[-1] |
| hours, minutes, seconds, hundredths = map(int, last_match) |
| total_seconds = (hours * 3600) + (minutes * 60) + seconds + (hundredths / 100.0) |
| print(f"Robust scan successful. Detected duration: {total_seconds:.2f}s") |
| return total_seconds |
| else: |
| |
| print(f"Error: Robust duration scan also failed for {os.path.basename(media_path)}.") |
| return 0.0 |
| except Exception as e: |
| print(f"An unexpected error occurred during robust scan for {media_path}: {e}") |
| return 0.0 |
|
|
| def get_video_dimensions(video_path): |
| if not video_path: return 0, 0 |
| try: |
| cap = cv2.VideoCapture(video_path) |
| if not cap.isOpened(): return 0, 0 |
| width, height = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) |
| cap.release() |
| return width, height |
| except Exception: return 0, 0 |
|
|
| def get_video_fps(video_path): |
| if not video_path: return 24.0 |
| try: |
| cap = cv2.VideoCapture(video_path) |
| if not cap.isOpened(): return 24.0 |
| fps = cap.get(cv2.CAP_PROP_FPS) |
| cap.release() |
| return fps if fps > 0 else 24.0 |
| except Exception: return 24.0 |
|
|
| def has_audio_stream(video_path): |
| """Checks if a video file has at least one audio stream.""" |
| if not video_path: |
| return False |
| try: |
| cmd = [ |
| "ffprobe", "-v", "error", "-select_streams", "a", |
| "-show_entries", "stream=codec_type", "-of", |
| "default=noprint_wrappers=1:nokey=1", video_path |
| ] |
| result = subprocess.run(cmd, capture_output=True, text=True, check=True) |
| return result.stdout.strip() != "" |
| except (subprocess.CalledProcessError, FileNotFoundError): |
| return False |
|
|
| def run_ffmpeg_command(cmd, desc="Processing with FFMPEG..."): |
| try: |
| print(f"Running FFMPEG command: {' '.join(cmd)}") |
| process = subprocess.run( |
| cmd, |
| capture_output=True, |
| text=True, |
| encoding='utf-8', |
| check=False |
| ) |
| if process.returncode != 0: |
| full_output = f"--- FFMPEG & GRADIO ERROR LOG ---\n\n" \ |
| f"FFMPEG COMMAND:\n{' '.join(cmd)}\n\n" \ |
| f"FFMPEG STDERR:\n{process.stderr}\n\n" \ |
| f"FFMPEG STDOUT:\n{process.stdout}" |
| raise subprocess.CalledProcessError(process.returncode, cmd, output=full_output) |
| |
| except subprocess.CalledProcessError as e: |
| raise gr.Error(f"FFMPEG failed!\n\nDetails:\n{e.output}") |
| except FileNotFoundError: |
| raise gr.Error("FFMPEG not found. Please ensure ffmpeg is installed and in your system's PATH.") |
|
|
| def batch_image_processor(files, processing_function, job_name, **kwargs): |
| if not files: raise gr.Error("Please upload at least one image.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"{job_name}_{timestamp}"); os.makedirs(job_temp_dir, exist_ok=True) |
| output_paths = [] |
| |
| for file_obj in files: |
| try: |
| base, _ = os.path.splitext(os.path.basename(file_obj.name)) |
| |
| if job_name == "zoom_videos": |
| output_filename = f"{base}.mp4" |
| elif job_name == "bg_removed": |
| output_filename = f"{base}.png" |
| elif job_name == "cropped": |
| output_filename = f"{base}_cropped.png" |
| else: |
| output_filename = os.path.basename(file_obj.name) |
| |
| output_path = os.path.join(job_temp_dir, output_filename) |
| |
| processing_function(input_path=file_obj.name, output_path=output_path, **kwargs) |
| output_paths.append(output_path) |
| |
| except Exception as e: |
| print(f"Skipping file {file_obj.name} due to error: {e}") |
| continue |
| |
| if not output_paths: |
| shutil.rmtree(job_temp_dir) |
| raise gr.Error("No images could be processed from the batch.") |
| |
| zip_base_name = os.path.join(TEMP_DIR, f"{job_name}_archive_{timestamp}") |
| zip_path = shutil.make_archive(zip_base_name, 'zip', job_temp_dir) |
| |
| return output_paths, zip_path, job_temp_dir |
|
|
|
|
| def process_batch_images_with_detector(files, detector_name): |
| detector = get_detector(detector_name) |
| def apply_detector(input_path, output_path, **kwargs): |
| with Image.open(input_path).convert("RGB") as img: |
| processed = detector(img, detect_resolution=512, image_resolution=1024) |
| processed.save(output_path) |
| output_paths, zip_path, _ = batch_image_processor(files, apply_detector, f"controlnet_{detector_name}") |
| return output_paths, zip_path |
|
|
| def process_video_with_detector(video_path, detector_name): |
| if not video_path: raise gr.Error("Please upload a video first.") |
| detector = get_detector(detector_name) |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"job_{timestamp}") |
| input_frames_dir, output_frames_dir = os.path.join(job_temp_dir, "input_frames"), os.path.join(job_temp_dir, "output_frames") |
| os.makedirs(input_frames_dir, exist_ok=True); os.makedirs(output_frames_dir, exist_ok=True) |
| output_video_path = os.path.join(TEMP_DIR, f"{detector_name.lower()}_output_{timestamp}.mp4") |
| cap = cv2.VideoCapture(video_path) |
| frame_count, frame_rate = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)), get_video_fps(video_path) |
| for i in range(frame_count): |
| success, frame = cap.read() |
| if not success: break |
| cv2.imwrite(os.path.join(input_frames_dir, f"frame_{i:05d}.png"), frame) |
| cap.release() |
| input_files = sorted(os.listdir(input_frames_dir)) |
| for filename in input_files: |
| with Image.open(os.path.join(input_frames_dir, filename)).convert("RGB") as image: |
| result_pil = detector(image, detect_resolution=512, image_resolution=1024) |
| result_np = cv2.cvtColor(np.array(result_pil), cv2.COLOR_RGB2BGR) |
| cv2.imwrite(os.path.join(output_frames_dir, filename), result_np) |
| cmd = ["ffmpeg", "-framerate", str(frame_rate), "-i", os.path.join(output_frames_dir, "frame_%05d.png"), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-y", output_video_path] |
| run_ffmpeg_command(cmd, "Compiling Video") |
| shutil.rmtree(job_temp_dir) |
| return output_video_path |
|
|
| def extract_first_last_frame(video_path): |
| if not video_path: |
| raise gr.Error("Please upload a video first.") |
|
|
| cap = cv2.VideoCapture(video_path) |
| if not cap.isOpened(): |
| raise gr.Error("Failed to open video file.") |
|
|
| frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) |
| if frame_count < 1: |
| cap.release() |
| raise gr.Error("Video has no frames.") |
|
|
| |
| cap.set(cv2.CAP_PROP_POS_FRAMES, 0) |
| success, first_frame_img = cap.read() |
| if not success: |
| cap.release() |
| raise gr.Error("Could not read the first frame.") |
|
|
| |
| |
| |
| last_frame_img = None |
| |
| |
| start_frame_for_last = max(1, frame_count - 10) |
| cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame_for_last) |
|
|
| |
| while True: |
| success, frame = cap.read() |
| if not success: |
| break |
| last_frame_img = frame |
|
|
| cap.release() |
| |
| |
| if last_frame_img is None: |
| last_frame_img = first_frame_img |
| |
| |
| |
| |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| first_frame_path = os.path.join(TEMP_DIR, f"first_frame_{timestamp}.png") |
| last_frame_path = os.path.join(TEMP_DIR, f"last_frame_{timestamp}.png") |
|
|
| |
| Image.fromarray(cv2.cvtColor(first_frame_img, cv2.COLOR_BGR2RGB)).save(first_frame_path) |
| Image.fromarray(cv2.cvtColor(last_frame_img, cv2.COLOR_BGR2RGB)).save(last_frame_path) |
|
|
| |
| return [first_frame_path, last_frame_path] |
|
|
| |
| def batch_extract_first_last_frames(videos, progress=gr.Progress(track_tqdm=True)): |
| if not videos: |
| raise gr.Error("Please upload at least one video.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"batch_fl_frames_{timestamp}") |
| os.makedirs(job_temp_dir, exist_ok=True) |
| |
| output_paths = [] |
|
|
| for video_file in progress.tqdm(videos, desc="Processing videos"): |
| try: |
| video_path = video_file.name |
| base_name = os.path.splitext(os.path.basename(video_path))[0] |
| |
| cap = cv2.VideoCapture(video_path) |
| if not cap.isOpened(): |
| gr.Warning(f"Skipping '{base_name}': could not open video file.") |
| continue |
|
|
| frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) |
| if frame_count < 1: |
| cap.release() |
| gr.Warning(f"Skipping '{base_name}': video has no frames.") |
| continue |
|
|
| |
| cap.set(cv2.CAP_PROP_POS_FRAMES, 0) |
| success, first_frame_img = cap.read() |
| if not success: |
| cap.release() |
| gr.Warning(f"Skipping '{base_name}': could not read the first frame.") |
| continue |
| |
| |
| last_frame_img = None |
| start_frame_for_last = max(1, frame_count - 10) |
| cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame_for_last) |
| while True: |
| success, frame = cap.read() |
| if not success: |
| break |
| last_frame_img = frame |
| cap.release() |
| |
| if last_frame_img is None: |
| last_frame_img = first_frame_img |
|
|
| |
| first_frame_path = os.path.join(job_temp_dir, f"{base_name}_first.png") |
| last_frame_path = os.path.join(job_temp_dir, f"{base_name}_last.png") |
|
|
| Image.fromarray(cv2.cvtColor(first_frame_img, cv2.COLOR_BGR2RGB)).save(first_frame_path) |
| Image.fromarray(cv2.cvtColor(last_frame_img, cv2.COLOR_BGR2RGB)).save(last_frame_path) |
| |
| output_paths.extend([first_frame_path, last_frame_path]) |
|
|
| except Exception as e: |
| gr.Warning(f"Skipping file {os.path.basename(video_file.name)} due to an error: {e}") |
| if 'cap' in locals() and cap.isOpened(): |
| cap.release() |
| continue |
| |
| if not output_paths: |
| shutil.rmtree(job_temp_dir) |
| raise gr.Error("No frames could be extracted from the batch.") |
| |
| zip_base_name = os.path.join(TEMP_DIR, f"batch_fl_archive_{timestamp}") |
| zip_path = shutil.make_archive(zip_base_name, 'zip', job_temp_dir) |
| |
| return output_paths, zip_path |
| |
| def video_to_frames_extractor(video_path, skip_rate, rotation, do_resize, out_w, out_h, out_format, jpg_quality): |
| if not video_path: raise gr.Error("Please upload a video first.") |
| if do_resize and (out_w <= 0 or out_h <= 0): raise gr.Error("If resizing, width and height must be positive.") |
| cap = cv2.VideoCapture(video_path) |
| if not cap.isOpened(): raise gr.Error("Failed to open video file.") |
| frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) |
| if frame_count < 1: cap.release(); raise gr.Error("Video appears to have no frames.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"v2f_{timestamp}"); os.makedirs(job_temp_dir, exist_ok=True) |
| frame_paths = [] |
| saved_count = 0 |
| for i in range(frame_count): |
| success, frame = cap.read() |
| if not success: break |
| if i % skip_rate != 0: continue |
| frame = rotate_image(frame, rotation) |
| if do_resize: frame = cv2.resize(frame, (out_w, out_h), interpolation=cv2.INTER_LANCZOS4) |
| frame_pil = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) |
| file_ext = out_format.lower() |
| frame_path = os.path.join(job_temp_dir, f"frame_{saved_count:05d}.{file_ext}") |
| if out_format == "JPG": frame_pil.save(frame_path, quality=jpg_quality) |
| else: frame_pil.save(frame_path) |
| frame_paths.append(frame_path) |
| saved_count += 1 |
| cap.release() |
| if not frame_paths: shutil.rmtree(job_temp_dir); raise gr.Error("Could not extract any frames.") |
| zip_base_name = os.path.join(TEMP_DIR, f"frames_archive_{timestamp}") |
| zip_path = shutil.make_archive(zip_base_name, 'zip', job_temp_dir) |
| return frame_paths[:100], zip_path |
|
|
| def create_video_from_frames(files, fps, rotation, do_resize, out_w, out_h): |
| if not files: raise gr.Error("Please upload frame images first.") |
| if do_resize and (out_w <= 0 or out_h <= 0): raise gr.Error("If resizing, width and height must be positive.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"f2v_{timestamp}"); os.makedirs(job_temp_dir, exist_ok=True) |
| filenames = [] |
| for i, file in enumerate(files): |
| ext = os.path.splitext(file.name)[1] |
| temp_path = os.path.join(job_temp_dir, f"frame_{i:05d}{ext}") |
| shutil.copy(file.name, temp_path); filenames.append(temp_path) |
| output_video_path = os.path.join(TEMP_DIR, f"video_from_frames_{timestamp}.mp4") |
| first_frame_img = rotate_image(cv2.imread(filenames[0]), rotation) |
| h, w, _ = first_frame_img.shape |
| if do_resize: w, h = out_w, out_h |
| w -= w % 2; h -= h % 2 |
| temp_processed_dir = os.path.join(job_temp_dir, "processed"); os.makedirs(temp_processed_dir, exist_ok=True) |
| for i, filename in enumerate(filenames): |
| frame = rotate_image(cv2.imread(filename), rotation) |
| frame = cv2.resize(frame, (w, h), interpolation=cv2.INTER_LANCZOS4) |
| cv2.imwrite(os.path.join(temp_processed_dir, f"pframe_{i:05d}.png"), frame) |
| cmd = ["ffmpeg", "-framerate", str(fps), "-i", os.path.join(temp_processed_dir, "pframe_%05d.png"), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-y", output_video_path] |
| run_ffmpeg_command(cmd, "Compiling Video") |
| shutil.rmtree(job_temp_dir) |
| return output_video_path |
|
|
| def image_to_looping_video(image_array, duration, audio_path=None): |
| if image_array is None: raise gr.Error("Please upload an image first.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| temp_image_path = os.path.join(TEMP_DIR, f"temp_image_{timestamp}.png") |
| output_video_path = os.path.join(TEMP_DIR, f"looping_video_{timestamp}.mp4") |
| |
| img = Image.fromarray(image_array) |
| img.save(temp_image_path) |
| width, height = img.size |
| width -= width % 2; height -= height % 2 |
| |
| cmd = ["ffmpeg", "-loop", "1", "-i", temp_image_path] |
| |
| if audio_path: |
| cmd.extend(["-i", audio_path, "-c:a", "aac", "-shortest"]) |
| |
| cmd.extend(["-c:v", "libx264", "-t", str(duration), "-pix_fmt", "yuv420p", "-vf", f"scale={width}:{height}", "-y", output_video_path]) |
| |
| run_ffmpeg_command(cmd, "Creating Looping Video...") |
| os.remove(temp_image_path) |
| return output_video_path |
|
|
| def create_zoom_videos(files, duration, zoom_ratio, zoom_direction, combine_videos, audio_path=None): |
| if not files: |
| raise gr.Error("Please upload at least one image.") |
|
|
| fps = 30 |
| total_frames = int(duration * fps) |
| zoom_step = (zoom_ratio - 1.0) / total_frames |
|
|
| zoom_coords = { |
| "Center": "x=iw/2-(iw/zoom)/2:y=ih/2-(ih/zoom)/2", "Top": "x=iw/2-(iw/zoom)/2:y=0", "Bottom": "x=iw/2-(iw/zoom)/2:y=ih-(ih/zoom)", |
| "Left": "x=0:y=ih/2-(ih/zoom)/2", "Right": "x=iw-(iw/zoom):y=ih/2-(ih/zoom)/2", "Top-Left": "x=0:y=0", |
| "Top-Right": "x=iw-(iw/zoom):y=0", "Bottom-Left": "x=0:y=ih-(ih/zoom)", "Bottom-Right": "x=iw-(iw/zoom):y=ih-(ih/zoom)", |
| } |
| |
| def process_single_image(input_path, output_path, **kwargs): |
| audio_for_clip = kwargs.get('audio_for_clip') |
| zoom_filter = (f"scale=3840:-1,zoompan=z='min(zoom+{zoom_step},{zoom_ratio})':{zoom_coords[zoom_direction]}:d={total_frames}:s=1920x1080:fps={fps}") |
| |
| cmd = ["ffmpeg", "-loop", "1", "-i", input_path] |
| if audio_for_clip: |
| cmd.extend(["-i", audio_for_clip, "-c:a", "aac", "-shortest"]) |
|
|
| cmd.extend(["-vf", zoom_filter, "-c:v", "libx264", "-t", str(duration), "-pix_fmt", "yuv420p", "-b:v", "5M", "-y", output_path]) |
| run_ffmpeg_command(cmd, f"Creating zoom video for {os.path.basename(input_path)}") |
|
|
| batch_kwargs = {} |
| if not combine_videos and audio_path: |
| batch_kwargs['audio_for_clip'] = audio_path |
| |
| video_paths, zip_path, job_temp_dir = batch_image_processor(files, process_single_image, "zoom_videos", **batch_kwargs) |
| |
| if not combine_videos: |
| return video_paths, None, zip_path |
|
|
| if not video_paths: |
| raise gr.Error("No videos were created to be combined.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| |
| silent_combined_path = os.path.join(job_temp_dir, f"combined_silent_{timestamp}.mp4") |
| if len(video_paths) > 1: |
| file_list_path = os.path.join(job_temp_dir, "files.txt") |
| with open(file_list_path, 'w', encoding='utf-8') as f: |
| for path in video_paths: |
| f.write(f"file '{os.path.abspath(path)}'\n") |
| run_ffmpeg_command(["ffmpeg", "-f", "concat", "-safe", "0", "-i", file_list_path, "-c", "copy", "-y", silent_combined_path], "Combining Videos") |
| else: |
| shutil.copy(video_paths[0], silent_combined_path) |
|
|
| if audio_path: |
| final_video_path = os.path.join(TEMP_DIR, f"combined_audio_{timestamp}.mp4") |
| run_ffmpeg_command(["ffmpeg", "-i", silent_combined_path, "-i", audio_path, "-c:v", "copy", "-c:a", "aac", "-shortest", "-y", final_video_path], "Adding audio...") |
| else: |
| final_video_path = os.path.join(TEMP_DIR, f"combined_final_{timestamp}.mp4") |
| shutil.move(silent_combined_path, final_video_path) |
|
|
| return None, final_video_path, zip_path |
|
|
|
|
| def change_video_speed(video_path, speed_multiplier): |
| if not video_path: raise gr.Error("Please upload a video first.") |
| if speed_multiplier <= 0: raise gr.Error("Speed multiplier must be positive.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_video_path = os.path.join(TEMP_DIR, f"speed_change_{timestamp}.mp4") |
| pts_value = 1 / speed_multiplier |
| cmd = ["ffmpeg", "-i", video_path, "-filter:v", f"setpts={pts_value}*PTS", "-an", "-y", output_video_path] |
| run_ffmpeg_command(cmd, "Changing Video Speed") |
| return output_video_path |
|
|
| def _get_atempo_filter_string(speed): |
| """Helper function to create a chained atempo filter string for FFMPEG.""" |
| filters = [] |
| |
| if speed > 100.0: |
| while speed > 100.0: |
| filters.append("atempo=100.0") |
| speed /= 100.0 |
| elif speed < 0.5: |
| while speed < 0.5: |
| filters.append("atempo=0.5") |
| speed /= 0.5 |
| |
| |
| if speed != 1.0: |
| filters.append(f"atempo={speed}") |
| |
| return ",".join(filters) if filters else None |
|
|
| def process_slowmo_enhance_video(video_path, output_path, slowdown_factor, method, progress): |
| """ |
| Processes a single video for slow-motion and enhancement. |
| """ |
| if not video_path: |
| raise gr.Error("Missing video path for processing.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"slowmo_{os.path.basename(video_path)}_{timestamp}") |
| os.makedirs(job_temp_dir, exist_ok=True) |
| |
| original_fps = get_video_fps(video_path) |
| final_fps = original_fps * slowdown_factor |
| has_audio = has_audio_stream(video_path) |
| |
| try: |
| if method == "AI-Enhanced (High Quality)": |
| input_frames_dir = os.path.join(job_temp_dir, "input_frames") |
| processed_frames_dir = os.path.join(job_temp_dir, "processed_frames") |
| os.makedirs(input_frames_dir, exist_ok=True) |
| os.makedirs(processed_frames_dir, exist_ok=True) |
| |
| load_enhance_ai_models() |
| |
| progress(0.1, desc="Extracting frames...") |
| run_ffmpeg_command(["ffmpeg", "-i", video_path, os.path.join(input_frames_dir, "frame_%06d.png")]) |
| |
| input_frames = sorted([os.path.join(input_frames_dir, f) for f in os.listdir(input_frames_dir)]) |
| if not input_frames: |
| raise gr.Error("Could not extract any frames from the video.") |
|
|
| progress(0.3, desc="AI Interpolating frames (This can be slow)...") |
| for i in progress.tqdm(range(len(input_frames) - 1), unit="frame pairs"): |
| frame0 = cv2.imread(input_frames[i]) |
| frame1 = cv2.imread(input_frames[i+1]) |
| |
| shutil.copy(input_frames[i], os.path.join(processed_frames_dir, f"proc_{i:06d}_0.png")) |
| |
| interpolated_frames = rife_model.process(frame0, frame1, count=slowdown_factor-1) |
| for j, int_frame in enumerate(interpolated_frames): |
| cv2.imwrite(os.path.join(processed_frames_dir, f"proc_{i:06d}_{j+1}.png"), int_frame) |
| |
| shutil.copy(input_frames[-1], os.path.join(processed_frames_dir, f"proc_{len(input_frames)-1:06d}_0.png")) |
|
|
| progress(0.8, desc="Compiling final video...") |
| silent_video_path = os.path.join(job_temp_dir, "silent_video.mp4") |
| |
| cmd = ["ffmpeg", "-framerate", str(original_fps), "-pattern_type", "glob", "-i", os.path.join(processed_frames_dir, "*.png"), "-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p", "-y", silent_video_path] |
| run_ffmpeg_command(cmd) |
|
|
| if has_audio: |
| progress(0.9, desc="Attaching slowed audio...") |
| atempo_filter_str = _get_atempo_filter_string(1.0 / slowdown_factor) |
| cmd_audio = ["ffmpeg", "-i", silent_video_path, "-i", video_path, "-filter:a", atempo_filter_str, "-c:v", "copy", "-map", "0:v:0", "-map", "1:a:0", "-y", output_path] |
| run_ffmpeg_command(cmd_audio) |
| else: |
| shutil.move(silent_video_path, output_path) |
|
|
| elif method == "Standard (Fast)": |
| progress(0.5, desc="Processing with FFMPEG filter...") |
| cmd = ["ffmpeg", "-y", "-i", video_path] |
| |
| vf_filter_chain = f"minterpolate=fps={final_fps}:mi_mode=mci,setpts={float(slowdown_factor)}*PTS" |
| cmd.extend(["-vf", vf_filter_chain]) |
| |
| if has_audio: |
| atempo_filter_str = _get_atempo_filter_string(1.0 / slowdown_factor) |
| if atempo_filter_str: |
| cmd.extend(["-af", atempo_filter_str]) |
| else: |
| cmd.append("-an") |
| |
| cmd.extend(["-r", str(original_fps)]) |
| cmd.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", output_path]) |
| run_ffmpeg_command(cmd) |
|
|
| finally: |
| if os.path.exists(job_temp_dir): |
| shutil.rmtree(job_temp_dir) |
|
|
| def batch_slowmo_enhance_videos(videos, slowdown_factor_str, method, progress=gr.Progress(track_tqdm=True)): |
| if not videos: |
| raise gr.Error("Please upload at least one video.") |
| |
| slowdown_factor = int(slowdown_factor_str.replace('x', '')) |
|
|
| if "AI-Enhanced" in method and not ENHANCE_AI_AVAILABLE: |
| raise gr.Error("AI-Enhanced method is not available. Please install 'rife-ncnn-vulkan-python' and restart the app.") |
| |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"slowmo_batch_{timestamp}") |
| os.makedirs(job_temp_dir, exist_ok=True) |
| |
| output_paths = [] |
| |
| for i, video_file in enumerate(videos): |
| progress(i / len(videos), desc=f"Processing video {i+1}/{len(videos)}: {os.path.basename(video_file.name)}") |
| base, _ = os.path.splitext(os.path.basename(video_file.name)) |
| output_path = os.path.join(job_temp_dir, f"{base}_slowmo_{slowdown_factor}x.mp4") |
| |
| process_slowmo_enhance_video(video_file.name, output_path, slowdown_factor, method, progress) |
| output_paths.append(output_path) |
| |
| if not output_paths: |
| shutil.rmtree(job_temp_dir) |
| raise gr.Error("No videos could be processed from the batch.") |
| |
| zip_base_name = os.path.join(TEMP_DIR, f"slowmo_archive_{timestamp}") |
| zip_path = shutil.make_archive(zip_base_name, 'zip', job_temp_dir) |
| |
| return output_paths, zip_path |
| |
| def change_audio_speed(audio_path, speed_multiplier): |
| if not audio_path: |
| raise gr.Error("Please upload an audio file.") |
| if speed_multiplier <= 0: |
| raise gr.Error("Speed multiplier must be a positive number.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| |
| _, extension = os.path.splitext(os.path.basename(audio_path)) |
| if not extension: extension = ".mp3" |
| output_audio_path = os.path.join(TEMP_DIR, f"audio_speed_{speed_multiplier}x_{timestamp}{extension}") |
|
|
| atempo_filter_str = _get_atempo_filter_string(speed_multiplier) |
|
|
| if not atempo_filter_str: |
| |
| gr.Info("No speed change applied (multiplier is 1.0).") |
| shutil.copy(audio_path, output_audio_path) |
| return output_audio_path |
| |
| cmd = ["ffmpeg", "-i", audio_path, "-filter:a", atempo_filter_str, "-y", output_audio_path] |
| |
| run_ffmpeg_command(cmd, "Changing audio speed...") |
| return output_audio_path |
|
|
| |
| def chop_audio_on_silence(audio_path, silence_thresh, min_silence_len, progress=gr.Progress(track_tqdm=True)): |
| if not audio_path: |
| raise gr.Error("Please upload an audio file to chop.") |
|
|
| progress(0, desc="Loading audio file...") |
| try: |
| sound = AudioSegment.from_file(audio_path) |
| except Exception as e: |
| raise gr.Error(f"Could not read audio file. It may be corrupt or in an unsupported format. Details: {e}") |
|
|
| progress(0.2, desc="Detecting non-silent chunks...") |
| |
| audio_chunks = split_on_silence( |
| sound, |
| min_silence_len=int(min_silence_len), |
| silence_thresh=int(silence_thresh), |
| keep_silence=200 |
| ) |
|
|
| if not audio_chunks: |
| raise gr.Error("No audio chunks were found above the silence threshold. Try using a lower (more negative) threshold value.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"audio_chop_{timestamp}") |
| os.makedirs(job_temp_dir, exist_ok=True) |
| |
| output_paths = [] |
|
|
| for i, chunk in enumerate(progress.tqdm(audio_chunks, desc="Exporting chunks...")): |
| output_path = os.path.join(job_temp_dir, f"chunk_{i:04d}.mp3") |
| chunk.export(output_path, format="mp3") |
| output_paths.append(output_path) |
|
|
| if not output_paths: |
| shutil.rmtree(job_temp_dir) |
| raise gr.Error("Failed to export any audio chunks.") |
| |
| zip_base_name = os.path.join(TEMP_DIR, f"audio_chop_archive_{timestamp}") |
| zip_path = shutil.make_archive(zip_base_name, 'zip', job_temp_dir) |
| |
| |
| return output_paths, zip_path |
|
|
| |
| def reverse_video(video_path, audio_option): |
| if not video_path: raise gr.Error("Please upload a video first.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_video_path = os.path.join(TEMP_DIR, f"reversed_video_{timestamp}.mp4") |
| filters = ["reverse"] |
| if audio_option == "Reverse Audio": filters.append("areverse") |
| cmd = ["ffmpeg", "-i", video_path, "-vf", filters[0]] |
| if len(filters) > 1: cmd.extend(["-af", filters[1]]) |
| if audio_option == "Remove Audio": cmd.append("-an") |
| cmd.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-y", output_video_path]) |
| run_ffmpeg_command(cmd, "Reversing video...") |
| return output_video_path |
|
|
| def add_audio_to_video(video_path, audio_path): |
| if not video_path: raise gr.Error("Please upload a video.") |
| if not audio_path: raise gr.Error("Please upload an audio file.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_video_path = os.path.join(TEMP_DIR, f"video_with_audio_{timestamp}.mp4") |
| cmd = ["ffmpeg", "-i", video_path, "-i", audio_path, "-c:v", "copy", "-c:a", "aac", "-shortest", "-y", output_video_path] |
| run_ffmpeg_command(cmd, "Adding Audio to Video") |
| return output_video_path |
|
|
| def extract_audio(video_path, audio_format="mp3"): |
| if not video_path: raise gr.Error("Please upload a video first.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_audio_path = os.path.join(TEMP_DIR, f"extracted_audio_{timestamp}.{audio_format}") |
| cmd = ["ffmpeg", "-i", video_path, "-vn"] |
| if audio_format == "mp3": cmd.extend(["-c:a", "libmp3lame", "-q:a", "2"]) |
| elif audio_format == "aac": cmd.extend(["-c:a", "aac", "-b:a", "192k"]) |
| elif audio_format == "wav": cmd.extend(["-c:a", "pcm_s16le"]) |
| cmd.extend(["-y", output_audio_path]) |
| run_ffmpeg_command(cmd, "Extracting audio...") |
| return output_audio_path |
|
|
| def create_gif_from_video(video_path, start_time, end_time): |
| if not video_path: raise gr.Error("Please upload a video first.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_gif_path, palette_path = os.path.join(TEMP_DIR, f"video_to_gif_{timestamp}.gif"), os.path.join(TEMP_DIR, f"palette_{timestamp}.png") |
| duration_filter = [] |
| if start_time > 0 or end_time > 0: |
| if end_time > 0 and end_time <= start_time: raise gr.Error("End time must be after start time.") |
| if start_time > 0: duration_filter.extend(["-ss", str(start_time)]) |
| if end_time > 0: duration_filter.extend(["-to", str(end_time)]) |
| |
| run_ffmpeg_command(["ffmpeg", "-i", video_path] + duration_filter + ["-vf", "fps=15,scale=480:-1:flags=lanczos,palettegen", "-y", palette_path]) |
| run_ffmpeg_command(["ffmpeg", "-i", video_path] + duration_filter + ["-i", palette_path, "-filter_complex", "fps=15,scale=480:-1:flags=lanczos[x];[x][1:v]paletteuse", "-y", output_gif_path]) |
| |
| os.remove(palette_path) |
| return output_gif_path |
|
|
| def get_frame_at_time(video_path, time_in_seconds=0): |
| if not video_path: return None |
| try: |
| command = ['ffmpeg', '-ss', str(time_in_seconds), '-i', video_path, '-vframes', '1', '-f', 'image2pipe', '-c:v', 'png', '-'] |
| pipe = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) |
| return Image.open(io.BytesIO(pipe.stdout)).convert("RGB") |
| except Exception as e: |
| print(f"Error extracting frame for crop preview: {e}") |
| cap = cv2.VideoCapture(video_path); cap.set(cv2.CAP_PROP_POS_MSEC, time_in_seconds * 1000) |
| success, frame = cap.read(); cap.release() |
| if success: return Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) |
| return None |
|
|
| def crop_video(video_path, x, y, w, h, do_resize, out_w, out_h): |
| if not video_path: raise gr.Error("Please upload a video first.") |
| w, h, x, y = int(w), int(h), int(x), int(y) |
| w -= w % 2; h -= h % 2 |
| if w <= 0 or h <= 0: raise gr.Error("Crop dimensions must be positive.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_video_path = os.path.join(TEMP_DIR, f"cropped_video_{timestamp}.mp4") |
| vf_filters = [f"crop={w}:{h}:{x}:{y}"] |
| if do_resize: |
| if out_w <= 0 or out_h <= 0: raise gr.Error("Resize dimensions must be positive.") |
| out_w, out_h = int(out_w), int(out_h) |
| out_w -= out_w % 2; out_h -= out_h % 2 |
| vf_filters.append(f"scale={out_w}:{out_h}") |
| cmd = ["ffmpeg", "-i", video_path, "-vf", ",".join(vf_filters), "-c:a", "copy", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-y", output_video_path] |
| run_ffmpeg_command(cmd, "Cropping video...") |
| return output_video_path |
|
|
| def trim_video(video_path, start_time, end_time): |
| if not video_path: raise gr.Error("Please upload a video first.") |
| if start_time < 0: start_time = 0 |
| if end_time <= start_time: end_time = 0 |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_video_path = os.path.join(TEMP_DIR, f"trimmed_video_{timestamp}.mp4") |
| cmd = ["ffmpeg", "-i", video_path, "-ss", str(start_time)] |
| if end_time > 0: cmd.extend(["-to", str(end_time)]) |
| cmd.extend(["-c:v", "libx264", "-c:a", "copy", "-pix_fmt", "yuv420p", "-y", output_video_path]) |
| run_ffmpeg_command(cmd, "Trimming Video") |
| return output_video_path |
|
|
| def apply_video_watermark(video_path, text, position, opacity, size_scale, color): |
| if not video_path: raise gr.Error("Please upload a video first.") |
| if not text: raise gr.Error("Watermark text cannot be empty.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_video_path = os.path.join(TEMP_DIR, f"watermarked_video_{timestamp}.mp4") |
|
|
| _ , video_h = get_video_dimensions(video_path) |
| if video_h == 0: |
| video_h = 720 |
|
|
| escaped_text = text.replace("'", r"'\''").replace(":", r"\:").replace(",", r"\,") |
| pos_map = {"Top-Left": "x=20:y=20", "Top-Right": "x=w-tw-20:y=20", "Bottom-Left": "x=20:y=h-th-20", "Bottom-Right": "x=w-tw-20:y=h-th-20", "Center": "x=(w-tw)/2:y=(h-th)/2"} |
| font_opacity = opacity / 100.0 |
| font_size = int(video_h / (50 - (size_scale * 3.5))) |
|
|
| drawtext_filter = ( |
| f"drawtext=" |
| f"text='{escaped_text}':" |
| f"{pos_map[position]}:" |
| f"fontsize={font_size}:" |
| f"fontcolor={color}@{font_opacity}" |
| ) |
|
|
| cmd = [ |
| "ffmpeg", "-i", video_path, |
| "-vf", drawtext_filter, |
| "-c:a", "copy", |
| "-c:v", "libx264", |
| "-pix_fmt", "yuv420p", |
| "-y", output_video_path |
| ] |
| run_ffmpeg_command(cmd, "Applying text watermark...") |
| return output_video_path |
| |
| def remove_video_background(video_path): |
| if not video_path: raise gr.Error("Please upload a video first.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"bg_rem_job_{timestamp}"); input_frames_dir, output_frames_dir = os.path.join(job_temp_dir, "input_frames"), os.path.join(job_temp_dir, "output_frames") |
| os.makedirs(input_frames_dir, exist_ok=True); os.makedirs(output_frames_dir, exist_ok=True) |
| cap = cv2.VideoCapture(video_path); frame_count, fps = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)), get_video_fps(video_path) |
| for i in range(frame_count): |
| success, frame = cap.read() |
| if not success: break |
| cv2.imwrite(os.path.join(input_frames_dir, f"frame_{i:05d}.png"), frame) |
| cap.release() |
| for filename in sorted(os.listdir(input_frames_dir)): |
| with Image.open(os.path.join(input_frames_dir, filename)) as img: |
| remove(img).save(os.path.join(output_frames_dir, filename)) |
| output_video_path = os.path.join(TEMP_DIR, f"bg_removed_{timestamp}.webm") |
| cmd = ["ffmpeg", "-framerate", str(fps), "-i", os.path.join(output_frames_dir, "frame_%05d.png"), "-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-auto-alt-ref", "0", "-b:v", "1M", "-y", output_video_path] |
| run_ffmpeg_command(cmd, "Compiling transparent video...") |
| shutil.rmtree(job_temp_dir) |
| return output_video_path |
|
|
| def generate_ass_from_whisper(result): |
| """Generates an ASS subtitle file content from a Whisper result object with word timestamps.""" |
| ass_content = [ |
| "[Script Info]", |
| "Title: Generated by Skriptz", |
| "ScriptType: v4.00+", |
| "WrapStyle: 0", |
| "PlayResX: 1920", |
| "PlayResY: 1080", |
| "\n[V4+ Styles]", |
| "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding", |
| "Style: Default,Arial,55,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,2,10,10,25,1", |
| "\n[Events]", |
| "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text" |
| ] |
|
|
| def format_time(s): |
| h, r = divmod(s, 3600) |
| m, s = divmod(r, 60) |
| cs = int((s - int(s)) * 100) |
| return f"{int(h)}:{int(m):02}:{int(s):02}.{cs:02}" |
|
|
| for segment in result['segments']: |
| start_time = format_time(segment['start']) |
| end_time = format_time(segment['end']) |
| |
| karaoke_line = "" |
| for word_info in segment['words']: |
| word = word_info['word'].strip() |
| duration_cs = int((word_info['end'] - word_info['start']) * 100) |
| karaoke_line += f"{{\\k{duration_cs}}}{word} " |
| |
| dialogue_line = f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{karaoke_line.strip()}" |
| ass_content.append(dialogue_line) |
|
|
| return "\n".join(ass_content) |
|
|
| def transcribe_media(media_path, model_name): |
| if media_path is None: raise gr.Error("Please upload a video or audio file first.") |
| model = load_whisper_model(model_name) |
| if model is None: raise gr.Error("Whisper model is not available.") |
| |
| audio_path = media_path.name |
| base_name = os.path.splitext(os.path.basename(media_path.name))[0] |
| |
| if audio_path.lower().endswith(('.mp4', '.mov', '.mkv', '.avi', '.webm')): |
| audio_path_temp = os.path.join(TEMP_DIR, f"{base_name}.mp3") |
| try: |
| run_ffmpeg_command(["ffmpeg", "-y", "-i", audio_path, "-q:a", "0", "-map", "a", audio_path_temp]) |
| audio_path = audio_path_temp |
| except gr.Error as e: |
| if "does not contain any stream" in str(e): raise gr.Error("The uploaded video has no audio track.") |
| else: raise e |
| |
| result = model.transcribe(audio_path, word_timestamps=True, verbose=False) |
| |
| def format_ts(s, separator=','): |
| h, r = divmod(s, 3600); m, s = divmod(r, 60) |
| return f"{int(h):02}:{int(m):02}:{int(s):02}{separator}{int((s-int(s))*1000):03}" |
| |
| srt_path = os.path.join(TEMP_DIR, f"{base_name}.srt") |
| vtt_path = os.path.join(TEMP_DIR, f"{base_name}.vtt") |
| ass_path = os.path.join(TEMP_DIR, f"{base_name}.ass") |
| |
| with open(srt_path, "w", encoding="utf-8") as srt_f, open(vtt_path, "w", encoding="utf-8") as vtt_f: |
| vtt_f.write("WEBVTT\n\n") |
| for i, seg in enumerate(result["segments"]): |
| start, end, text = seg['start'], seg['end'], seg['text'].strip() |
| srt_f.write(f"{i + 1}\n{format_ts(start)} --> {format_ts(end)}\n{text}\n\n") |
| vtt_f.write(f"{format_ts(start, '.')} --> {format_ts(end, '.')}\n{text}\n\n") |
| |
| ass_content = generate_ass_from_whisper(result) |
| with open(ass_path, "w", encoding="utf-8") as ass_f: |
| ass_f.write(ass_content) |
|
|
| return result["text"], [srt_path, vtt_path, ass_path] |
|
|
| def transcribe_and_prep_burn(media_file, model_name): |
| if not media_file: raise gr.Error("Please upload a file first.") |
| is_video = media_file.name.lower().endswith(('.mp4', '.mov', '.mkv', '.avi', '.webm')) |
| text, files = transcribe_media(media_file, model_name) |
| if is_video: return text, files, media_file.name, gr.update(visible=True) |
| else: return text, files, None, gr.update(visible=False) |
|
|
| def reformat_srt_for_word_wrap(original_srt_path, words_per_line): |
| if not original_srt_path or not os.path.exists(original_srt_path): |
| return None |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| reformatted_path = os.path.join(TEMP_DIR, f"reformatted_{timestamp}.srt") |
| |
| with open(original_srt_path, 'r', encoding='utf-8') as f_in, \ |
| open(reformatted_path, 'w', encoding='utf-8') as f_out: |
| content = f_in.read().strip().split('\n\n') |
| for block in content: |
| lines = block.split('\n') |
| if len(lines) < 3: |
| f_out.write(block + '\n\n') |
| continue |
| text_lines = ' '.join(lines[2:]) |
| words = text_lines.split() |
| new_text_lines = [] |
| current_line = [] |
| for word in words: |
| current_line.append(word) |
| if len(current_line) >= words_per_line: |
| new_text_lines.append(' '.join(current_line)) |
| current_line = [] |
| if current_line: new_text_lines.append(' '.join(current_line)) |
| |
| reformatted_text = '\n'.join(new_text_lines) |
| f_out.write(f"{lines[0]}\n{lines[1]}\n{reformatted_text}\n\n") |
| |
| return reformatted_path |
|
|
| def burn_block_subtitles(video_path, srt_file_obj, font_size_scale, font_color, words_per_line): |
| original_srt_path = srt_file_obj[0].name |
| reformatted_srt_path = None |
| try: |
| reformatted_srt_path = reformat_srt_for_word_wrap(original_srt_path, words_per_line) |
| if not reformatted_srt_path: raise gr.Error("Failed to reformat subtitle file.") |
| |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_video_path = os.path.join(TEMP_DIR, f"subtitled_video_{timestamp}.mp4") |
| _, video_h = get_video_dimensions(video_path) |
| if video_h == 0: video_h = 720 |
| divisor = 32 - (font_size_scale * 2) |
| calculated_font_size = int(video_h / divisor) |
| color_bgr = font_color[5:7] + font_color[3:5] + font_color[1:3] |
| ffmpeg_color = f"&H00{color_bgr.upper()}" |
| escaped_srt_path = reformatted_srt_path.replace('\\', '/').replace(':', r'\\:') |
| |
| vf_filter = f"subtitles='{escaped_srt_path}':force_style='Fontsize={calculated_font_size},PrimaryColour={ffmpeg_color},BorderStyle=1,Outline=1,Shadow=0.5,MarginV=25'" |
| cmd = ["ffmpeg", "-y", "-i", video_path, "-vf", vf_filter, "-c:a", "copy", "-c:v", "libx264", "-pix_fmt", "yuv420p", output_video_path] |
| run_ffmpeg_command(cmd, "Burning block subtitles into video...") |
| return output_video_path |
| finally: |
| if reformatted_srt_path and os.path.exists(reformatted_srt_path): |
| os.remove(reformatted_srt_path) |
|
|
| def burn_karaoke_subtitles(video_path, subtitle_files, font_size_scale, base_color, highlight_color): |
| ass_file_path = subtitle_files[2].name |
| temp_ass_path = None |
| try: |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| temp_ass_path = os.path.join(TEMP_DIR, f"style_applied_{timestamp}.ass") |
| output_video_path = os.path.join(TEMP_DIR, f"karaoke_video_{timestamp}.mp4") |
|
|
| _, video_h = get_video_dimensions(video_path) |
| if video_h == 0: video_h = 720 |
| calculated_font_size = int((video_h / 20) * (font_size_scale / 5)) |
|
|
| def format_ass_color(hex_color): |
| if hex_color.startswith('#'): hex_color = hex_color[1:] |
| r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6] |
| return f"&H00{b.upper()}{g.upper()}{r.upper()}" |
|
|
| primary_color_ass = format_ass_color(highlight_color) |
| secondary_color_ass = format_ass_color(base_color) |
|
|
| with open(ass_file_path, 'r', encoding='utf-8') as f_in, open(temp_ass_path, 'w', encoding='utf-8') as f_out: |
| for line in f_in: |
| if line.startswith("Style:"): |
| parts = line.split(',') |
| parts[2] = str(calculated_font_size) |
| parts[3] = secondary_color_ass |
| parts[4] = primary_color_ass |
| |
| if len(parts) > 17: |
| parts[16] = '0' |
| parts[17] = '0' |
|
|
| f_out.write(','.join(parts)) |
| else: |
| f_out.write(line) |
| |
| escaped_ass_path = temp_ass_path.replace('\\', '/').replace(':', r'\\:') |
| vf_filter = f"subtitles='{escaped_ass_path}'" |
| cmd = ["ffmpeg", "-y", "-i", video_path, "-vf", vf_filter, "-c:a", "copy", "-c:v", "libx264", "-pix_fmt", "yuv420p", output_video_path] |
| run_ffmpeg_command(cmd, "Burning karaoke subtitles into video...") |
| return output_video_path |
| finally: |
| if temp_ass_path and os.path.exists(temp_ass_path): |
| os.remove(temp_ass_path) |
| |
| def burn_subtitles_wrapper(video_path, subtitle_files, style, font_size_scale, block_font_color, block_words_per_line, kara_base_color, kara_highlight_color): |
| if not video_path or not subtitle_files: raise gr.Error("Missing video or subtitle files. Please transcribe first.") |
| |
| if style == "Block": |
| return burn_block_subtitles(video_path, subtitle_files, font_size_scale, block_font_color, block_words_per_line) |
| elif style == "Karaoke": |
| return burn_karaoke_subtitles(video_path, subtitle_files, font_size_scale, kara_base_color, kara_highlight_color) |
| else: |
| raise gr.Error("Invalid subtitle style selected.") |
|
|
| def remove_background_single(input_path, output_path, **kwargs): |
| with Image.open(input_path) as img: |
| remove(img).save(output_path) |
|
|
| def remove_background_batch(files): |
| output_paths, zip_path, _ = batch_image_processor(files, remove_background_single, "bg_removed") |
| return output_paths, zip_path |
|
|
| def resize_convert_single_image(input_path, output_path, **kwargs): |
| output_format = kwargs.get('output_format', 'JPG') |
| quality = kwargs.get('quality', 95) |
| enable_resize = kwargs.get('enable_resize', False) |
| max_w = kwargs.get('max_w', 1024) |
| max_h = kwargs.get('max_h', 1024) |
| resize_mode = kwargs.get('resize_mode', "Fit (preserve aspect ratio)") |
| |
| with Image.open(input_path) as img: |
| if output_format in ['JPG', 'WEBP'] and img.mode in ['RGBA', 'P', 'LA']: |
| img = img.convert("RGB") |
| |
| if enable_resize: |
| if resize_mode == "Fit (preserve aspect ratio)": |
| img.thumbnail((max_w, max_h), Image.Resampling.LANCZOS) |
| else: |
| img = img.resize((max_w, max_h), Image.Resampling.LANCZOS) |
| |
| save_kwargs = {} |
| pil_format = 'JPEG' if output_format == 'JPG' else output_format |
|
|
| if pil_format in ['JPEG', 'WEBP']: |
| save_kwargs['quality'] = quality |
| |
| img.save(output_path, pil_format, **save_kwargs) |
|
|
| def batch_resize_convert_images(files, output_format, quality, enable_resize, max_w, max_h, resize_mode): |
| if not files: raise gr.Error("Please upload at least one image.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_name = "resized_converted" |
| job_temp_dir = os.path.join(TEMP_DIR, f"{job_name}_{timestamp}"); os.makedirs(job_temp_dir, exist_ok=True) |
| output_paths = [] |
| |
| |
| if max_w > 0 and max_h > 0: |
| enable_resize = True |
| |
| processing_kwargs = { |
| 'output_format': output_format, 'quality': quality, 'enable_resize': enable_resize, |
| 'max_w': max_w, 'max_h': max_h, 'resize_mode': resize_mode |
| } |
| for file_obj in files: |
| try: |
| base, _ = os.path.splitext(os.path.basename(file_obj.name)) |
| output_filename = f"{base}.{output_format.lower()}" |
| output_path = os.path.join(job_temp_dir, output_filename) |
| resize_convert_single_image(file_obj.name, output_path, **processing_kwargs) |
| output_paths.append(output_path) |
| except Exception as e: print(f"Skipping file {file_obj.name} due to error: {e}"); continue |
| if not output_paths: shutil.rmtree(job_temp_dir); raise gr.Error("No images could be processed.") |
| zip_base_name = os.path.join(TEMP_DIR, f"{job_name}_archive_{timestamp}") |
| zip_path = shutil.make_archive(zip_base_name, 'zip', job_temp_dir) |
| return output_paths[:100], zip_path |
| |
| def apply_watermark_single(input_path, output_path, watermark_text, position, opacity): |
| with Image.open(input_path).convert("RGBA") as image: |
| if not watermark_text: raise ValueError("Watermark text cannot be empty.") |
| txt = Image.new("RGBA", image.size, (255, 255, 255, 0)) |
| try: font = ImageFont.truetype("DejaVuSans.ttf", int(image.width / 20)) |
| except IOError: font = ImageFont.load_default() |
| d = ImageDraw.Draw(txt); bbox = d.textbbox((0, 0), watermark_text, font=font); w, h = bbox[2]-bbox[0], bbox[3]-bbox[1] |
| pos_map = {"Top-Left":(10,10), "Top-Right":(image.width-w-10,10), "Bottom-Left":(10,image.height-h-10), "Bottom-Right":(image.width-w-10,image.height-h-10), "Center":((image.width-w)/2,(image.height-h)/2)} |
| d.text(pos_map[position], watermark_text, font=font, fill=(255, 255, 255, int(255 * (opacity / 100)))) |
| Image.alpha_composite(image, txt).convert("RGB").save(output_path) |
|
|
| def apply_watermark_batch(files, watermark_text, position, opacity): |
| if not watermark_text: raise gr.Error("Please provide watermark text.") |
| processing_func = lambda input_path, output_path: apply_watermark_single( |
| input_path, output_path, watermark_text=watermark_text, position=position, opacity=opacity |
| ) |
| output_paths, zip_path, _ = batch_image_processor(files, processing_func, "watermarked") |
| return output_paths, zip_path |
|
|
| |
|
|
| def convert_compress_video(video_path, out_format, v_codec, crf_value, scale_option, a_codec, a_bitrate, output_dir=None, base_name=None): |
| if not video_path: |
| raise gr.Error("Please upload a video to convert.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| |
| _output_dir = output_dir if output_dir else TEMP_DIR |
| _base_name = base_name if base_name else f"converted_{timestamp}" |
| |
| output_filename = f"{_base_name}.{out_format.lower()}" |
| output_path = os.path.join(_output_dir, output_filename) |
|
|
| cmd = ["ffmpeg", "-i", video_path] |
| vf_filters = [] |
|
|
| if scale_option != "Original": |
| w, h = get_video_dimensions(video_path) |
| if w > 0 and h > 0: |
| target_h = int(scale_option.replace('p', '')) |
| target_w = round(w * target_h / h / 2) * 2 |
| vf_filters.append(f"scale={target_w}:{target_h}") |
|
|
| vf_filters.append("pad=ceil(iw/2)*2:ceil(ih/2)*2") |
| vf_filters.append("setsar=1") |
|
|
| if vf_filters: |
| cmd.extend(["-vf", ",".join(vf_filters)]) |
|
|
| cmd.extend(["-c:v", v_codec]) |
| if v_codec in ["libx264", "libx265"]: |
| cmd.extend(["-crf", str(crf_value)]) |
|
|
| cmd.extend(["-pix_fmt", "yuv420p"]) |
|
|
| if has_audio_stream(video_path): |
| if a_codec == "copy": |
| cmd.extend(["-c:a", "copy"]) |
| else: |
| cmd.extend(["-c:a", a_codec, "-b:a", f"{a_bitrate}k"]) |
| else: |
| cmd.append("-an") |
|
|
| if out_format.lower() in ["mp4", "mov"]: |
| cmd.extend(["-movflags", "+faststart"]) |
|
|
| cmd.extend(["-y", output_path]) |
| run_ffmpeg_command(cmd, f"Converting {os.path.basename(video_path)}.") |
| return output_path |
|
|
| def batch_convert_compress_videos(files, out_format, v_codec, crf_value, scale_option, a_codec, a_bitrate, progress=gr.Progress(track_tqdm=True)): |
| if not files: |
| raise gr.Error("Please upload at least one video to convert.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"batch_convert_{timestamp}") |
| os.makedirs(job_temp_dir, exist_ok=True) |
| |
| output_paths = [] |
|
|
| for video_file in progress.tqdm(files, desc="Converting videos"): |
| try: |
| base_name = os.path.splitext(os.path.basename(video_file.name))[0] |
| output_path = convert_compress_video( |
| video_path=video_file.name, |
| out_format=out_format, |
| v_codec=v_codec, |
| crf_value=crf_value, |
| scale_option=scale_option, |
| a_codec=a_codec, |
| a_bitrate=a_bitrate, |
| output_dir=job_temp_dir, |
| base_name=base_name |
| ) |
| output_paths.append(output_path) |
| except Exception as e: |
| gr.Warning(f"Skipping file {os.path.basename(video_file.name)} due to an error: {e}") |
| continue |
|
|
| if not output_paths: |
| shutil.rmtree(job_temp_dir) |
| raise gr.Error("No videos could be processed from the batch.") |
|
|
| zip_base_name = os.path.join(TEMP_DIR, f"video_convert_archive_{timestamp}") |
| zip_path = shutil.make_archive(zip_base_name, 'zip', job_temp_dir) |
|
|
| return output_paths, zip_path |
|
|
| def convert_audio(media_path, out_format, a_bitrate, output_dir=None, base_name=None): |
| if not media_path: |
| raise gr.Error("Please provide a media file to convert.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| _output_dir = output_dir if output_dir else TEMP_DIR |
| _base_name = base_name if base_name else f"audio_converted_{timestamp}" |
| |
| output_filename = f"{_base_name}.{out_format.lower()}" |
| output_path = os.path.join(_output_dir, output_filename) |
|
|
| cmd = ["ffmpeg", "-i", media_path, "-vn"] |
|
|
| if out_format == "mp3": |
| cmd.extend(["-c:a", "libmp3lame", "-b:a", f"{a_bitrate}k"]) |
| elif out_format == "aac": |
| cmd.extend(["-c:a", "aac", "-b:a", f"{a_bitrate}k"]) |
| elif out_format == "ogg": |
| cmd.extend(["-c:a", "libopus", "-b:a", f"{a_bitrate}k"]) |
| elif out_format == "wav": |
| cmd.extend(["-c:a", "pcm_s16le"]) |
| elif out_format == "flac": |
| cmd.extend(["-c:a", "flac"]) |
|
|
| cmd.extend(["-y", output_path]) |
| run_ffmpeg_command(cmd, f"Converting audio from {os.path.basename(media_path)}...") |
| return output_path |
|
|
| def batch_convert_audio(files, out_format, a_bitrate, progress=gr.Progress(track_tqdm=True)): |
| if not files: |
| raise gr.Error("Please upload at least one file to convert.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"batch_audio_convert_{timestamp}") |
| os.makedirs(job_temp_dir, exist_ok=True) |
| |
| output_paths = [] |
|
|
| for media_file in progress.tqdm(files, desc="Converting audio"): |
| try: |
| is_video = get_file_type(media_file.name) == 'video' |
| if is_video and not has_audio_stream(media_file.name): |
| gr.Warning(f"Skipping video '{os.path.basename(media_file.name)}' as it has no audio track.") |
| continue |
|
|
| base_name = os.path.splitext(os.path.basename(media_file.name))[0] |
| output_path = convert_audio( |
| media_path=media_file.name, |
| out_format=out_format, |
| a_bitrate=a_bitrate, |
| output_dir=job_temp_dir, |
| base_name=base_name |
| ) |
| output_paths.append(output_path) |
| except Exception as e: |
| gr.Warning(f"Skipping file {os.path.basename(media_file.name)} due to an error: {e}") |
| continue |
|
|
| if not output_paths: |
| shutil.rmtree(job_temp_dir) |
| raise gr.Error("No files could be processed from the batch.") |
|
|
| zip_base_name = os.path.join(TEMP_DIR, f"audio_convert_archive_{timestamp}") |
| zip_path = shutil.make_archive(zip_base_name, 'zip', job_temp_dir) |
| |
| return output_paths[0], zip_path |
|
|
| |
| |
| def apply_video_fade(video_path, fade_in_duration, fade_out_duration): |
| if not video_path: raise gr.Error("Please upload a video.") |
| video_duration = get_media_duration(video_path) |
| if fade_in_duration + fade_out_duration > video_duration: raise gr.Error("The sum of fade durations cannot be greater than the video duration.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_video_path = os.path.join(TEMP_DIR, f"faded_video_{timestamp}.mp4") |
| fade_filters = [] |
| if fade_in_duration > 0: fade_filters.append(f"fade=t=in:st=0:d={fade_in_duration}") |
| if fade_out_duration > 0: fade_out_start = video_duration - fade_out_duration; fade_filters.append(f"fade=t=out:st={fade_out_start}:d={fade_out_duration}") |
| if not fade_filters: gr.Info("No fade applied."); return video_path |
| cmd = ["ffmpeg", "-i", video_path, "-vf", ",".join(fade_filters), "-c:a", "copy", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-y", output_video_path] |
| run_ffmpeg_command(cmd, "Applying video fade...") |
| return output_video_path |
|
|
| |
| def preview_color_grading_ffmpeg(image_np, brightness, contrast, saturation, sharpness): |
| """Applies color grading to a single frame using FFMPEG for an accurate preview.""" |
| if image_np is None: |
| return None |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") |
| input_path = os.path.join(TEMP_DIR, f"cg_preview_in_{timestamp}.png") |
| output_path = os.path.join(TEMP_DIR, f"cg_preview_out_{timestamp}.png") |
|
|
| try: |
| Image.fromarray(image_np).save(input_path) |
|
|
| eq_filters, other_filters = [], [] |
| if brightness != 0.0: eq_filters.append(f"brightness={brightness}") |
| if contrast != 1.0: eq_filters.append(f"contrast={contrast}") |
| if saturation != 1.0: eq_filters.append(f"saturation={saturation}") |
| if sharpness > 0.0: other_filters.append(f"unsharp=5:5:{sharpness}") |
|
|
| vf_parts = [] |
| if eq_filters: vf_parts.append("eq=" + ":".join(eq_filters)) |
| if other_filters: vf_parts.extend(other_filters) |
|
|
| if not vf_parts: |
| return Image.fromarray(image_np) |
|
|
| vf_string = ",".join(vf_parts) |
| cmd = ["ffmpeg", "-i", input_path, "-vf", vf_string, "-y", output_path] |
| |
| subprocess.run(cmd, capture_output=True, text=True, check=False) |
|
|
| if os.path.exists(output_path): |
| with Image.open(output_path) as img: |
| return img.copy() |
| else: |
| return Image.fromarray(image_np) |
|
|
| except Exception as e: |
| print(f"Error in FFMPEG preview: {e}") |
| return Image.fromarray(image_np) |
| finally: |
| if os.path.exists(input_path): os.remove(input_path) |
| if os.path.exists(output_path): os.remove(output_path) |
|
|
| def apply_color_grading(video_path, brightness, contrast, saturation, sharpness): |
| """Applies color grading to a full video using FFMPEG.""" |
| if not video_path: |
| raise gr.Error("Please upload a video first.") |
| |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_video_path = os.path.join(TEMP_DIR, f"graded_video_{timestamp}.mp4") |
|
|
| eq_filters, other_filters = [], [] |
| if brightness != 0.0: eq_filters.append(f"brightness={brightness}") |
| if contrast != 1.0: eq_filters.append(f"contrast={contrast}") |
| if saturation != 1.0: eq_filters.append(f"saturation={saturation}") |
| if sharpness > 0.0: other_filters.append(f"unsharp=5:5:{sharpness}") |
|
|
| vf_parts = [] |
| if eq_filters: vf_parts.append("eq=" + ":".join(eq_filters)) |
| if other_filters: vf_parts.extend(other_filters) |
|
|
| if not vf_parts: |
| gr.Info("No adjustments made. Returning original video path.") |
| return video_path |
|
|
| vf_string = ",".join(vf_parts) |
| |
| cmd = ["ffmpeg", "-i", video_path, "-vf", vf_string, "-c:a", "copy", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-y", output_video_path] |
| run_ffmpeg_command(cmd, "Applying Color Grading...") |
| return output_video_path |
| |
|
|
| def trim_and_fade_audio(audio_path, start_time, end_time, fade_in_duration, fade_out_duration): |
| if not audio_path: raise gr.Error("Please upload an audio file.") |
| audio_duration = get_media_duration(audio_path) |
| if start_time < 0: start_time = 0 |
| if end_time <= 0 or end_time > audio_duration: end_time = audio_duration |
| if start_time >= end_time: raise gr.Error("Start time must be less than end time.") |
| trimmed_duration = end_time - start_time |
| if fade_in_duration + fade_out_duration > trimmed_duration: raise gr.Error("Sum of fade durations cannot be greater than the trimmed audio duration.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_audio_path = os.path.join(TEMP_DIR, f"edited_audio_{timestamp}.mp3") |
| af_filters = [] |
| if fade_in_duration > 0: af_filters.append(f"afade=t=in:st=0:d={fade_in_duration}") |
| if fade_out_duration > 0: fade_out_start = trimmed_duration - fade_out_duration; af_filters.append(f"afade=t=out:st={fade_out_start}:d={fade_out_duration}") |
| cmd = ["ffmpeg", "-ss", str(start_time), "-to", str(end_time), "-i", audio_path] |
| if af_filters: cmd.extend(["-af", ",".join(af_filters)]) |
| cmd.extend(["-y", output_audio_path]) |
| run_ffmpeg_command(cmd, "Trimming and fading audio...") |
| return output_audio_path |
|
|
| |
|
|
| def create_gradual_ramp_video(video_path, progress=gr.Progress(track_tqdm=True)): |
| """ |
| Creates a video with a gradual speed ramp: 1x -> 0.5x -> 1x. |
| The effect is applied over the entire duration of the video. |
| This uses a piecewise approximation with frame interpolation for a smooth result. |
| |
| --- ROBUSTNESS ENHANCEMENT --- |
| A hybrid approach is used: |
| 1. For videos < 2.0s: A simplified, robust method applies an *average* speed change across |
| the whole clip. This avoids errors from creating many tiny, unstable segments. |
| 2. For videos >= 2.0s: The original advanced segmentation logic is used to create a |
| more detailed and noticeable ramp effect. |
| """ |
| if not video_path: |
| raise gr.Error("Please upload a video to process.") |
|
|
| progress(0, desc="Analyzing video properties...") |
| duration = get_media_duration(video_path) |
| |
| if duration == 0: |
| raise gr.Error("Could not determine video duration. The file may be corrupt.") |
| |
| fps = get_video_fps(video_path) |
| has_audio = has_audio_stream(video_path) |
| |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_video_path = os.path.join(TEMP_DIR, f"gradual_ramp_{timestamp}.mp4") |
|
|
| |
| |
| |
| if duration < 2.0: |
| gr.Info("Video is short (< 2s). Applying a simplified, robust ramp effect.") |
| progress(0.2, desc="Applying simplified ramp for short video...") |
| |
| |
| |
| avg_speed = 2.0 / 3.0 |
| |
| |
| interpolated_fps = fps / avg_speed |
| |
| filter_complex_parts = [] |
| |
| |
| video_filter = f"[0:v]minterpolate=fps={interpolated_fps}:mi_mode=mci,setpts=PTS/{avg_speed}[vout]" |
| filter_complex_parts.append(video_filter) |
| |
| |
| if has_audio: |
| atempo_str = _get_atempo_filter_string(avg_speed) |
| audio_filter = f"[0:a]asetpts=PTS" |
| if atempo_str: |
| audio_filter += f",{atempo_str}" |
| audio_filter += f"[aout]" |
| filter_complex_parts.append(audio_filter) |
| |
| filter_complex_str = ";".join(filter_complex_parts) |
|
|
| |
| cmd = ["ffmpeg", "-y", "-i", video_path, "-filter_complex", filter_complex_str, "-map", "[vout]"] |
| if has_audio: |
| cmd.extend(["-map", "[aout]"]) |
| cmd.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", output_video_path]) |
| |
| progress(0.6, desc="Executing simplified FFMPEG command...") |
| run_ffmpeg_command(cmd, "Applying simplified speed ramp...") |
| return output_video_path |
|
|
| |
| progress(0.1, desc="Planning detailed speed ramp...") |
|
|
| |
| |
| target_segment_duration = 0.25 |
| num_segments = int(duration / target_segment_duration) |
| if num_segments % 2 != 0: |
| num_segments += 1 |
| num_segments = max(10, min(num_segments, 60)) |
| |
| min_speed = 0.5 |
| half_segments = num_segments / 2.0 |
| |
| filter_complex_parts = [] |
| video_outputs, audio_outputs = [], [] |
| |
| |
| for i in progress.tqdm(range(num_segments), desc="Building FFMPEG filter command..."): |
| start_time = i * duration / num_segments |
| end_time = (i + 1) * duration / num_segments |
|
|
| |
| x = (i - half_segments + 0.5) / half_segments |
| speed = (1.0 - min_speed) * (x ** 2) + min_speed |
| speed = max(0.01, speed) |
|
|
| |
| interpolated_fps_seg = fps / speed |
| setpts_val_seg = 1.0 / speed |
| video_filter = ( |
| f"[0:v]trim=start={start_time}:end={end_time},setpts=PTS-STARTPTS," |
| f"minterpolate=fps={interpolated_fps_seg}:mi_mode=mci," |
| f"setpts={setpts_val_seg}*PTS[v{i}]" |
| ) |
| filter_complex_parts.append(video_filter) |
| video_outputs.append(f"[v{i}]") |
|
|
| |
| if has_audio: |
| atempo_str_seg = _get_atempo_filter_string(speed) |
| audio_filter = f"[0:a]atrim=start={start_time}:end={end_time},asetpts=PTS-STARTPTS" |
| if atempo_str_seg: |
| audio_filter += f",{atempo_str_seg}" |
| audio_filter += f"[a{i}]" |
| |
| filter_complex_parts.append(audio_filter) |
| audio_outputs.append(f"[a{i}]") |
|
|
| progress(0.5, desc="Finalizing filter command...") |
| |
| |
| concat_filter_v = f"{''.join(video_outputs)}concat=n={num_segments}:v=1:a=0[vout]" |
| filter_complex_parts.append(concat_filter_v) |
|
|
| if has_audio and audio_outputs: |
| concat_filter_a = f"{''.join(audio_outputs)}concat=n={num_segments}:v=0:a=1[aout]" |
| filter_complex_parts.append(concat_filter_a) |
|
|
| filter_complex_str = ";".join(filter_complex_parts) |
|
|
| |
| cmd = ["ffmpeg", "-y", "-i", video_path, "-filter_complex", filter_complex_str, "-map", "[vout]"] |
| if has_audio and audio_outputs: |
| cmd.extend(["-map", "[aout]"]) |
| cmd.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", output_video_path]) |
| |
| progress(0.6, desc="Executing FFMPEG... This may take a while.") |
| run_ffmpeg_command(cmd, "Applying gradual speed ramp...") |
|
|
| return output_video_path |
| |
| |
| FLUX_MODELS = {"FLUX.1-schnell (Fast)": "black-forest-labs/FLUX.1-schnell", "FLUX.1-dev (High Quality)": "black-forest-labs/FLUX.1-dev"} |
| def call_flux_api(prompt, model_choice, width, height, hf_token): |
| if not hf_token: raise gr.Error("Hugging Face User Access Token is required.") |
| try: |
| client = Client(FLUX_MODELS[model_choice], hf_token=hf_token) |
| return client.predict(prompt=prompt, seed=0, randomize_seed=True, width=width, height=height, num_inference_steps=8 if "dev" in model_choice else 4, api_name="/infer")[0] |
| except Exception as e: raise gr.Error(f"API call failed: {e}") |
| def get_image_as_base64(path): |
| try: |
| with open(path, "rb") as f: return f"data:image/png;base64,{base64.b64encode(f.read()).decode('utf-8')}" |
| except FileNotFoundError: return None |
|
|
| |
| def filter_presets(query, all_presets): |
| if not query: |
| return gr.update(choices=sorted(list(all_presets.keys()))) |
| |
| filtered_keys = [key for key in all_presets.keys() if query.lower() in key.lower()] |
| return gr.update(choices=sorted(filtered_keys)) |
|
|
| def save_preset(presets, name, url): |
| if not name or not name.strip(): |
| gr.Warning("Preset name cannot be empty."); return presets, gr.update() |
| if not url or not url.strip(): |
| gr.Warning("Target URL cannot be empty."); return presets, gr.update() |
| |
| presets[name] = url |
| gr.Info(f"Preset '{name}' saved!") |
| return presets, gr.update(choices=sorted(list(presets.keys()))) |
|
|
| def delete_preset(presets, name): |
| if name in presets: |
| del presets[name] |
| gr.Info(f"Preset '{name}' deleted!") |
| return presets, gr.update(choices=sorted(list(presets.keys())), value=None), "" |
| |
| gr.Warning(f"Preset '{name}' not found.") |
| return presets, gr.update(), gr.update() |
|
|
| def load_preset(presets, name): |
| return presets.get(name, "") |
|
|
|
|
| |
| def ping_pong_video(video_path, audio_option): |
| if not video_path: raise gr.Error("Please upload a video.") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"pingpong_{timestamp}"); os.makedirs(job_temp_dir, exist_ok=True) |
| reversed_video_path = os.path.join(job_temp_dir, "reversed_temp.mp4") |
| cmd_reverse = ["ffmpeg", "-i", video_path, "-vf", "reverse"] |
| if audio_option == "Reverse Audio": cmd_reverse.extend(["-af", "areverse"]) |
| else: cmd_reverse.append("-an") |
| cmd_reverse.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-y", reversed_video_path]) |
| run_ffmpeg_command(cmd_reverse) |
| file_list_path = os.path.join(job_temp_dir, "files.txt") |
| with open(file_list_path, 'w', encoding='utf-8') as f: |
| f.write(f"file '{os.path.abspath(video_path)}'\n") |
| f.write(f"file '{os.path.abspath(reversed_video_path)}'\n") |
| output_video_path = os.path.join(TEMP_DIR, f"pingpong_video_{timestamp}.mp4") |
| cmd_join = ["ffmpeg", "-f", "concat", "-safe", "0", "-i", file_list_path, "-c", "copy", "-y", output_video_path] |
| if audio_option == "Original Audio Only": |
| cmd_join = ["ffmpeg", "-i", video_path, "-i", reversed_video_path, "-filter_complex", "[0:v][1:v]concat=n=2:v=1[v]", "-map", "[v]", "-map", "0:a?", "-c:a", "copy", "-y", output_video_path] |
| run_ffmpeg_command(cmd_join) |
| shutil.rmtree(job_temp_dir) |
| return output_video_path |
|
|
|
|
| |
| def stabilize_video(video_path, shakiness, smoothing): |
| """ |
| Stabilizes a video using a two-pass FFMPEG process. |
| Pass 1: Detects motion vectors. |
| Pass 2: Uses the motion vectors to smooth the video. |
| """ |
| if not video_path: |
| raise gr.Error("Please upload a video to stabilize.") |
| |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| transforms_path = os.path.join(TEMP_DIR, f"transforms_{timestamp}.trf") |
| output_video_path = os.path.join(TEMP_DIR, f"stabilized_{timestamp}.mp4") |
|
|
| try: |
| |
| detect_cmd = [ |
| "ffmpeg", "-i", video_path, |
| "-vf", f"vidstabdetect=shakiness={shakiness}:result={transforms_path}", |
| "-f", "null", "-" |
| ] |
| run_ffmpeg_command(detect_cmd, "Analyzing video for stabilization (Pass 1/2)...") |
|
|
| |
| transform_cmd = [ |
| "ffmpeg", "-i", video_path, |
| "-vf", f"vidstabtransform=input={transforms_path}:smoothing={smoothing}:optalgo=gauss", |
| "-c:a", "copy", |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", "-y", |
| output_video_path |
| ] |
| run_ffmpeg_command(transform_cmd, "Applying stabilization (Pass 2/2)...") |
|
|
| finally: |
| |
| if os.path.exists(transforms_path): |
| os.remove(transforms_path) |
| |
| return output_video_path |
|
|
| |
| def generate_waveform_preview(video_path): |
| """Generates a PNG image of the audio waveform.""" |
| if not video_path or not has_audio_stream(video_path): |
| return None |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_image_path = os.path.join(TEMP_DIR, f"waveform_{timestamp}.png") |
| |
| |
| cmd = [ |
| "ffmpeg", "-i", video_path, |
| "-filter_complex", "aformat=channel_layouts=mono,compand,showwavespic=s=1280x240:colors=#38bdf8", |
| "-frames:v", "1", |
| "-y", output_image_path |
| ] |
| |
| try: |
| |
| result = subprocess.run(cmd, capture_output=True, text=True, check=True) |
| return output_image_path |
| except (subprocess.CalledProcessError, FileNotFoundError) as e: |
| print(f"--- WAVEFORM GENERATION ERROR ---\n{e}") |
| return None |
|
|
| def auto_jump_cut(video_path, silence_threshold, min_silence_duration, resolution_choice, custom_w, custom_h, progress=gr.Progress(track_tqdm=True)): |
| """ |
| Automatically removes silent parts from a video and stitches the remaining parts together. |
| """ |
| if not video_path: |
| raise gr.Error("Please upload a video to process.") |
| if not has_audio_stream(video_path): |
| raise gr.Error("The uploaded video has no audio track. Cannot detect silence.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_video_path = os.path.join(TEMP_DIR, f"jumpcut_{timestamp}.mp4") |
|
|
| |
| progress(0.1, desc="Analyzing for silent sections...") |
| |
| silence_cmd = [ |
| "ffmpeg", "-i", video_path, |
| "-af", f"silencedetect=noise={silence_threshold}dB:d={min_silence_duration}", |
| "-f", "null", "-" |
| ] |
| |
| print(f"Running silence detection: {' '.join(silence_cmd)}") |
| result = subprocess.run(silence_cmd, capture_output=True, text=True, encoding='utf-8') |
| |
| silence_starts = [float(x) for x in re.findall(r'silence_start: (\d+\.?\d*)', result.stderr)] |
| silence_ends = [float(x) for x in re.findall(r'silence_end: (\d+\.?\d*)', result.stderr)] |
| |
| if not silence_starts: |
| gr.Info("No silence was detected with the current settings. Returning original video.") |
| return video_path |
| |
| silences = list(zip(silence_starts, silence_ends)) |
|
|
| |
| progress(0.3, desc="Calculating video cuts...") |
| video_duration = get_media_duration(video_path) |
| |
| keep_segments = [] |
| last_silence_end = 0.0 |
| for start, end in silences: |
| if start > last_silence_end: |
| keep_segments.append((last_silence_end, start)) |
| last_silence_end = end |
|
|
| if last_silence_end < video_duration: |
| keep_segments.append((last_silence_end, video_duration)) |
| |
| if not keep_segments: |
| raise gr.Error("Failed to calculate any segments to keep. Try adjusting silence parameters.") |
|
|
| |
| progress(0.5, desc="Building FFMPEG command...") |
| |
| scale_pad_filter = "" |
| target_w, target_h = get_video_dimensions(video_path) |
|
|
| if resolution_choice != "Keep Original": |
| if resolution_choice == "1080p (1920x1080)": |
| target_w, target_h = 1920, 1080 |
| elif resolution_choice == "Portrait (1080x1920)": |
| target_w, target_h = 1080, 1920 |
| elif resolution_choice == "Custom": |
| target_w, target_h = int(custom_w), int(custom_h) |
|
|
| target_w -= target_w % 2 |
| target_h -= target_h % 2 |
| scale_pad_filter = f"scale={target_w}:{target_h}:force_original_aspect_ratio=decrease,pad={target_w}:{target_h}:(ow-iw)/2:(oh-ih)/2,setsar=1" |
|
|
| filter_complex_parts = [] |
| video_outputs = [] |
| audio_outputs = [] |
| |
| for i, (start, end) in enumerate(keep_segments): |
| filter_complex_parts.append(f"[0:v]trim=start={start}:end={end},setpts=PTS-STARTPTS[v{i}]") |
| filter_complex_parts.append(f"[0:a]atrim=start={start}:end={end},asetpts=PTS-STARTPTS[a{i}]") |
| |
| if scale_pad_filter: |
| filter_complex_parts.append(f"[v{i}]{scale_pad_filter}[scaled_v{i}]") |
| video_outputs.append(f"[scaled_v{i}]") |
| else: |
| video_outputs.append(f"[v{i}]") |
| |
| audio_outputs.append(f"[a{i}]") |
| |
| filter_complex_parts.append(f"{''.join(video_outputs)}concat=n={len(keep_segments)}:v=1:a=0[vout]") |
| filter_complex_parts.append(f"{''.join(audio_outputs)}concat=n={len(keep_segments)}:v=0:a=1[aout]") |
| |
| filter_complex_str = ";".join(filter_complex_parts) |
|
|
| |
| final_cmd = [ |
| "ffmpeg", "-i", video_path, |
| "-filter_complex", filter_complex_str, |
| "-map", "[vout]", "-map", "[aout]", |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", |
| "-c:a", "aac", |
| "-y", output_video_path |
| ] |
| |
| progress(0.7, desc="Generating final jump-cut video...") |
| run_ffmpeg_command(final_cmd, desc="Stitching video segments...") |
| |
| return output_video_path |
|
|
| |
| def chop_video_on_silence(video_path, silence_threshold, min_silence_duration, resolution_choice, custom_w, custom_h, progress=gr.Progress(track_tqdm=True)): |
| """ |
| Splits a video into multiple clips, removing the silent parts. |
| """ |
| if not video_path: |
| raise gr.Error("Please upload a video to process.") |
| if not has_audio_stream(video_path): |
| raise gr.Error("The uploaded video has no audio track. Cannot detect silence.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"video_chop_{timestamp}") |
| os.makedirs(job_temp_dir, exist_ok=True) |
| |
| output_paths = [] |
|
|
| progress(0.1, desc="Analyzing for silent sections...") |
| silence_cmd = [ |
| "ffmpeg", "-i", video_path, |
| "-af", f"silencedetect=noise={silence_threshold}dB:d={min_silence_duration}", |
| "-f", "null", "-" |
| ] |
| result = subprocess.run(silence_cmd, capture_output=True, text=True, encoding='utf-8') |
| |
| silence_starts = [float(x) for x in re.findall(r'silence_start: (\d+\.?\d*)', result.stderr)] |
| silence_ends = [float(x) for x in re.findall(r'silence_end: (\d+\.?\d*)', result.stderr)] |
| |
| if not silence_starts: |
| shutil.rmtree(job_temp_dir) |
| raise gr.Error("No silence was detected with the current settings. Try adjusting the parameters.") |
| |
| silences = list(zip(silence_starts, silence_ends)) |
|
|
| progress(0.3, desc="Calculating video cuts...") |
| video_duration = get_media_duration(video_path) |
| keep_segments = [] |
| last_silence_end = 0.0 |
| for start, end in silences: |
| if start > last_silence_end: |
| keep_segments.append((last_silence_end, start)) |
| last_silence_end = end |
| if last_silence_end < video_duration: |
| keep_segments.append((last_silence_end, video_duration)) |
| |
| if not keep_segments: |
| shutil.rmtree(job_temp_dir) |
| raise gr.Error("Failed to calculate any segments to keep.") |
|
|
| vf_filter = None |
| if resolution_choice != "Keep Original": |
| if resolution_choice == "1080p (1920x1080)": |
| target_w, target_h = 1920, 1080 |
| elif resolution_choice == "Portrait (1080x1920)": |
| target_w, target_h = 1080, 1920 |
| elif resolution_choice == "Custom": |
| target_w, target_h = int(custom_w), int(custom_h) |
|
|
| target_w -= target_w % 2 |
| target_h -= target_h % 2 |
| vf_filter = f"scale={target_w}:{target_h}:force_original_aspect_ratio=decrease,pad={target_w}:{target_h}:(ow-iw)/2:(oh-ih)/2,setsar=1" |
| |
| for i, (start, end) in enumerate(progress.tqdm(keep_segments, desc="Exporting video clips...")): |
| output_clip_path = os.path.join(job_temp_dir, f"clip_{i:04d}.mp4") |
| duration = end - start |
| |
| cmd = ["ffmpeg", "-y", "-ss", str(start), "-to", str(end), "-i", video_path] |
| |
| if vf_filter: |
| |
| cmd.extend(["-vf", vf_filter, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac"]) |
| else: |
| |
| cmd.extend(["-c", "copy"]) |
| |
| cmd.append(output_clip_path) |
| |
| try: |
| run_ffmpeg_command(cmd) |
| output_paths.append(output_clip_path) |
| except Exception as e: |
| gr.Warning(f"Skipping a clip due to an error: {e}") |
| continue |
| |
| if not output_paths: |
| shutil.rmtree(job_temp_dir) |
| raise gr.Error("No video clips could be exported.") |
| |
| zip_base_name = os.path.join(TEMP_DIR, f"video_chop_archive_{timestamp}") |
| zip_path = shutil.make_archive(zip_base_name, 'zip', job_temp_dir) |
| |
| return output_paths, zip_path |
|
|
| |
|
|
| def get_file_type(file_path): |
| if not file_path: return "unknown" |
| image_exts = ['.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'] |
| video_exts = ['.mp4', '.mov', '.mkv', '.avi', '.webm'] |
| ext = os.path.splitext(file_path.lower())[1] |
| if ext in image_exts: return "image" |
| if ext in video_exts: return "video" |
| return "unknown" |
|
|
| def add_assets_to_bin(files, current_assets): |
| if not files: |
| return current_assets, gr.update(value=[a['path'] for a in current_assets] if current_assets else None) |
|
|
| session_id = f"storyboard_session_{datetime.now().strftime('%Y%m%d_%H%M%S')}" |
| asset_session_dir = os.path.join(TEMP_DIR, session_id) |
| os.makedirs(asset_session_dir, exist_ok=True) |
| |
| updated_asset_list = list(current_assets) |
| for file_obj in files: |
| try: |
| file_type = get_file_type(file_obj.name) |
| if file_type == "unknown": |
| gr.Warning(f"Skipping unknown file type: {os.path.basename(file_obj.name)}") |
| continue |
|
|
| new_path = os.path.join(asset_session_dir, os.path.basename(file_obj.name)) |
| shutil.copy(file_obj.name, new_path) |
|
|
| updated_asset_list.append({"path": new_path, "name": os.path.basename(new_path), "type": file_type}) |
| except Exception as e: |
| gr.Warning(f"Error adding asset {os.path.basename(file_obj.name)}: {e}") |
| |
| return updated_asset_list, gr.update(value=[a['path'] for a in updated_asset_list]) |
|
|
| def handle_asset_selection(evt: gr.SelectData, assets_state, timeline_state): |
| if not evt.selected: |
| return timeline_state, None |
| |
| selected_asset = assets_state[evt.index] |
| new_timeline = list(timeline_state) |
| |
| item_to_add = { |
| "path": selected_asset['path'], |
| "name": selected_asset['name'], |
| "type": selected_asset['type'], |
| } |
| |
| if selected_asset['type'] == 'image': |
| item_to_add.update({ |
| "duration": 3.0, |
| "start_time": 0, |
| "original_duration": 0 |
| }) |
| else: |
| original_duration = get_media_duration(selected_asset['path']) |
| |
| if original_duration <= 0: |
| gr.Warning(f"Could not read duration for '{selected_asset['name']}'. Defaulting to 3.0 seconds. The file may be corrupt or in an unsupported format.") |
| original_duration = 3.0 |
|
|
| item_to_add.update({ |
| "duration": round(original_duration, 2), |
| "start_time": 0.0, |
| "original_duration": round(original_duration, 2) |
| }) |
|
|
| new_timeline.append(item_to_add) |
| gr.Info(f"Added '{selected_asset['name']}' to timeline.") |
| |
| preview_frames = None |
| if selected_asset['type'] == 'video': |
| try: |
| preview_frames = extract_first_last_frame(selected_asset['path']) |
| except Exception as e: |
| print(f"Could not generate preview for {selected_asset['name']}: {e}") |
|
|
| return new_timeline, preview_frames |
| |
| def add_all_assets_to_timeline(assets_state, timeline_state): |
| if not assets_state: |
| gr.Warning("Asset bin is empty.") |
| return timeline_state |
|
|
| new_timeline = list(timeline_state) |
| |
| for asset in assets_state: |
| item_to_add = { |
| "path": asset['path'], |
| "name": asset['name'], |
| "type": asset['type'], |
| } |
| if asset['type'] == 'image': |
| item_to_add.update({ |
| "duration": 3.0, |
| "start_time": 0, |
| "original_duration": 0 |
| }) |
| else: |
| original_duration = get_media_duration(asset['path']) |
|
|
| if original_duration <= 0: |
| gr.Warning(f"Could not read duration for '{asset['name']}'. Defaulting to 3.0 seconds.") |
| original_duration = 3.0 |
| |
| item_to_add.update({ |
| "duration": round(original_duration, 2), |
| "start_time": 0.0, |
| "original_duration": round(original_duration, 2) |
| }) |
| new_timeline.append(item_to_add) |
| |
| gr.Info(f"Added {len(assets_state)} assets to the timeline.") |
| return new_timeline |
|
|
| def update_timeline_df(timeline_state): |
| if not timeline_state: return gr.update(value=None) |
| df_data = [[i + 1, item['name'], item['type'], item['duration']] for i, item in enumerate(timeline_state)] |
| return gr.update(value=df_data) |
|
|
| def handle_timeline_selection(timeline_state, evt: gr.SelectData): |
| if not evt.selected: |
| return -1, None, None, gr.update(interactive=False), gr.update(interactive=False), gr.update(interactive=False), gr.update(visible=False), 0, 0 |
| |
| index = evt.index[0] |
| if not (0 <= index < len(timeline_state)): |
| return -1, None, None, gr.update(interactive=False), gr.update(interactive=False), gr.update(interactive=False), gr.update(visible=False), 0, 0 |
| |
| selected_item = timeline_state[index] |
| |
| preview_val = selected_item['path'] |
| duration_val = selected_item['duration'] |
| |
| can_move_up = index > 0 |
| can_move_down = index < len(timeline_state) - 1 |
| |
| if selected_item['type'] == 'video': |
| start_time = selected_item.get('start_time', 0.0) |
| end_time = start_time + selected_item['duration'] |
| return (index, preview_val, duration_val, |
| gr.update(interactive=can_move_up), gr.update(interactive=can_move_down), gr.update(interactive=True), |
| gr.update(visible=True), round(start_time, 2), round(end_time, 2)) |
| else: |
| return (index, preview_val, duration_val, |
| gr.update(interactive=can_move_up), gr.update(interactive=can_move_down), gr.update(interactive=True), |
| gr.update(visible=False), 0, 0) |
|
|
| def apply_trim_and_update(timeline_state, selected_index, new_start, new_end): |
| if selected_index == -1 or not (0 <= selected_index < len(timeline_state)): |
| gr.Warning("No clip selected in timeline.") |
| return timeline_state, gr.update() |
|
|
| item_to_update = timeline_state[selected_index] |
|
|
| if item_to_update['type'] != 'video': |
| gr.Warning("Trimming is only available for video clips.") |
| return timeline_state, gr.update() |
|
|
| original_duration = item_to_update.get('original_duration', 0) |
| if not (0 <= new_start < new_end and new_end <= original_duration): |
| gr.Warning(f"Invalid trim times. Must be between 0 and {original_duration:.2f}s, and start must be before end.") |
| return timeline_state, gr.update() |
|
|
| new_duration = new_end - new_start |
| |
| new_timeline = list(timeline_state) |
| new_timeline[selected_index]['start_time'] = round(new_start, 2) |
| new_timeline[selected_index]['duration'] = round(new_duration, 2) |
| |
| gr.Info(f"Clip '{item_to_update['name']}' trimmed. New duration is {new_duration:.2f}s.") |
| |
| return new_timeline, gr.update(value=round(new_duration, 2)) |
|
|
| def update_clip_properties(timeline_state, selected_index, new_duration): |
| if selected_index == -1 or not (0 <= selected_index < len(timeline_state)): |
| gr.Warning("No clip selected in timeline.") |
| return timeline_state |
| |
| if new_duration <= 0: |
| gr.Warning("Duration must be a positive number.") |
| return timeline_state |
| |
| new_timeline = list(timeline_state) |
| item_to_update = new_timeline[selected_index] |
| |
| if item_to_update['type'] == 'video': |
| start_time = item_to_update.get('start_time', 0.0) |
| original_duration = item_to_update.get('original_duration', 0.0) |
| max_possible_duration = original_duration - start_time |
| if new_duration > max_possible_duration: |
| gr.Warning(f"Duration cannot exceed available video length from start time ({max_possible_duration:.2f}s). Clamping value.") |
| new_duration = max_possible_duration |
| |
| item_to_update['duration'] = round(new_duration, 2) |
| gr.Info(f"Updated duration for '{item_to_update['name']}'.") |
| return new_timeline |
|
|
| def handle_timeline_action(timeline_state, selected_index, action): |
| if selected_index == -1 or not (0 <= selected_index < len(timeline_state)): |
| gr.Warning("Please select a clip from the timeline first.") |
| return timeline_state, gr.update() |
| |
| new_list = list(timeline_state) |
| new_index = selected_index |
|
|
| if action == "up" and selected_index > 0: |
| new_list.insert(selected_index - 1, new_list.pop(selected_index)) |
| new_index = selected_index - 1 |
| elif action == "down" and selected_index < len(new_list) - 1: |
| new_list.insert(selected_index + 1, new_list.pop(selected_index)) |
| new_index = selected_index + 1 |
| elif action == "remove": |
| new_list.pop(selected_index) |
| new_index = -1 |
| |
| |
| return new_list, gr.update(selected_index=new_index if new_index != -1 else None) |
|
|
| def set_resolution_from_first_asset(timeline_state): |
| if not timeline_state: |
| gr.Warning("Timeline is empty. Cannot determine resolution.") |
| return gr.update(), gr.update() |
| |
| first_item = timeline_state[0] |
| path = first_item['path'] |
| item_type = first_item['type'] |
| |
| w, h = 0, 0 |
| if item_type == 'video': |
| w, h = get_video_dimensions(path) |
| elif item_type == 'image': |
| try: |
| with Image.open(path) as img: |
| w, h = img.size |
| except Exception as e: |
| print(f"Could not get image dimensions for {path}: {e}") |
| |
| if w > 0 and h > 0: |
| gr.Info(f"Set resolution to {w}x{h} based on '{first_item['name']}'.") |
| return w, h |
| else: |
| gr.Warning(f"Could not get dimensions for the first asset: '{first_item['name']}'.") |
| return gr.update(), gr.update() |
|
|
| def create_animatic(timeline_data, audio_path, out_w, out_h, keep_original_audio): |
| if not timeline_data: |
| raise gr.Error("Timeline is empty. Please add assets to the timeline.") |
| |
| out_w, out_h = int(out_w), int(out_h) |
| if out_w <= 0 or out_h <= 0: |
| raise gr.Error("Output width and height must be positive numbers.") |
| out_w -= out_w % 2 |
| out_h -= out_h % 2 |
| |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"animatic_{timestamp}") |
| os.makedirs(job_temp_dir, exist_ok=True) |
| |
| clip_paths = [] |
| |
| for i, item in enumerate(timeline_data): |
| item_path, item_type, item_duration = item['path'], item['type'], item['duration'] |
| start_time = item.get('start_time', 0) |
| |
| if item_duration <= 0: |
| gr.Warning(f"Skipping clip '{item['name']}' because its duration is zero.") |
| continue |
|
|
| output_clip_path = os.path.join(job_temp_dir, f"clip_{i:04d}.mp4") |
| |
| cmd = ["ffmpeg", "-y"] |
| |
| vf_base_scale = f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease,pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2,setsar=1" |
| |
| if item_type == 'video': |
| video_has_audio = has_audio_stream(item_path) |
|
|
| if start_time > 0: |
| cmd.extend(["-ss", str(start_time)]) |
| |
| cmd.extend(["-t", str(item_duration), "-i", item_path]) |
|
|
| vf_filters = [f"setpts=PTS-STARTPTS", vf_base_scale] |
| cmd.extend(["-vf", ",".join(vf_filters)]) |
|
|
| if keep_original_audio: |
| if video_has_audio: |
| cmd.extend(["-af", "asetpts=PTS-STARTPTS"]) |
| cmd.extend(["-c:a", "aac", "-ar", "44100"]) |
| else: |
| cmd.extend(["-f", "lavfi", "-t", str(item_duration), "-i", "anullsrc=channel_layout=stereo:sample_rate=44100"]) |
| cmd.extend(["-map", "0:v:0", "-map", "1:a:0"]) |
| cmd.extend(["-c:a", "aac", "-ar", "44100"]) |
| else: |
| cmd.append("-an") |
| |
| cmd.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p"]) |
|
|
| else: |
| cmd.extend(["-loop", "1", "-i", item_path, "-t", str(item_duration)]) |
| vf_filter_img = f"{vf_base_scale},format=yuv420p" |
| cmd.extend(["-vf", vf_filter_img]) |
| |
| if keep_original_audio: |
| cmd.extend(["-f", "lavfi", "-t", str(item_duration), "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", "-shortest"]) |
| cmd.extend(["-c:a", "aac", "-ar", "44100"]) |
| else: |
| cmd.append("-an") |
|
|
| cmd.append(output_clip_path) |
| |
| run_ffmpeg_command(cmd, f"Processing clip {i+1}/{len(timeline_data)}: {item['name']}") |
| clip_paths.append(output_clip_path) |
|
|
| if not clip_paths: |
| shutil.rmtree(job_temp_dir) |
| raise gr.Error("No valid clips were generated. Check clip durations and file integrity.") |
|
|
| combined_video_path = os.path.join(job_temp_dir, "combined_video.mp4") |
|
|
| if len(clip_paths) > 1: |
| cmd_concat = ["ffmpeg", "-y"] |
| video_inputs, audio_inputs = [], [] |
| |
| for i, path in enumerate(clip_paths): |
| cmd_concat.extend(["-i", path]) |
| video_inputs.append(f"[{i}:v]") |
| if keep_original_audio: audio_inputs.append(f"[{i}:a]") |
|
|
| filter_complex_str = "" |
| if keep_original_audio: |
| video_concat_str = "".join(video_inputs) + f"concat=n={len(clip_paths)}:v=1:a=0[v_out];" |
| audio_concat_str = "".join(audio_inputs) + f"concat=n={len(clip_paths)}:v=0:a=1[a_out]" |
| filter_complex_str = video_concat_str + audio_concat_str |
| cmd_concat.extend(["-filter_complex", filter_complex_str, "-map", "[v_out]", "-map", "[a_out]"]) |
| else: |
| video_concat_str = "".join(video_inputs) + f"concat=n={len(clip_paths)}:v=1:a=0[v_out]" |
| filter_complex_str = video_concat_str |
| cmd_concat.extend(["-filter_complex", filter_complex_str, "-map", "[v_out]"]) |
| |
| cmd_concat.append(combined_video_path) |
| run_ffmpeg_command(cmd_concat, "Joining and Finalizing Video (Robust Mode)...") |
| else: |
| if os.path.exists(clip_paths[0]): |
| shutil.copy(clip_paths[0], combined_video_path) |
| else: |
| shutil.rmtree(job_temp_dir) |
| raise gr.Error("The only clip in the timeline failed to process.") |
|
|
| final_output_path = os.path.join(TEMP_DIR, f"animatic_final_{timestamp}.mp4") |
| |
| if not keep_original_audio and audio_path: |
| run_ffmpeg_command(["ffmpeg", "-y", "-i", combined_video_path, "-i", audio_path, "-c:v", "copy", "-c:a", "aac", "-shortest", final_output_path], "Muxing audio") |
| else: |
| shutil.move(combined_video_path, final_output_path) |
| |
| shutil.rmtree(job_temp_dir) |
| return final_output_path |
| |
| def detect_bpm(audio_path): |
| if not audio_path: |
| return "Please upload an audio track first." |
| try: |
| y, sr = librosa.load(audio_path) |
| tempo_val, _ = librosa.beat.beat_track(y=y, sr=sr) |
| |
| if isinstance(tempo_val, np.ndarray): |
| tempo = tempo_val.item() |
| else: |
| tempo = float(tempo_val) |
|
|
| if tempo > 0: |
| return f"Detected BPM: {tempo:.2f}" |
| else: |
| return "Could not detect BPM." |
| except Exception as e: |
| print(f"--- BPM DETECTION ERROR ---\n{e}") |
| return "Error: Could not analyze audio file." |
|
|
| def update_new_bpm_display(original_bpm_text, speed_multiplier): |
| if not original_bpm_text or "Detected" not in original_bpm_text: |
| return "---" |
| |
| try: |
| bpm_match = re.search(r"(\d+\.\d+)", original_bpm_text) |
| if bpm_match: |
| original_bpm = float(bpm_match.group(1)) |
| new_bpm = original_bpm * speed_multiplier |
| return f"Estimated New BPM: {new_bpm:.2f}" |
| else: |
| return "---" |
| except (ValueError, TypeError): |
| return "---" |
|
|
|
|
| def create_rhythmic_animatic(timeline_data, audio_path, measure_choice, out_w, out_h): |
| if not timeline_data: raise gr.Error("Timeline is empty.") |
| if not audio_path: raise gr.Error("An audio track is required for rhythmic editing.") |
|
|
| try: |
| y, sr = librosa.load(audio_path) |
| tempo_val, _ = librosa.beat.beat_track(y=y, sr=sr) |
| |
| if isinstance(tempo_val, np.ndarray): |
| tempo = tempo_val.item() |
| else: |
| tempo = float(tempo_val) |
|
|
| if not tempo or tempo <= 0: |
| raise gr.Error("Could not determine BPM from audio file.") |
| except Exception as e: |
| raise gr.Error(f"Audio analysis failed: {e}") |
|
|
| seconds_per_beat = 60.0 / tempo |
| seconds_per_measure = seconds_per_beat * 4.0 |
| |
| measure_multipliers = { "2 Measures": 2.0, "1 Measure": 1.0, "1/2 Measure": 0.5, "1/4 Measure (Beat)": 0.25 } |
| clip_duration = seconds_per_measure * measure_multipliers[measure_choice] |
|
|
| rhythmic_timeline = [] |
| for item in timeline_data: |
| new_item = item.copy() |
| if new_item['type'] == 'video': |
| start_time = new_item.get('start_time', 0) |
| available_duration = new_item.get('original_duration', 0) - start_time |
| new_item['duration'] = min(clip_duration, available_duration) |
| else: |
| new_item['duration'] = clip_duration |
| rhythmic_timeline.append(new_item) |
| |
| gr.Info(f"Re-timed {len(rhythmic_timeline)} clips to ~{clip_duration:.2f}s each based on {tempo:.2f} BPM.") |
| |
| return create_animatic(rhythmic_timeline, audio_path, out_w, out_h, keep_original_audio=False) |
|
|
| |
|
|
| def _create_auto_trailer_impl(video_path, trailer_duration, clip_duration, analysis_method, transition_style, music_path, out_w, out_h, progress: Progress): |
| """Internal implementation of the auto-trailer creator.""" |
| if not video_path: raise gr.Error("Please upload a source video.") |
| |
| source_duration = get_media_duration(video_path) |
| |
| if source_duration < trailer_duration: |
| gr.Warning(f"Source video is only {source_duration:.1f}s long. The trailer duration will be capped at the source video length.") |
| trailer_duration = source_duration |
|
|
| if clip_duration > trailer_duration: |
| new_clip_duration = trailer_duration / 2 if trailer_duration > 2 else trailer_duration |
| gr.Warning(f"Clip duration ({clip_duration}s) is longer than the trailer duration ({trailer_duration:.1f}s). Adjusting clip duration to {new_clip_duration:.1f}s.") |
| clip_duration = new_clip_duration |
|
|
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"trailer_{timestamp}") |
| os.makedirs(job_temp_dir, exist_ok=True) |
| |
| try: |
| progress(0, desc="Analyzing video for high-motion scenes...") |
| |
| cap = cv2.VideoCapture(video_path) |
| fps = cap.get(cv2.CAP_PROP_FPS) |
| if fps == 0: fps = 30 |
| |
| chunk_duration_frames = int(clip_duration * fps) |
| video_total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) |
| |
| chunk_scores = [] |
| prev_frame = None |
| |
| frame_num = 0 |
| while cap.isOpened(): |
| ret, frame = cap.read() |
| if not ret: break |
|
|
| frame_skip = max(1, int(fps / 5)) |
| if frame_num % frame_skip == 0: |
| gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) |
| gray = cv2.GaussianBlur(gray, (21, 21), 0) |
|
|
| if prev_frame is not None: |
| frame_delta = cv2.absdiff(prev_frame, gray) |
| thresh = cv2.threshold(frame_delta, 25, 255, cv2.THRESH_BINARY)[1] |
| motion_score = np.sum(thresh) |
| |
| chunk_index = frame_num // chunk_duration_frames |
| while len(chunk_scores) <= chunk_index: |
| chunk_scores.append({'start_time': (len(chunk_scores) * clip_duration), 'scores': []}) |
| chunk_scores[chunk_index]['scores'].append(motion_score) |
| |
| prev_frame = gray |
| |
| frame_num += 1 |
| if frame_num % 100 == 0: |
| progress(0.2 * (frame_num / video_total_frames), desc=f"Analyzing frame {frame_num}/{video_total_frames}...") |
|
|
| cap.release() |
| |
| final_chunks = [{'start_time': chunk['start_time'], 'score': sum(chunk['scores']) / len(chunk['scores'])} for chunk in chunk_scores if chunk['scores']] |
|
|
| if not final_chunks: raise gr.Error("Could not analyze video for motion. Is the video very short or static?") |
|
|
| progress(0.2, desc="Selecting the best clips...") |
| num_clips_to_select = max(1, int(trailer_duration / clip_duration)) |
| selected_clips_info = sorted(sorted(final_chunks, key=lambda x: x['score'], reverse=True)[:num_clips_to_select], key=lambda x: x['start_time']) |
| |
| extracted_clips, out_w, out_h = [], int(out_w) - (int(out_w) % 2), int(out_h) - (int(out_h) % 2) |
| |
| for i, clip_info in enumerate(selected_clips_info): |
| progress(0.2 + (0.5 * (i / len(selected_clips_info))), desc=f"Extracting clip {i+1}/{len(selected_clips_info)}...") |
| output_clip_path = os.path.join(job_temp_dir, f"clip_{i:03d}.mp4") |
| vf_filter = f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease,pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2,setsar=1" |
| cmd = ["ffmpeg", "-y", "-ss", str(clip_info['start_time']), "-i", video_path, "-t", str(clip_duration), "-vf", vf_filter, "-an", "-c:v", "libx264", "-pix_fmt", "yuv420p", output_clip_path] |
| run_ffmpeg_command(cmd) |
| extracted_clips.append(output_clip_path) |
|
|
| if not extracted_clips: raise gr.Error("Failed to extract any clips.") |
| progress(0.7, desc="Stitching clips together...") |
| |
| final_silent_path = os.path.join(job_temp_dir, "final_silent.mp4") |
|
|
| if transition_style == "None" or len(extracted_clips) == 1: |
| if len(extracted_clips) == 1: |
| shutil.copy(extracted_clips[0], final_silent_path) |
| else: |
| file_list_path = os.path.join(job_temp_dir, "files.txt") |
| with open(file_list_path, 'w', encoding='utf-8') as f: |
| for path in extracted_clips: f.write(f"file '{os.path.abspath(path)}'\n") |
| run_ffmpeg_command(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", file_list_path, "-c", "copy", final_silent_path], "Concatenating clips...") |
| else: |
| transition_duration = 0.5 |
| cmd = ["ffmpeg", "-y"] |
| filter_complex = [] |
| running_duration = 0 |
| |
| for i, clip_path in enumerate(extracted_clips): |
| cmd.extend(["-i", clip_path]) |
| |
| for i in range(len(extracted_clips) - 1): |
| input1 = f"[{i}:v]" if i == 0 else f"[v{i-1}]" |
| input2 = f"[{i+1}:v]" |
| output = f"[v{i}]" |
| offset = max(0, running_duration + clip_duration - transition_duration) |
| filter_complex.append(f"{input1}{input2}xfade=transition={transition_style.lower()}:duration={transition_duration}:offset={offset}{output}") |
| running_duration += clip_duration - transition_duration |
| |
| cmd.extend([ |
| "-filter_complex", ";".join(filter_complex), |
| "-map", f"[v{len(extracted_clips)-2}]", |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", |
| final_silent_path |
| ]) |
| run_ffmpeg_command(cmd, "Applying transitions...") |
| |
| progress(0.95, desc="Adding background music...") |
| final_output_path = os.path.join(TEMP_DIR, f"trailer_final_{timestamp}.mp4") |
| if music_path: |
| run_ffmpeg_command(["ffmpeg", "-y", "-i", final_silent_path, "-i", music_path, "-c:v", "copy", "-c:a", "aac", "-map", "0:v:0", "-map", "1:a:0", "-shortest", final_output_path], "Muxing audio") |
| else: |
| shutil.move(final_silent_path, final_output_path) |
| return final_output_path |
| finally: |
| if os.path.exists(job_temp_dir): shutil.rmtree(job_temp_dir) |
|
|
|
|
| def auto_trailer_wrapper(video_path, trailer_duration, clip_duration, analysis_method, transition_style, music_path, out_w, out_h, progress=gr.Progress(track_tqdm=True)): |
| return _create_auto_trailer_impl(video_path, trailer_duration, clip_duration, analysis_method, transition_style, music_path, out_w, out_h, progress) |
|
|
|
|
| def generate_waveform_video(video_path, style, size, position, color): |
| if not video_path: raise gr.Error("Please upload a video first.") |
| |
| if not has_audio_stream(video_path): |
| raise gr.Error("The uploaded video has no audio track. A waveform cannot be generated.") |
| |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_video_path = os.path.join(TEMP_DIR, f"waveform_video_{timestamp}.mp4") |
| |
| pos_map = { |
| "Bottom": f"overlay=x=(W-w)/2:y=H-h-50", |
| "Center": f"overlay=x=(W-w)/2:y=(H-h)/2", |
| "Top": f"overlay=x=(W-w)/2:y=50" |
| } |
|
|
| safe_color = color.lstrip('#') |
|
|
| filter_complex = ( |
| f"[0:a]showwaves=s={size}:mode={style}:colors={safe_color}:rate=25[wave];" |
| f"[0:v][wave]{pos_map[position]}" |
| ) |
| |
| cmd = [ |
| "ffmpeg", "-i", video_path, |
| "-filter_complex", filter_complex, |
| "-c:a", "copy", |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", "-y", |
| output_video_path |
| ] |
| |
| run_ffmpeg_command(cmd, "Generating Audio Waveform...") |
| return output_video_path |
|
|
| def create_pip_video(main_video, overlay_media, position, scale): |
| if not main_video: raise gr.Error("Please upload a main video.") |
| if not overlay_media: raise gr.Error("Please upload an overlay video or image.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_video_path = os.path.join(TEMP_DIR, f"pip_video_{timestamp}.mp4") |
|
|
| scale_filter = f"[1:v]scale=iw*{scale}:-1[scaled_overlay]" |
|
|
| pos_map = { |
| "Top-Left": "x=10:y=10", |
| "Top-Center": "x=(W-w)/2:y=10", |
| "Top-Right": "x=W-w-10:y=10", |
| "Center-Left": "x=10:y=(H-h)/2", |
| "Center": "x=(W-w)/2:y=(H-h)/2", |
| "Center-Right": "x=W-w-10:y=(H-h)/2", |
| "Bottom-Left": "x=10:y=H-h-10", |
| "Bottom-Center": "x=(W-w)/2:y=H-h-10", |
| "Bottom-Right": "x=W-w-10:y=H-h-10" |
| } |
| overlay_filter = f"[0:v][scaled_overlay]overlay={pos_map[position]}" |
| |
| cmd = ["ffmpeg", "-i", main_video, "-i", overlay_media.name] |
| |
| cmd.extend([ |
| "-filter_complex", f"{scale_filter};{overlay_filter}", |
| "-map", "0:a?", "-c:a", "copy", |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", "-y", |
| output_video_path |
| ]) |
| |
| run_ffmpeg_command(cmd, "Creating Picture-in-Picture video...") |
| return output_video_path |
|
|
| def create_meme(image, text_input, position, font_choice, font_size_scale, text_color, outline_color): |
| if image is None: raise gr.Error("Please upload an image.") |
| |
| parsed_text_color = parse_color(text_color) |
| parsed_outline_color = parse_color(outline_color) |
|
|
| img = Image.fromarray(image).convert("RGB") |
| draw = ImageDraw.Draw(img) |
| |
| FONT_MAP = { |
| "Impact": "impact.ttf", |
| "Arial": "arial.ttf", |
| "Arial Black": "ariblk.ttf", |
| "Comic Sans MS": "comic.ttf", |
| "Courier New": "cour.ttf", |
| "Georgia": "georgia.ttf", |
| "Tahoma": "tahoma.ttf", |
| "Times New Roman": "times.ttf", |
| "Trebuchet MS": "trebuc.ttf", |
| "Verdana": "verdana.ttf" |
| } |
| font_path = FONT_MAP.get(font_choice, "impact.ttf") |
|
|
| try: |
| font_size = int(img.width / 10 * (font_size_scale / 5)) |
| font = ImageFont.truetype(font_path, font_size) |
| except IOError: |
| gr.Warning(f"{font_choice} font ('{font_path}') not found. Trying Arial.") |
| try: |
| font_path = FONT_MAP["Arial"] |
| font = ImageFont.truetype(font_path, font_size) |
| except IOError: |
| gr.Warning("Arial font not found. Using default font.") |
| font = ImageFont.load_default() |
|
|
| def draw_text_with_outline(text, x, y): |
| |
| draw.text((x-2, y-2), text, font=font, fill=parsed_outline_color) |
| draw.text((x+2, y-2), text, font=font, fill=parsed_outline_color) |
| draw.text((x-2, y+2), text, font=font, fill=parsed_outline_color) |
| draw.text((x+2, y+2), text, font=font, fill=parsed_outline_color) |
| |
| draw.text((x, y), text, font=font, fill=parsed_text_color) |
|
|
| if text_input: |
| bbox = draw.textbbox((0, 0), text_input.upper(), font=font) |
| text_width = bbox[2] - bbox[0] |
| text_height = bbox[3] - bbox[1] |
| x = (img.width - text_width) / 2 |
|
|
| if position == "Top": |
| y = 10 |
| elif position == "Bottom": |
| y = img.height - text_height - 10 |
| else: |
| y = (img.height - text_height) / 2 |
| |
| draw_text_with_outline(text_input.upper(), x, y) |
| |
| return img |
|
|
| def stitch_images_smartly(img1_np, img2_np, output_size, bg_color_hex): |
| """ |
| Stitches two images together into a square. |
| - If input images are vertical (based on first image), they are placed side-by-side. |
| - If input images are horizontal, they are stacked vertically. |
| """ |
| if img1_np is None or img2_np is None: |
| raise gr.Error("Please upload two images.") |
|
|
| |
| img1 = Image.fromarray(img1_np).convert("RGBA") |
| img2 = Image.fromarray(img2_np).convert("RGBA") |
|
|
| |
| bg_color = parse_color(bg_color_hex) |
|
|
| |
| final_image = Image.new("RGB", (output_size, output_size), bg_color) |
| |
| |
| w1, h1 = img1.size |
| is_vertical = h1 > w1 |
|
|
| if is_vertical: |
| |
| target_box_w = output_size // 2 |
| target_box_h = output_size |
| |
| |
| img1.thumbnail((target_box_w, target_box_h), Image.Resampling.LANCZOS) |
| x1_offset = (target_box_w - img1.width) // 2 |
| y1_offset = (target_box_h - img1.height) // 2 |
| final_image.paste(img1, (x1_offset, y1_offset), img1) |
|
|
| |
| img2.thumbnail((target_box_w, target_box_h), Image.Resampling.LANCZOS) |
| x2_offset = target_box_w + (target_box_w - img2.width) // 2 |
| y2_offset = (target_box_h - img2.height) // 2 |
| final_image.paste(img2, (x2_offset, y2_offset), img2) |
| |
| else: |
| |
| target_box_w = output_size |
| target_box_h = output_size // 2 |
|
|
| |
| img1.thumbnail((target_box_w, target_box_h), Image.Resampling.LANCZOS) |
| x1_offset = (target_box_w - img1.width) // 2 |
| y1_offset = (target_box_h - img1.height) // 2 |
| final_image.paste(img1, (x1_offset, y1_offset), img1) |
|
|
| |
| img2.thumbnail((target_box_w, target_box_h), Image.Resampling.LANCZOS) |
| x2_offset = (target_box_w - img2.width) // 2 |
| y2_offset = target_box_h + (target_box_h - img2.height) // 2 |
| final_image.paste(img2, (x2_offset, y2_offset), img2) |
|
|
| return final_image |
|
|
| def merge_videos(videos): |
| if not videos or len(videos) < 2: |
| raise gr.Error("Please upload at least two videos to merge.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"merge_{timestamp}") |
| os.makedirs(job_temp_dir, exist_ok=True) |
| |
| first_video_path = videos[0].name |
| w, h = get_video_dimensions(first_video_path) |
| fps = get_video_fps(first_video_path) |
| w -= w % 2 |
| h -= h % 2 |
|
|
| processed_clips = [] |
| for i, video_file in enumerate(videos): |
| clip_path = os.path.join(job_temp_dir, f"clip_{i}.mp4") |
| cmd = [ |
| "ffmpeg", "-i", video_file.name, |
| "-vf", f"scale={w}:{h},setsar=1", "-r", str(fps), |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", |
| "-c:a", "aac", "-ar", "44100", |
| "-y", clip_path |
| ] |
| run_ffmpeg_command(cmd) |
| processed_clips.append(clip_path) |
|
|
| file_list_path = os.path.join(job_temp_dir, "files.txt") |
| with open(file_list_path, 'w', encoding='utf-8') as f: |
| for path in processed_clips: |
| f.write(f"file '{os.path.abspath(path)}'\n") |
|
|
| output_video_path = os.path.join(TEMP_DIR, f"merged_video_{timestamp}.mp4") |
| cmd_merge = [ |
| "ffmpeg", "-f", "concat", "-safe", "0", "-i", file_list_path, |
| "-c", "copy", "-y", output_video_path |
| ] |
| run_ffmpeg_command(cmd_merge, "Merging videos...") |
|
|
| shutil.rmtree(job_temp_dir) |
| return output_video_path |
|
|
| |
| def update_crop_preview(original_image, x, y, w, h): |
| if original_image is None: |
| return None |
| |
| |
| preview_image = original_image.copy() |
| draw = ImageDraw.Draw(preview_image) |
| |
| |
| box = (x, y, x + w, y + h) |
| |
| |
| draw.rectangle(box, outline="#38bdf8", width=3) |
| |
| return preview_image |
|
|
| def crop_single_image(input_path, output_path, **kwargs): |
| x = int(kwargs.get('x', 0)) |
| y = int(kwargs.get('y', 0)) |
| w = int(kwargs.get('w', 512)) |
| h = int(kwargs.get('h', 512)) |
| with Image.open(input_path) as img: |
| cropped_img = img.crop((x, y, x + w, y + h)) |
| cropped_img.save(output_path) |
|
|
| def batch_crop_images(files, x, y, w, h): |
| if not files: raise gr.Error("Please upload at least one image.") |
| if w <= 0 or h <= 0: raise gr.Error("Width and Height must be positive.") |
| |
| processing_kwargs = {'x': x, 'y': y, 'w': w, 'h': h} |
| output_paths, zip_path, _ = batch_image_processor( |
| files, |
| crop_single_image, |
| "cropped", |
| **processing_kwargs |
| ) |
| return output_paths, zip_path |
|
|
| |
| def create_collage(files, layout, width, height, bg_color_hex): |
| if not files: |
| raise gr.Error("Please upload images to create a collage.") |
| |
| bg_color = parse_color(bg_color_hex) |
| images = [Image.open(file.name).convert("RGBA") for file in files] |
| n = len(images) |
| |
| if layout == "Grid": |
| cols = int(math.ceil(math.sqrt(n))) |
| rows = int(math.ceil(n / cols)) |
| elif layout == "Horizontal": |
| cols, rows = n, 1 |
| else: |
| cols, rows = 1, n |
| |
| cell_w = width // cols |
| cell_h = height // rows |
| |
| canvas = Image.new("RGB", (width, height), bg_color) |
| |
| for i, img in enumerate(images): |
| row = i // cols |
| col = i % cols |
| |
| img.thumbnail((cell_w, cell_h), Image.Resampling.LANCZOS) |
| |
| paste_x = (col * cell_w) + (cell_w - img.width) // 2 |
| paste_y = (row * cell_h) + (cell_h - img.height) // 2 |
| |
| canvas.paste(img, (paste_x, paste_y), img) |
| |
| return canvas |
|
|
| |
| def update_audio_source_choices_for_grid(files): |
| if not files: |
| return gr.update(choices=["From Video 1", "None"], value="From Video 1") |
| |
| choices = [f"From Video {i+1}" for i in range(len(files))] |
| choices.append("None") |
| return gr.update(choices=choices, value=choices[0]) |
|
|
| def compile_video_grid(videos, layout, width, height, bg_color, audio_choice, music_path): |
| if not videos: |
| raise gr.Error("Please upload videos to compile.") |
| |
| num_videos = len(videos) |
| layout_map = { |
| "2x1 (Side-by-Side)": 2, |
| "1x2 (Stacked)": 2, |
| "2x2 (Quad-View)": 4, |
| "4x4 (16-View)": 16, |
| "8x4 (32-View)": 32, |
| } |
| |
| required_videos = layout_map.get(layout) |
| if num_videos != required_videos: |
| raise gr.Error(f"The '{layout}' layout requires exactly {required_videos} videos, but you uploaded {num_videos}.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_path = os.path.join(TEMP_DIR, f"grid_video_{timestamp}.mp4") |
|
|
| width, height = int(width) - (int(width) % 2), int(height) - (int(height) % 2) |
| |
| cmd = ["ffmpeg", "-y"] |
| input_count = 0 |
| for video_file in videos: |
| cmd.extend(["-i", video_file.name]) |
| input_count += 1 |
| |
| if music_path: |
| cmd.extend(["-i", music_path]) |
|
|
| filter_complex_parts = [] |
| |
| if layout in ["2x1 (Side-by-Side)", "1x2 (Stacked)", "2x2 (Quad-View)"]: |
| if layout == "2x1 (Side-by-Side)": |
| tile_w, tile_h = width // 2, height |
| filter_complex_parts.append(f"[0:v]scale={tile_w}:{tile_h}:force_original_aspect_ratio=decrease,pad={tile_w}:{tile_h}:-1:-1:color={bg_color}[v0];[1:v]scale={tile_w}:{tile_h}:force_original_aspect_ratio=decrease,pad={tile_w}:{tile_h}:-1:-1:color={bg_color}[v1];[v0][v1]hstack=inputs=2[vout]") |
| elif layout == "1x2 (Stacked)": |
| tile_w, tile_h = width, height // 2 |
| filter_complex_parts.append(f"[0:v]scale={tile_w}:{tile_h}:force_original_aspect_ratio=decrease,pad={tile_w}:{tile_h}:-1:-1:color={bg_color}[v0];[1:v]scale={tile_w}:{tile_h}:force_original_aspect_ratio=decrease,pad={tile_w}:{tile_h}:-1:-1:color={bg_color}[v1];[v0][v1]vstack=inputs=2[vout]") |
| elif layout == "2x2 (Quad-View)": |
| tile_w, tile_h = width // 2, height // 2 |
| for i in range(4): |
| filter_complex_parts.append(f"[{i}:v]scale={tile_w}:{tile_h}:force_original_aspect_ratio=decrease,pad={tile_w}:{tile_h}:-1:-1:color={bg_color}[v{i}]") |
| filter_complex_parts.append("[v0][v1][v2][v3]xstack=inputs=4:layout=0_0|w0_0|0_h0|w0_h0[vout]") |
| |
| elif layout in ["4x4 (16-View)", "8x4 (32-View)"]: |
| cols, rows = (4, 4) if layout == "4x4 (16-View)" else (8, 4) |
| tile_w, tile_h = width // cols, height // rows |
| |
| |
| for i in range(required_videos): |
| filter_complex_parts.append(f"[{i}:v]scale={tile_w}:{tile_h}:force_original_aspect_ratio=decrease,pad={tile_w}:{tile_h}:-1:-1:color={bg_color}[v{i}]") |
| |
| |
| row_outputs = [] |
| for r in range(rows): |
| start_index = r * cols |
| end_index = start_index + cols |
| row_inputs = "".join([f"[v{i}]" for i in range(start_index, end_index)]) |
| row_output_label = f"[row{r}]" |
| filter_complex_parts.append(f"{row_inputs}hstack=inputs={cols}{row_output_label}") |
| row_outputs.append(row_output_label) |
| |
| |
| final_vstack_inputs = "".join(row_outputs) |
| filter_complex_parts.append(f"{final_vstack_inputs}vstack=inputs={rows}[vout]") |
|
|
| cmd.extend(["-filter_complex", ";".join(filter_complex_parts)]) |
| cmd.extend(["-map", "[vout]"]) |
| |
| |
| if music_path: |
| cmd.extend(["-map", f"{input_count}:a?"]) |
| cmd.extend(["-c:a", "aac", "-shortest"]) |
| elif audio_choice != "None": |
| try: |
| audio_idx_match = re.search(r'\d+', audio_choice) |
| if audio_idx_match: |
| audio_idx = int(audio_idx_match.group()) - 1 |
| if 0 <= audio_idx < num_videos: |
| cmd.extend(["-map", f"{audio_idx}:a?"]) |
| cmd.extend(["-c:a", "aac", "-shortest"]) |
| except (AttributeError, IndexError): |
| raise gr.Error("Invalid audio source selected.") |
| |
| cmd.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p", output_path]) |
| |
| run_ffmpeg_command(cmd, "Compiling video grid...") |
| return output_path |
|
|
| def _create_automated_slideshow_impl(images, audio_path, kb_effect_style, transition_style, rhythm_choice, out_w, out_h, progress: Progress): |
| """Internal implementation of the slideshow creator with progress tracking.""" |
| if not images: raise gr.Error("Please upload at least one image.") |
| if not audio_path: raise gr.Error("Please upload an audio track for rhythmic editing.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"slideshow_{timestamp}") |
| os.makedirs(job_temp_dir, exist_ok=True) |
| |
| try: |
| out_w, out_h = int(out_w) - (int(out_w) % 2), int(out_h) - (int(out_h) % 2) |
| output_res_str = f"{out_w}x{out_h}" |
| fps = 30 |
| transition_duration = 0.5 |
|
|
| progress(0, desc="Analyzing audio track...") |
| try: |
| y, sr = librosa.load(audio_path) |
| audio_duration = librosa.get_duration(y=y, sr=sr) |
| _, beat_frames = librosa.beat.beat_track(y=y, sr=sr, units='frames') |
| beat_times = librosa.frames_to_time(beat_frames, sr=sr) |
| except Exception as e: |
| raise gr.Error(f"Audio analysis failed: {e}") |
|
|
| beats_per_clip = {"1 Image per Beat": 1, "1 Image every 2 Beats": 2, "1 Image per Measure (4 Beats)": 4}[rhythm_choice] |
| clip_start_times = [0.0] + [beat_times[i] for i in range(beats_per_clip, len(beat_times), beats_per_clip)] |
| |
| MAX_CLIPS = 200 |
| if len(clip_start_times) > MAX_CLIPS: |
| gr.Warning(f"Audio beat detection resulted in {len(clip_start_times)} clips. Capping at {MAX_CLIPS} to ensure performance.") |
| clip_start_times = clip_start_times[:MAX_CLIPS] |
|
|
| num_clips = len(clip_start_times) |
| image_paths = [img.name for img in images] |
| looped_image_paths = [image_paths[i % len(image_paths)] for i in range(num_clips)] |
| |
| kb_clips = [] |
| total_steps = num_clips + 1 |
| current_step = 0 |
|
|
| for i in range(num_clips): |
| progress(current_step / total_steps, desc=f"Creating clip {i+1}/{num_clips}") |
| start_time = clip_start_times[i] |
| end_time = clip_start_times[i + 1] if i + 1 < len(clip_start_times) else audio_duration |
| clip_duration = end_time - start_time |
| if clip_duration <= transition_duration: continue |
|
|
| total_frames = int(clip_duration * fps) |
| if total_frames <= 0: continue |
| |
| output_clip_path = os.path.join(job_temp_dir, f"kb_clip_{i:04d}.mp4") |
|
|
| with Image.open(looped_image_paths[i]) as img: |
| iw, ih = img.size |
|
|
| zoom_levels = {"Subtle": (1.1, 1.15), "Standard": (1.1, 1.25), "Dynamic": (1.2, 1.5)} |
| start_zoom = 1.0 |
| end_zoom = random.uniform(*zoom_levels[kb_effect_style]) |
| |
| directions = ['top_left', 'top_right', 'bottom_left', 'bottom_right', 'center'] |
| start_pos_name, end_pos_name = random.sample(directions, 2) |
| |
| def get_xy(pos_name, zoom_val, img_w, img_h): |
| if pos_name == 'center': return (img_w/2 - (img_w/zoom_val)/2, img_h/2 - (img_h/zoom_val)/2) |
| if pos_name == 'top_left': return (0, 0) |
| if pos_name == 'top_right': return (img_w - img_w/zoom_val, 0) |
| if pos_name == 'bottom_left': return (0, img_h - img_h/zoom_val) |
| if pos_name == 'bottom_right': return (img_w - img_w/zoom_val, img_h - img_h/zoom_val) |
| return (0,0) |
|
|
| start_x, start_y = get_xy(start_pos_name, start_zoom, iw, ih) |
| end_x, end_y = get_xy(end_pos_name, end_zoom, iw, ih) |
| |
| x_expr = f"{start_x}+({end_x}-({start_x}))*on/({total_frames}-1)" |
| y_expr = f"{start_y}+({end_y}-({start_y}))*on/({total_frames}-1)" |
| z_expr = f"if(lte(on,0),{start_zoom},{start_zoom}+({end_zoom}-{start_zoom})*on/({total_frames}-1))" |
|
|
| zoompan_filter = f"zoompan=z='{z_expr}':x='{x_expr}':y='{y_expr}':d={total_frames}:s={output_res_str}:fps={fps}" |
|
|
| cmd = ["ffmpeg", "-y", "-loop", "1", "-i", looped_image_paths[i], "-vf", zoompan_filter, "-t", str(clip_duration), "-c:v", "libx264", "-pix_fmt", "yuv420p", output_clip_path] |
| run_ffmpeg_command(cmd) |
| kb_clips.append({"path": output_clip_path, "duration": clip_duration}) |
| current_step += 1 |
|
|
| if not kb_clips: raise gr.Error("No clips were generated. The audio may be too short or the rhythm settings too fast.") |
| |
| progress(current_step / total_steps, desc=f"Applying transitions...") |
| final_silent_path = os.path.join(job_temp_dir, "final_silent.mp4") |
| if len(kb_clips) == 1: |
| shutil.copy(kb_clips[0]['path'], final_silent_path) |
| else: |
| all_transitions = ["fade", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright", "slideup", "slidedown", "dissolve"] |
| cmd = ["ffmpeg", "-y"] |
| filter_complex = [] |
| running_duration = 0 |
| |
| for i, clip in enumerate(kb_clips): |
| cmd.extend(["-i", clip['path']]) |
|
|
| for i in range(len(kb_clips) - 1): |
| input1 = f"[{i}:v]" if i == 0 else f"[v{i-1}]" |
| input2 = f"[{i+1}:v]" |
| output = f"[v{i}]" |
| transition = random.choice(all_transitions) if transition_style == "Random" else transition_style.lower() |
| offset = running_duration + kb_clips[i]['duration'] - transition_duration |
| filter_complex.append(f"{input1}{input2}xfade=transition={transition}:duration={transition_duration}:offset={offset}{output}") |
| running_duration += kb_clips[i]['duration'] - transition_duration |
|
|
| cmd.extend(["-filter_complex", ";".join(filter_complex), "-map", f"[v{len(kb_clips)-2}]", "-c:v", "libx264", "-pix_fmt", "yuv420p", final_silent_path]) |
| run_ffmpeg_command(cmd) |
|
|
| progress(0.98, desc="Muxing final audio...") |
| final_output_path = os.path.join(TEMP_DIR, f"slideshow_final_{timestamp}.mp4") |
| run_ffmpeg_command(["ffmpeg", "-y", "-i", final_silent_path, "-i", audio_path, "-c:v", "copy", "-c:a", "aac", "-shortest", final_output_path], "Muxing audio") |
| |
| return final_output_path |
| finally: |
| if os.path.exists(job_temp_dir): |
| shutil.rmtree(job_temp_dir) |
|
|
| def slideshow_wrapper(images, audio_path, kb_effect_style, transition_style, rhythm_choice, out_w, out_h, progress=gr.Progress(track_tqdm=True)): |
| return _create_automated_slideshow_impl(images, audio_path, kb_effect_style, transition_style, rhythm_choice, out_w, out_h, progress) |
|
|
| def _create_rhythmic_remix_impl(video_path, audio_path, cut_style, beat_sync, resolution_choice, custom_w, custom_h, progress: Progress): |
| """Internal implementation of the auto-rhythmic video remixer.""" |
| if not video_path: raise gr.Error("Please upload a source video.") |
| if not audio_path: raise gr.Error("Please upload an audio track.") |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| job_temp_dir = os.path.join(TEMP_DIR, f"remix_{timestamp}") |
| os.makedirs(job_temp_dir, exist_ok=True) |
| |
| try: |
| vf_filter = None |
| if resolution_choice == "Match Source Video Dimensions": |
| out_w, out_h = get_video_dimensions(video_path) |
| if out_w == 0 or out_h == 0: |
| gr.Warning("Could not read source video dimensions. Defaulting to 1080p.") |
| out_w, out_h = 1920, 1080 |
| vf_filter = f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease,pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2,setsar=1" |
| elif resolution_choice == "1080p (1920x1080)": |
| out_w, out_h = 1920, 1080 |
| elif resolution_choice == "720p (1280x720)": |
| out_w, out_h = 1280, 720 |
| elif resolution_choice == "Custom": |
| out_w, out_h = int(custom_w), int(custom_h) |
| if out_w <= 0 or out_h <= 0: |
| raise gr.Error("Custom width and height must be positive numbers.") |
| |
| out_w, out_h = out_w - (out_w % 2), out_h - (out_h % 2) |
|
|
| if vf_filter is None and resolution_choice != "Match Source Video Dimensions": |
| vf_filter = f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease,pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2,setsar=1" |
|
|
| progress(0, desc="Analyzing audio track for beats...") |
| try: |
| y, sr = librosa.load(audio_path) |
| audio_duration = librosa.get_duration(y=y, sr=sr) |
| _, beat_frames = librosa.beat.beat_track(y=y, sr=sr, units='frames') |
| beat_times = librosa.frames_to_time(beat_frames, sr=sr) |
| if len(beat_times) < 2: raise ValueError("Not enough beats were detected in the audio.") |
| except Exception as e: |
| raise gr.Error(f"Audio analysis failed: {e}") |
|
|
| beats_per_clip = {"On the Beat": 1, "Every 2 Beats": 2, "Every Measure (4 beats)": 4}[beat_sync] |
| clip_definitions = [] |
| clip_start_beat_indices = range(0, len(beat_times), beats_per_clip) |
| |
| for i, beat_index in enumerate(clip_start_beat_indices): |
| start_beat_time = beat_times[beat_index] |
| if i + 1 < len(clip_start_beat_indices): |
| end_beat_time = beat_times[clip_start_beat_indices[i+1]] |
| else: |
| end_beat_time = audio_duration |
| |
| duration = end_beat_time - start_beat_time |
| if duration > 0.1: |
| clip_definitions.append({'duration': duration}) |
|
|
| if not clip_definitions: |
| raise gr.Error("Could not define any video clips based on the detected beats.") |
| |
| progress(0.1, desc="Planning video cuts...") |
| source_duration = get_media_duration(video_path) |
| |
| current_time_in_source = 0 |
| for clip in clip_definitions: |
| if cut_style == "Sequential": |
| clip['source_start'] = current_time_in_source |
| current_time_in_source += clip['duration'] |
| if current_time_in_source > source_duration: |
| gr.Warning("Source video is shorter than the music. Looping video from the beginning.") |
| current_time_in_source = 0 |
| |
| elif cut_style == "Random Shuffle": |
| max_start_time = source_duration - clip['duration'] |
| clip['source_start'] = random.uniform(0, max_start_time) if max_start_time > 0 else 0 |
|
|
| extracted_clip_paths = [] |
| for i, clip in enumerate(clip_definitions): |
| progress(0.1 + (0.7 * (i / len(clip_definitions))), desc=f"Extracting clip {i+1}/{len(clip_definitions)}...") |
| output_clip_path = os.path.join(job_temp_dir, f"clip_{i:04d}.mp4") |
| |
| cmd = [ |
| "ffmpeg", "-y", |
| "-ss", str(clip['source_start']), |
| "-i", video_path, |
| "-t", str(clip['duration']), |
| "-an", |
| "-c:v", "libx264", "-pix_fmt", "yuv420p" |
| ] |
| if vf_filter: |
| cmd.extend(["-vf", vf_filter]) |
| cmd.append(output_clip_path) |
|
|
| run_ffmpeg_command(cmd) |
| extracted_clip_paths.append(output_clip_path) |
|
|
| progress(0.85, desc="Stitching clips together...") |
| file_list_path = os.path.join(job_temp_dir, "files.txt") |
| with open(file_list_path, 'w', encoding='utf-8') as f: |
| for path in extracted_clip_paths: |
| f.write(f"file '{os.path.abspath(path)}'\n") |
| |
| silent_final_path = os.path.join(job_temp_dir, "final_silent.mp4") |
| run_ffmpeg_command(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", file_list_path, "-c", "copy", silent_final_path], "Concatenating clips...") |
|
|
| progress(0.95, desc="Adding music...") |
| final_output_path = os.path.join(TEMP_DIR, f"remix_final_{timestamp}.mp4") |
| run_ffmpeg_command(["ffmpeg", "-y", "-i", silent_final_path, "-i", audio_path, "-c:v", "copy", "-c:a", "aac", "-map", "0:v:0", "-map", "1:a:0", "-shortest", final_output_path], "Muxing audio") |
| |
| return final_output_path |
|
|
| finally: |
| if os.path.exists(job_temp_dir): |
| shutil.rmtree(job_temp_dir) |
|
|
| def rhythmic_remix_wrapper(video_path, audio_path, cut_style, beat_sync, resolution_choice, custom_w, custom_h, progress=gr.Progress(track_tqdm=True)): |
| return _create_rhythmic_remix_impl(video_path, audio_path, cut_style, beat_sync, resolution_choice, custom_w, custom_h, progress) |
|
|
|
|
| |
| bling_css = """ |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap'); |
| |
| :root { |
| --bling-main-font: 'Inter', sans-serif; |
| --bling-gradient-start: #0f172a; |
| --bling-gradient-mid: #1e293b; |
| --bling-gradient-end: #334155; |
| --bling-accent-color: #38bdf8; /* sky-400 */ |
| } |
| |
| body, .gradio-container { |
| font-family: var(--bling-main-font) !important; |
| background: var(--bling-gradient-start); |
| background: linear-gradient(135deg, var(--bling-gradient-start) 0%, var(--bling-gradient-mid) 50%, var(--bling-gradient-end) 100%); |
| background-size: 200% 200%; |
| animation: gradient-animation 15s ease infinite; |
| } |
| |
| @keyframes gradient-animation { |
| 0% { background-position: 0% 50%; } |
| 50% { background-position: 100% 50%; } |
| 100% { background-position: 0% 50%; } |
| } |
| |
| /* Glassmorphism for containers */ |
| .gradio-tabs, .gradio-accordion, .gradio-group { |
| background: rgba(255, 255, 255, 0.05) !important; |
| border: 1px solid rgba(255, 255, 255, 0.1) !important; |
| border-radius: 12px !important; |
| backdrop-filter: blur(10px) !important; |
| -webkit-backdrop-filter: blur(10px) !important; |
| box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1) !important; |
| } |
| |
| /* Button Bling */ |
| .gradio-button { |
| transition: all 0.2s ease-in-out !important; |
| box-shadow: 0 2px 4px rgba(0,0,0,0.2) !important; |
| } |
| .gradio-button:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 4px 8px rgba(0,0,0,0.3) !important; |
| filter: brightness(1.1); |
| } |
| |
| /* Custom Info/Warning Boxes */ |
| .gradio-info { |
| background: rgba(56, 189, 248, 0.1) !important; /* sky-400 with alpha */ |
| color: #f0f9ff !important; /* sky-50 */ |
| border-left: 4px solid var(--bling-accent-color) !important; |
| border-radius: 8px !important; |
| } |
| .gradio-warning { |
| background: rgba(251, 191, 36, 0.1) !important; /* amber-400 with alpha */ |
| color: #fffbeb !important; /* amber-50 */ |
| border-left: 4px solid #fbbf24 !important; |
| border-radius: 8px !important; |
| } |
| |
| |
| /* Custom Scrollbars */ |
| ::-webkit-scrollbar { width: 8px; } |
| ::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.1); } |
| ::-webkit-scrollbar-thumb { background-color: var(--bling-accent-color); border-radius: 4px; } |
| ::-webkit-scrollbar-thumb:hover { background-color: #0ea5e9; } /* sky-500 */ |
| |
| #custom-footer { |
| text-align: center !important; |
| padding: 20px 0 5px 0 !important; |
| font-size: .9em; |
| color: #94a3b8; /* slate-400 */ |
| } |
| |
| /* Loading Overlay CSS */ |
| #loading-overlay { |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100vw; |
| height: 100vh; |
| background-color: rgba(15, 23, 42, 0.8); |
| z-index: 10000; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| flex-direction: column; |
| color: white; |
| font-size: 1.2em; |
| backdrop-filter: blur(5px); |
| -webkit-backdrop-filter: blur(5px); |
| opacity: 0; |
| visibility: hidden; |
| transition: opacity 0.3s ease, visibility 0.3s ease; |
| } |
| #loading-overlay.visible { |
| opacity: 1; |
| visibility: visible; |
| } |
| .spinner { |
| width: 60px; |
| height: 60px; |
| border: 5px solid rgba(255, 255, 255, 0.3); |
| border-top-color: var(--bling-accent-color); |
| border-radius: 50%; |
| animation: spin 1s linear infinite; |
| margin-bottom: 20px; |
| } |
| @keyframes spin { |
| to { transform: rotate(360deg); } |
| } |
| """ |
|
|
| bling_js = """ |
| () => { |
| // --- JKL Video Control --- |
| let activeVideo = null; |
| document.addEventListener('mouseover', (e) => { |
| if (e.target.tagName === 'VIDEO') { |
| activeVideo = e.target; |
| } |
| }); |
| document.addEventListener('keydown', (e) => { |
| const activeElement = document.activeElement; |
| if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) { |
| return; |
| } |
| if (!activeVideo) return; |
| const frameTime = 1 / 30; |
| let handled = false; |
| switch (e.key.toLowerCase()) { |
| case 'k': activeVideo.paused ? activeVideo.play() : activeVideo.pause(); handled = true; break; |
| case 'j': activeVideo.currentTime = Math.max(0, activeVideo.currentTime - frameTime); handled = true; break; |
| case 'l': activeVideo.currentTime += frameTime; handled = true; break; |
| } |
| if (handled) e.preventDefault(); |
| }); |
| |
| // --- Loading Overlay --- |
| function show_overlay(message = 'Processing... Please wait.') { |
| let overlay = document.getElementById('loading-overlay'); |
| if (!overlay) { |
| overlay = document.createElement('div'); |
| overlay.id = 'loading-overlay'; |
| overlay.innerHTML = `<div class="spinner"></div><p id="loading-message"></p>`; |
| document.body.appendChild(overlay); |
| } |
| document.getElementById('loading-message').textContent = message; |
| overlay.classList.add('visible'); |
| } |
| function hide_overlay() { |
| const overlay = document.getElementById('loading-overlay'); |
| if (overlay) { |
| overlay.classList.remove('visible'); |
| } |
| } |
| |
| // --- Confetti --- |
| function fire_confetti() { |
| const a=document.createElement("script");a.setAttribute("src","https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/dist/confetti.browser.min.js"),document.head.appendChild(a),a.onload=()=>{var e=confetti.create(null,{resize:!0,useWorker:!0});e({particleCount:150,spread:90,origin:{y:.6}})} |
| } |
| |
| // --- Audio Feedback with Synthesized Whistle --- |
| const skriptz_audio = { |
| context: null, |
| isInitialized: false, |
| }; |
| |
| async function init_audio() { |
| if (skriptz_audio.isInitialized) return; |
| try { |
| skriptz_audio.context = new (window.AudioContext || window.webkitAudioContext)(); |
| if (skriptz_audio.context.state === 'suspended') { |
| await skriptz_audio.context.resume(); |
| } |
| } catch (e) { |
| console.error('Failed to initialize Web Audio API:', e); |
| } |
| skriptz_audio.isInitialized = true; |
| } |
| |
| async function play_finish_sound() { |
| if (!skriptz_audio.isInitialized) { |
| await init_audio(); |
| } |
| const context = skriptz_audio.context; |
| if (!context) return; |
| |
| if (context.state === 'suspended') { |
| await context.resume(); |
| } |
| |
| const now = context.currentTime; |
| const delay = 0.2; |
| const startTime = now + delay; |
| |
| const oscillator = context.createOscillator(); |
| const gainNode = context.createGain(); |
| |
| oscillator.connect(gainNode); |
| gainNode.connect(context.destination); |
| |
| oscillator.type = 'sine'; |
| |
| const startFreq = 2000; |
| const endFreq = 1000; |
| oscillator.frequency.setValueAtTime(startFreq, startTime); |
| oscillator.frequency.exponentialRampToValueAtTime(endFreq, startTime + 0.15); |
| |
| gainNode.gain.setValueAtTime(0, startTime); |
| gainNode.gain.linearRampToValueAtTime(0.4, startTime + 0.02); |
| gainNode.gain.linearRampToValueAtTime(0, startTime + 0.15); |
| |
| oscillator.start(startTime); |
| oscillator.stop(startTime + 0.2); |
| } |
| |
| // --- Dynamic Page Title --- |
| function update_title(tab_name) { |
| if (tab_name) { |
| const clean_name = tab_name.replace(/[\\u{1F600}-\\u{1F64F}\\u{1F300}-\\u{1F5FF}\\u{1F680}-\\u{1F6FF}\\u{1F700}-\\u{1F77F}\\u{1F780}-\\u{1F7FF}\\u{1F800}-\\u{1F8FF}\\u{1F900}-\\u{1F9FF}\\u{1FA00}-\\u{1FA6F}\\u{1FA70}-\\u{1FAFF}\\u{2600}-\\u{26FF}\\u{2700}-\\u{27BF}]/gu, '').trim(); |
| document.title = `Skriptz - ${clean_name}`; |
| } else { |
| document.title = "Skriptz - Universal Tool"; |
| } |
| } |
| |
| // --- Copy to Clipboard --- |
| function copy_to_clipboard(text_id) { |
| const text_area = document.getElementById(text_id).querySelector('textarea'); |
| if(text_area) { |
| text_area.select(); |
| document.execCommand('copy'); |
| const original_button = this.event.target; |
| const original_text = original_button.innerText; |
| original_button.innerText = 'Copied!'; |
| setTimeout(() => { original_button.innerText = original_text; }, 2000); |
| } |
| } |
| |
| // --- Storyboard Time Getter --- |
| function storyboard_get_time(){ |
| const e=document.querySelector('#storyboard_clip_preview video'); |
| return e?e.currentTime:0 |
| } |
| |
| // Make functions globally accessible for Gradio |
| window.skriptz_bling = { |
| show_overlay, |
| hide_overlay, |
| fire_confetti, |
| play_finish_sound, |
| update_title, |
| copy_to_clipboard, |
| storyboard_get_time |
| }; |
| } |
| """ |
|
|
| with gr.Blocks( |
| title="Skriptz - Universal Tool", |
| css=bling_css, |
| js=bling_js |
| ) as demo: |
| gr.HTML(""" |
| <div id="loading-overlay"> |
| <div class="spinner"></div> |
| <p id="loading-message">Processing... Please wait.</p> |
| </div> |
| <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/dist/confetti.browser.min.js"></script> |
| """, visible=False) |
|
|
| logo_b64 = get_image_as_base64("logo.png") |
| if logo_b64: gr.HTML(f"""<div style="display: flex; justify-content: center; align-items: center; text-align: center; margin-bottom: 20px;"><a href="https://linktr.ee/skylinkd" target="_blank" rel="noopener noreferrer"><img src="{logo_b64}" alt="Skriptz Banner" style="max-width: 100%; max-height: 100px; height: auto;"></a></div>""") |
| else: gr.Markdown("# Skriptz Universal Tool") |
| gr.Markdown("<h3 style='text-align: center;'>Your one-stop shop for video and image processing</h3>") |
|
|
| storyboard_get_time_js = "() => { return window.skriptz_bling.storyboard_get_time(); }" |
| show_overlay_js = "() => { window.skriptz_bling.show_overlay('Working hard... this may take a moment!'); }" |
| hide_overlay_js = "() => { window.skriptz_bling.hide_overlay(); }" |
| fire_confetti_and_sound_js = "() => { window.skriptz_bling.fire_confetti(); window.skriptz_bling.play_finish_sound(); }" |
| copy_transcription_js = "() => { window.skriptz_bling.copy_to_clipboard('transcription_textbox'); }" |
|
|
| |
| UNIFIED_HEIGHT = 440 |
|
|
| with gr.Tabs(elem_id="main_tabs") as main_tabs: |
| with gr.TabItem("π¬ Storyboard & Animatic", elem_id="storyboard_tab"): |
| gr.Markdown("## Create Video Animatics from Images and Clips") |
| gr.Info("1. **Build:** Upload assets, click to add to timeline. 2. **Time:** Set durations, trim videos, or use Rhythmic Editing. 3. **Generate:** Create your final video.") |
| |
| assets_state = gr.State([]) |
| timeline_state = gr.State([]) |
| selected_timeline_index_state = gr.State(-1) |
|
|
| with gr.Row(equal_height=False): |
| with gr.Column(scale=2, min_width=400): |
| with gr.Group(): |
| gr.Markdown("### 1. Asset Bin") |
| assets_upload_btn = gr.File(label="Upload Images & Video Clips", file_count="multiple", file_types=["image", "video"]) |
| asset_gallery = gr.Gallery(label="Click an asset to add it to the timeline", columns=4, object_fit="contain", height=400) |
| asset_preview_gallery = gr.Gallery(label="Video Asset Preview (First & Last Frame)", columns=2, height=240, object_fit="contain", interactive=False) |
| with gr.Row(): |
| add_all_to_timeline_btn = gr.Button("β¬οΈ Add All to Timeline") |
| clear_assets_btn = gr.Button("ποΈ Clear Asset Bin") |
|
|
| with gr.Column(scale=3, min_width=600): |
| with gr.Group(): |
| gr.Markdown("### 2. Timeline & Generation") |
| timeline_df = gr.DataFrame(headers=["#", "Asset", "Type", "Duration (s)"], datatype=["number", "str", "str", "number"], interactive=False, row_count=(10, "fixed")) |
| with gr.Row(): |
| timeline_up_btn = gr.Button("β¬οΈ Move Up", interactive=False) |
| timeline_down_btn = gr.Button("β¬οΈ Move Down", interactive=False) |
| timeline_remove_btn = gr.Button("ποΈ Remove", interactive=False) |
| clear_timeline_btn = gr.Button("π₯ Clear Timeline") |
| |
| gr.Markdown("### 3. Output Settings") |
| keep_audio_checkbox = gr.Checkbox(label="Keep Original Audio from Video Clips", value=False, info="If checked, the Project Audio Track below will be ignored.") |
| animatic_audio = gr.Audio(label="Project Audio Track (Narration/Music)", type="filepath") |
| with gr.Row(): |
| animatic_out_w = gr.Number(label="Output Width", value=1920) |
| animatic_out_h = gr.Number(label="Output Height", value=1080) |
| match_first_asset_btn = gr.Button("π Match First Asset's Resolution") |
| generate_animatic_btn = gr.Button("π¬ Generate Manual Animatic", variant="secondary") |
|
|
| with gr.Accordion("π΅ Rhythmic Editing (Beat Sync)", open=False): |
| gr.Info("This will override manual durations and re-time all clips to match the music's rhythm.") |
| with gr.Row(): |
| analyze_bpm_btn = gr.Button("π₯ Analyze BPM") |
| bpm_display = gr.Textbox(label="Audio BPM", interactive=False) |
| measure_dropdown = gr.Dropdown( |
| ["2 Measures", "1 Measure", "1/2 Measure", "1/4 Measure (Beat)"], |
| value="1 Measure", label="Cut Duration per Clip" |
| ) |
| generate_rhythmic_btn = gr.Button("πΆ Generate Rhythmic Animatic", variant="primary") |
| |
| animatic_output_video = gr.Video(label="Final Video Output", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
| |
| with gr.Column(scale=2, min_width=300): |
| with gr.Group(): |
| gr.Markdown("### 4. Clip Properties") |
| gr.Info("Select a clip in the timeline table to edit it.") |
| clip_preview = gr.Video(label="Selected Clip Preview", interactive=True, elem_id="storyboard_clip_preview", height=UNIFIED_HEIGHT) |
| clip_duration_input = gr.Number(label="Set Duration (seconds)", interactive=True, precision=2) |
| update_clip_btn = gr.Button("π Update Clip Duration") |
|
|
| with gr.Group(visible=False) as trim_group: |
| gr.Markdown("#### Video Trimming") |
| gr.Info("Use player (K=Play, J/L=Frame Step) to find a frame, then use buttons below.") |
| with gr.Row(): |
| clip_start_time_input = gr.Number(label="Start Time (s)", precision=2) |
| clip_end_time_input = gr.Number(label="End Time (s)", precision=2) |
| with gr.Row(): |
| set_clip_start_btn = gr.Button("Set START") |
| set_clip_end_btn = gr.Button("Set END") |
| apply_trim_btn = gr.Button("βοΈ Apply Trim") |
|
|
| assets_upload_btn.upload(add_assets_to_bin, [assets_upload_btn, assets_state], [assets_state, asset_gallery]) |
| asset_gallery.select(handle_asset_selection, [assets_state, timeline_state], [timeline_state, asset_preview_gallery]) |
| add_all_to_timeline_btn.click(add_all_assets_to_timeline, [assets_state, timeline_state], timeline_state) |
| timeline_state.change(update_timeline_df, timeline_state, timeline_df) |
| timeline_state.change(lambda: (-1, None, None, gr.update(interactive=False), gr.update(interactive=False), gr.update(interactive=False), gr.update(visible=False), 0, 0), |
| outputs=[selected_timeline_index_state, clip_preview, clip_duration_input, timeline_up_btn, timeline_down_btn, timeline_remove_btn, trim_group, clip_start_time_input, clip_end_time_input]) |
| timeline_df.select( |
| handle_timeline_selection, |
| timeline_state, |
| [selected_timeline_index_state, clip_preview, clip_duration_input, timeline_up_btn, timeline_down_btn, timeline_remove_btn, trim_group, clip_start_time_input, clip_end_time_input] |
| ) |
| update_clip_btn.click(update_clip_properties, [timeline_state, selected_timeline_index_state, clip_duration_input], timeline_state) |
| apply_trim_btn.click(apply_trim_and_update, [timeline_state, selected_timeline_index_state, clip_start_time_input, clip_end_time_input], [timeline_state, clip_duration_input]) |
| set_clip_start_btn.click(fn=None, js=storyboard_get_time_js, outputs=clip_start_time_input) |
| set_clip_end_btn.click(fn=None, js=storyboard_get_time_js, outputs=clip_end_time_input) |
| timeline_up_btn.click(handle_timeline_action, [timeline_state, selected_timeline_index_state, gr.State("up")], [timeline_state, timeline_df]) |
| timeline_down_btn.click(handle_timeline_action, [timeline_state, selected_timeline_index_state, gr.State("down")], [timeline_state, timeline_df]) |
| timeline_remove_btn.click(handle_timeline_action, [timeline_state, selected_timeline_index_state, gr.State("remove")], [timeline_state, timeline_df]) |
| clear_assets_btn.click(lambda: ([], gr.update(value=None), None), outputs=[assets_state, asset_gallery, asset_preview_gallery]) |
| clear_timeline_btn.click(lambda: [], outputs=[timeline_state]) |
| keep_audio_checkbox.change(fn=lambda x: gr.update(interactive=not x), inputs=keep_audio_checkbox, outputs=animatic_audio) |
| generate_animatic_btn.click(fn=create_animatic, inputs=[timeline_state, animatic_audio, animatic_out_w, animatic_out_h, keep_audio_checkbox], outputs=animatic_output_video).then(fn=None, js=fire_confetti_and_sound_js) |
| analyze_bpm_btn.click(detect_bpm, animatic_audio, bpm_display) |
| generate_rhythmic_btn.click(fn=create_rhythmic_animatic, inputs=[timeline_state, animatic_audio, measure_dropdown, animatic_out_w, animatic_out_h], outputs=animatic_output_video).then(fn=None, js=fire_confetti_and_sound_js) |
| match_first_asset_btn.click(set_resolution_from_first_asset, timeline_state, [animatic_out_w, animatic_out_h]) |
| |
| with gr.TabItem("π¨ Creative Suite", elem_id="creative_tab"): |
| with gr.Tabs(): |
| with gr.TabItem("π¬ Automated Slideshow"): |
| gr.Markdown("## Automated Rhythmic Slideshow Creator") |
| gr.Info("Turn a collection of images and a music track into a dynamic video with Ken Burns effects and transitions synced to the beat.") |
| with gr.Row(): |
| with gr.Column(scale=2): |
| slideshow_input_images = gr.File(label="Upload Images", file_count="multiple", file_types=["image"]) |
| slideshow_audio = gr.Audio(label="Upload Music Track", type="filepath") |
| |
| with gr.Accordion("βοΈ Style & Timing", open=True): |
| slideshow_kb_effect = gr.Dropdown(["Subtle", "Standard", "Dynamic"], value="Standard", label="Ken Burns Effect Intensity") |
| slideshow_transition = gr.Dropdown(["Random", "Fade", "WipeLeft", "WipeRight", "Dissolve", "SlideLeft", "SlideRight"], value="Random", label="Transition Style") |
| slideshow_rhythm = gr.Dropdown(["1 Image per Beat", "1 Image every 2 Beats", "1 Image per Measure (4 Beats)"], value="1 Image every 2 Beats", label="Image Display Rhythm") |
| |
| with gr.Row(): |
| slideshow_out_w = gr.Number(label="Output Width", value=1920) |
| slideshow_out_h = gr.Number(label="Output Height", value=1080) |
| |
| slideshow_generate_btn = gr.Button("π Generate Slideshow", variant="primary") |
| with gr.Column(scale=3): |
| slideshow_output_video = gr.Video(label="Generated Slideshow Video", show_download_button=True, height=UNIFIED_HEIGHT) |
|
|
| slideshow_generate_btn.click( |
| fn=slideshow_wrapper, |
| inputs=[slideshow_input_images, slideshow_audio, slideshow_kb_effect, slideshow_transition, slideshow_rhythm, slideshow_out_w, slideshow_out_h], |
| outputs=slideshow_output_video |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("π΅ Auto Music Video"): |
| gr.Markdown("## Automatic Rhythmic Video Remixer") |
| gr.Info("Automatically cuts a source video to the beat of a music track, creating a dynamic music video.") |
| with gr.Row(): |
| with gr.Column(scale=2): |
| remix_input_video = gr.Video(label="Upload Source Video", height=UNIFIED_HEIGHT) |
| remix_audio = gr.Audio(label="Upload Music Track", type="filepath") |
| |
| with gr.Accordion("βοΈ Remix Settings", open=True): |
| remix_cut_style = gr.Radio( |
| ["Sequential", "Random Shuffle"], |
| value="Random Shuffle", |
| label="Video Cut Style", |
| info="Sequential: Cuts the video from start to finish. Random Shuffle: Picks random moments from the source video." |
| ) |
| remix_beat_sync = gr.Dropdown( |
| ["On the Beat", "Every 2 Beats", "Every Measure (4 beats)"], |
| value="Every 2 Beats", |
| label="Cutting Frequency" |
| ) |
| remix_resolution = gr.Radio( |
| ["Match Source Video Dimensions", "1080p (1920x1080)", "720p (1280x720)", "Custom"], |
| value="1080p (1920x1080)", |
| label="Output Resolution" |
| ) |
| with gr.Row(visible=False) as custom_res_row: |
| remix_custom_w = gr.Number(label="Custom Width", value=1920) |
| remix_custom_h = gr.Number(label="Custom Height", value=1080) |
| |
| remix_generate_btn = gr.Button("π€ Generate Music Video", variant="primary") |
| with gr.Column(scale=3): |
| remix_output_video = gr.Video(label="Generated Music Video", show_download_button=True, height=UNIFIED_HEIGHT) |
| |
| remix_resolution.change( |
| fn=lambda choice: gr.update(visible=(choice == "Custom")), |
| inputs=remix_resolution, |
| outputs=custom_res_row |
| ) |
|
|
| remix_generate_btn.click( |
| fn=rhythmic_remix_wrapper, |
| inputs=[remix_input_video, remix_audio, remix_cut_style, remix_beat_sync, remix_resolution, remix_custom_w, remix_custom_h], |
| outputs=remix_output_video |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("β‘ Gradual Speed Ramp"): |
| gr.Markdown("## Create a Smooth, Gradual Speed Ramp Effect") |
| gr.Info("Applies a smooth 'bullet-time' like effect to the entire video, slowing down to 50% speed in the middle and then ramping back up to normal speed. This uses frame interpolation for a high-quality result.") |
| with gr.Row(): |
| with gr.Column(scale=2): |
| gradual_ramp_input_video = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| gradual_ramp_btn = gr.Button("β Apply Gradual Ramp", variant="primary") |
| with gr.Column(scale=3): |
| gradual_ramp_output_video = gr.Video(label="Ramped Video Output", show_download_button=True, height=UNIFIED_HEIGHT) |
| |
| gradual_ramp_btn.click( |
| fn=create_gradual_ramp_video, |
| inputs=[gradual_ramp_input_video], |
| outputs=gradual_ramp_output_video, |
| show_progress="full" |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("ποΈ Auto-Trailer"): |
| gr.Markdown("## Automatic Trailer Creator") |
| gr.Info("Upload a long video, and this tool will find the most action-packed moments to create a short, dynamic trailer.") |
| with gr.Row(): |
| with gr.Column(scale=2): |
| trailer_input_video = gr.Video(label="Upload Source Video", height=UNIFIED_HEIGHT) |
| trailer_music = gr.Audio(label="Add Background Music (Optional)", type="filepath") |
| |
| with gr.Accordion("βοΈ Trailer Settings", open=True): |
| trailer_total_duration = gr.Slider(10, 120, 30, step=5, label="Total Trailer Length (s)") |
| trailer_clip_duration = gr.Slider(1.0, 5.0, 2.0, step=0.5, label="Duration of Each Clip (s)") |
| trailer_analysis_method = gr.Dropdown(["Motion"], value="Motion", label="Scene Analysis Method", info="Currently only motion detection is supported.") |
| trailer_transition = gr.Dropdown(["None", "Fade", "WipeLeft", "WipeRight", "Dissolve", "SlideLeft", "SlideRight"], value="Fade", label="Transition Style") |
| |
| with gr.Row(): |
| trailer_out_w = gr.Number(label="Output Width", value=1920) |
| trailer_out_h = gr.Number(label="Output Height", value=1080) |
| |
| trailer_generate_btn = gr.Button("π Generate Trailer", variant="primary") |
| with gr.Column(scale=3): |
| trailer_output_video = gr.Video(label="Generated Trailer Video", show_download_button=True, height=UNIFIED_HEIGHT) |
|
|
| trailer_generate_btn.click( |
| fn=auto_trailer_wrapper, |
| inputs=[ |
| trailer_input_video, trailer_total_duration, trailer_clip_duration, |
| trailer_analysis_method, trailer_transition, trailer_music, |
| trailer_out_w, trailer_out_h |
| ], |
| outputs=trailer_output_video |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("π΅ Audio Waveform"): |
| gr.Markdown("## Generate & Overlay Audio Waveforms") |
| gr.Info("Upload a video with an audio track to generate a dynamic waveform visualization.") |
| with gr.Row(): |
| with gr.Column(): |
| waveform_input_video = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| with gr.Row(): |
| waveform_style = gr.Dropdown(["line", "p2p", "point"], value="line", label="Waveform Style") |
| waveform_size = gr.Textbox(value="800x200", label="Size (WxH)") |
| with gr.Row(): |
| waveform_position = gr.Dropdown(["Bottom", "Center", "Top"], value="Bottom", label="Position") |
| waveform_color = gr.ColorPicker(value="#38bdf8", label="Color") |
| waveform_btn = gr.Button("πΆ Generate Waveform Video", variant="primary") |
| with gr.Column(): |
| waveform_output_video = gr.Video(label="Output Video with Waveform", show_download_button=True, height=UNIFIED_HEIGHT) |
| waveform_btn.click( |
| fn=generate_waveform_video, |
| inputs=[waveform_input_video, waveform_style, waveform_size, waveform_position, waveform_color], |
| outputs=waveform_output_video |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("πΌοΈ Picture-in-Picture"): |
| gr.Markdown("## Create Picture-in-Picture (PiP) & Image Overlays") |
| gr.Info("Upload a main video and an overlay (video or image). The overlay will be placed on top of the main video.") |
| with gr.Row(): |
| with gr.Column(): |
| pip_main_video = gr.Video(label="Main Video (Background)", height=UNIFIED_HEIGHT) |
| pip_overlay_media = gr.File(label="Overlay Media (Video or Image)", file_types=["video", "image"]) |
| with gr.Row(): |
| pip_position = gr.Dropdown( |
| ["Top-Left", "Top-Center", "Top-Right", "Center-Left", "Center", "Center-Right", "Bottom-Left", "Bottom-Center", "Bottom-Right"], |
| value="Bottom-Right", label="Position" |
| ) |
| pip_scale = gr.Slider(0.01, 0.75, 0.25, step=0.01, label="Overlay Size") |
| pip_btn = gr.Button("β¨ Create PiP Video", variant="primary") |
| with gr.Column(): |
| pip_output_video = gr.Video(label="Output PiP Video", show_download_button=True, height=UNIFIED_HEIGHT) |
| pip_btn.click( |
| fn=create_pip_video, |
| inputs=[pip_main_video, pip_overlay_media, pip_position, pip_scale], |
| outputs=pip_output_video |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("π Meme Creator"): |
| gr.Markdown("## Classic Meme & Text Overlay Creator") |
| gr.Info("Upload an image and add text. Font availability depends on your operating system.") |
| with gr.Row(): |
| with gr.Column(): |
| meme_input_image = gr.Image(type="numpy", label="Input Image", height=UNIFIED_HEIGHT) |
| meme_text = gr.Textbox(label="Text", placeholder="Your witty text here...") |
| meme_position = gr.Radio(["Top", "Center", "Bottom"], value="Top", label="Text Position") |
| meme_font = gr.Dropdown( |
| ["Impact", "Arial", "Arial Black", "Comic Sans MS", "Courier New", "Georgia", "Tahoma", "Times New Roman", "Trebuchet MS", "Verdana"], |
| value="Impact", |
| label="Font" |
| ) |
| with gr.Row(): |
| meme_text_color = gr.ColorPicker(value="#FFFFFF", label="Text Color") |
| meme_outline_color = gr.ColorPicker(value="#000000", label="Outline Color") |
| meme_font_size = gr.Slider(1, 10, 5, step=1, label="Relative Font Size") |
| meme_btn = gr.Button("π Generate Meme", variant="primary") |
| with gr.Column(): |
| meme_output_image = gr.Image(label="Output Image", show_download_button=True, height=UNIFIED_HEIGHT) |
| meme_btn.click( |
| fn=create_meme, |
| inputs=[meme_input_image, meme_text, meme_position, meme_font, meme_font_size, meme_text_color, meme_outline_color], |
| outputs=meme_output_image |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("π€ FLUX.1 API"): |
| gr.Markdown("### Generate an image using `FLUX.1` models via Gradio Client.") |
| gr.Info("Requires a Hugging Face User Access Token.") |
| with gr.Row(): |
| with gr.Column(): |
| hf_token_input = gr.Textbox(label="HF Token", type="password", placeholder="Enter hf_... token") |
| flux_model_dropdown = gr.Dropdown(list(FLUX_MODELS.keys()), value="FLUX.1-schnell (Fast)", label="Select FLUX Model") |
| prompt_input_flux = gr.Textbox(label="Prompt", lines=3, placeholder="A cinematic photo...") |
| with gr.Row(): |
| flux_width_slider = gr.Slider(256, 2048, 1024, step=64, label="Width") |
| flux_height_slider = gr.Slider(256, 2048, 1024, step=64, label="Height") |
| flux_btn = gr.Button("βοΈ Generate Image", variant="primary") |
| with gr.Column(): |
| output_image_flux = gr.Image(label="Generated Image", show_download_button=True, height=UNIFIED_HEIGHT) |
| flux_btn.click(call_flux_api, [prompt_input_flux, flux_model_dropdown, flux_width_slider, flux_height_slider, hf_token_input], output_image_flux).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("πΌοΈ Image Utilities", elem_id="image_tab"): |
| with gr.Tabs(): |
| with gr.TabItem("β¨ Manipulate"): |
| gr.Markdown("### Simple Image Manipulation") |
| gr.Info("Apply a single transformation like inverting colors, flipping, or rotating.") |
| with gr.Row(): |
| with gr.Column(): |
| manip_input_image = gr.Image(type="numpy", label="Input Image", height=UNIFIED_HEIGHT) |
| manip_operation_radio = gr.Radio( |
| ["Invert Colors", "Flip Horizontal", "Flip Vertical", "Rotate 90Β° Right", "Rotate 90Β° Left"], |
| label="Select Operation", value="Invert Colors" |
| ) |
| manip_apply_btn = gr.Button("π¨ Apply Manipulation", variant="primary") |
| with gr.Column(): |
| manip_output_image = gr.Image(label="Output Image", show_download_button=True, height=UNIFIED_HEIGHT) |
| manip_apply_btn.click(fn=manipulate_image, inputs=[manip_input_image, manip_operation_radio], outputs=manip_output_image).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("βοΈ Batch Cropper"): |
| gr.Markdown("### Crop a batch of images to the same dimensions.") |
| gr.Info("Upload images, and the first image will be used as a preview. Adjust the sliders to see the crop area, then process the whole batch.") |
| crop_original_preview_state = gr.State() |
| with gr.Row(): |
| with gr.Column(scale=1): |
| crop_input_images = gr.File(label="Upload Images", file_count="multiple", file_types=["image"]) |
| gr.Markdown("#### Crop Box Settings") |
| crop_box_w = gr.Slider(label="Width", minimum=64, maximum=4096, step=8, value=512) |
| crop_box_h = gr.Slider(label="Height", minimum=64, maximum=4096, step=8, value=512) |
| crop_box_x = gr.Slider(label="X Offset", minimum=0, maximum=4096, step=8, value=0) |
| crop_box_y = gr.Slider(label="Y Offset", minimum=0, maximum=4096, step=8, value=0) |
| crop_btn = gr.Button("βοΈ Crop All Images", variant="primary") |
| with gr.Column(scale=2): |
| crop_preview_image_display = gr.Image(label="Crop Preview (on first image)", type="pil", interactive=False, height=UNIFIED_HEIGHT) |
| crop_output_gallery = gr.Gallery(label="Cropped Images Preview", columns=4, object_fit="contain", height="auto") |
| crop_output_zip = gr.File(label="Download All as .zip", interactive=False) |
| |
| def setup_crop_preview(files): |
| if not files: |
| return None, None, gr.update(), gr.update(), gr.update(), gr.update() |
| |
| first_image_path = files[0].name |
| try: |
| img = Image.open(first_image_path).convert("RGB") |
| w, h = img.size |
| return img, img, gr.update(maximum=w, value=min(512, w)), gr.update(maximum=h, value=min(512, h)), gr.update(maximum=w), gr.update(maximum=h) |
| except Exception as e: |
| gr.Warning(f"Could not load preview image: {e}") |
| return None, None, gr.update(), gr.update(), gr.update(), gr.update() |
| |
| crop_input_images.upload( |
| fn=setup_crop_preview, |
| inputs=[crop_input_images], |
| outputs=[crop_preview_image_display, crop_original_preview_state, crop_box_w, crop_box_h, crop_box_x, crop_box_y] |
| ) |
|
|
| crop_sliders = [crop_box_x, crop_box_y, crop_box_w, crop_box_h] |
| for slider in crop_sliders: |
| slider.release( |
| fn=update_crop_preview, |
| inputs=[crop_original_preview_state] + crop_sliders, |
| outputs=crop_preview_image_display |
| ) |
|
|
| crop_btn.click( |
| fn=batch_crop_images, |
| inputs=[crop_input_images] + crop_sliders, |
| outputs=[crop_output_gallery, crop_output_zip] |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("π§© Collage Maker"): |
| gr.Markdown("### Create a collage from multiple images.") |
| with gr.Row(): |
| with gr.Column(): |
| collage_input_images = gr.File(label="Upload Images", file_count="multiple", file_types=["image"]) |
| collage_layout = gr.Radio(["Grid", "Horizontal", "Vertical"], value="Grid", label="Layout") |
| collage_w = gr.Slider(label="Collage Width", minimum=256, maximum=4096, value=1920, step=64) |
| collage_h = gr.Slider(label="Collage Height", minimum=256, maximum=4096, value=1080, step=64) |
| collage_bg_color = gr.ColorPicker(value="#000000", label="Background Color") |
| collage_btn = gr.Button("π¨ Create Collage", variant="primary") |
| with gr.Column(): |
| collage_output_image = gr.Image(label="Output Collage", show_download_button=True, height=UNIFIED_HEIGHT) |
| collage_btn.click( |
| fn=create_collage, |
| inputs=[collage_input_images, collage_layout, collage_w, collage_h, collage_bg_color], |
| outputs=collage_output_image |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("πΈ Duo-Stitcher"): |
| gr.Markdown("### Smart Image Stitcher") |
| gr.Info("Upload two vertical images to join them side-by-side, or two horizontal images to stack them. The result is always a square.") |
| with gr.Row(): |
| with gr.Column(): |
| stitch_img1 = gr.Image(type="numpy", label="Image 1 (Left/Top)", height=UNIFIED_HEIGHT) |
| stitch_img2 = gr.Image(type="numpy", label="Image 2 (Right/Bottom)", height=UNIFIED_HEIGHT) |
| with gr.Row(): |
| stitch_size = gr.Slider(512, 4096, 1024, step=128, label="Output Size (pixels)") |
| stitch_bg_color = gr.ColorPicker(value="#000000", label="Background Color") |
| stitch_btn = gr.Button("π§© Stitch Images", variant="primary") |
| with gr.Column(): |
| stitch_output_image = gr.Image(label="Stitched Output Image", show_download_button=True, height=UNIFIED_HEIGHT) |
| stitch_btn.click( |
| fn=stitch_images_smartly, |
| inputs=[stitch_img1, stitch_img2, stitch_size, stitch_bg_color], |
| outputs=stitch_output_image |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("πΉ Image to Video"): |
| gr.Markdown("### Create a short, looping video from a single static image.") |
| with gr.Row(): |
| with gr.Column(): |
| input_image_i2v = gr.Image(type="numpy", label="Input Image", height=UNIFIED_HEIGHT) |
| duration_slider_i2v = gr.Slider(1, 30, 5, step=0.1, label="Duration (s)") |
| input_audio_i2v = gr.Audio(label="Add Music (Optional)", type="filepath") |
| compile_i2v_btn = gr.Button("π¬ Create Looping Video", variant="primary") |
| with gr.Column(): |
| output_video_i2v = gr.Video(label="Output Looping Video", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
| compile_i2v_btn.click(image_to_looping_video, [input_image_i2v, duration_slider_i2v, input_audio_i2v], output_video_i2v).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("π Zoom Video"): |
| gr.Markdown("### Create a 'Ken Burns' style zoom/pan video from an image.") |
| gr.Info("Upload one or more images. The output will be a gallery of videos, or a single combined video if you check the box.") |
| with gr.Row(): |
| with gr.Column(): |
| i2zv_input_images = gr.File(label="Upload Images", file_count="multiple", file_types=["image"]) |
| i2zv_duration = gr.Slider(1, 30, 5, step=0.5, label="Video Duration (s) per Image") |
| i2zv_zoom_ratio = gr.Slider(1.0, 2.0, 1.25, step=0.05, label="Zoom Ratio") |
| i2zv_zoom_dir = gr.Dropdown( |
| ["Center", "Top", "Bottom", "Left", "Right", "Top-Left", "Top-Right", "Bottom-Left", "Bottom-Right"], |
| value="Center", label="Zoom Direction" |
| ) |
| i2zv_combine = gr.Checkbox(label="Combine all videos into one", value=False) |
| i2zv_audio = gr.Audio(label="Add Music (Optional)", type="filepath") |
| i2zv_btn = gr.Button("β¨ Create Zoom Video(s)", variant="primary") |
| with gr.Column(): |
| i2zv_output_gallery = gr.Gallery(label="Output Video Previews", columns=2, object_fit="contain", visible=True) |
| i2zv_output_video = gr.Video(label="Combined Output Video", interactive=True, visible=False, show_download_button=True, height=UNIFIED_HEIGHT) |
| i2zv_output_zip = gr.File(label="Download All as .zip", interactive=False) |
| |
| i2zv_combine.change( |
| fn=lambda x: [gr.update(visible=not x), gr.update(visible=x)], |
| inputs=i2zv_combine, |
| outputs=[i2zv_output_gallery, i2zv_output_video] |
| ) |
| i2zv_btn.click( |
| fn=create_zoom_videos, |
| inputs=[i2zv_input_images, i2zv_duration, i2zv_zoom_ratio, i2zv_zoom_dir, i2zv_combine, i2zv_audio], |
| outputs=[i2zv_output_gallery, i2zv_output_video, i2zv_output_zip] |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("βοΈ Batch BG Remover"): |
| gr.Markdown("### Remove the background from a batch of images.") |
| with gr.Row(): |
| with gr.Column(): |
| input_images_bg = gr.File(label="Upload Images", file_count="multiple", file_types=["image"]) |
| remove_bg_btn = gr.Button("π§Ό Remove Backgrounds", variant="primary") |
| with gr.Column(): |
| output_gallery_bg = gr.Gallery(label="Images with Transparent Background", columns=4, object_fit="contain", height="auto") |
| output_zip_bg = gr.File(label="Download All as .zip", interactive=False) |
| remove_bg_btn.click(remove_background_batch, input_images_bg, [output_gallery_bg, output_zip_bg]).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("ποΈ Batch Watermarker"): |
| gr.Markdown("### Apply a text watermark to a batch of images.") |
| with gr.Row(): |
| with gr.Column(): |
| input_images_wm = gr.File(label="Upload Images", file_count="multiple", file_types=["image"]) |
| watermark_text = gr.Textbox(label="Watermark Text", placeholder="(c) My Awesome Project") |
| watermark_pos = gr.Radio(["Top-Left", "Top-Right", "Bottom-Left", "Bottom-Right", "Center"], value="Bottom-Right", label="Position") |
| watermark_opacity = gr.Slider(0, 100, 50, step=1, label="Opacity (%)") |
| watermark_btn = gr.Button("βοΈ Apply Watermarks", variant="primary") |
| with gr.Column(): |
| output_gallery_wm = gr.Gallery(label="Watermarked Images", columns=4, object_fit="contain", height="auto") |
| output_zip_wm = gr.File(label="Download All as .zip", interactive=False) |
| watermark_btn.click(apply_watermark_batch, [input_images_wm, watermark_text, watermark_pos, watermark_opacity], [output_gallery_wm, output_zip_wm]).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("π Batch Resizer & Converter"): |
| gr.Markdown("### Convert, resize, and compress a batch of images.") |
| gr.Info("Choose a preset for quick resizing or select 'Custom' to enter your own dimensions.") |
| with gr.Row(): |
| with gr.Column(): |
| brc_input_images = gr.File(label="Upload Images", file_count="multiple", file_types=["image"]) |
| with gr.Accordion("βοΈ Output Settings", open=True): |
| brc_preset = gr.Dropdown( |
| label="Resolution Presets", |
| choices=[ |
| "1024x1024 (Square)", "768x768 (Square)", "720x720 (Square)", |
| "1280x720 (Landscape 16:9)", "720x1280 (Portrait 9:16)", |
| "768x512 (Landscape 3:2)", "512x768 (Portrait 2:3)", |
| "Custom" |
| ], |
| value="1024x1024 (Square)" |
| ) |
| with gr.Row(visible=False) as brc_custom_size_row: |
| brc_max_w = gr.Number(label="Width", value=1024) |
| brc_max_h = gr.Number(label="Height", value=1024) |
| |
| brc_resize_mode = gr.Radio(["Fit (preserve aspect ratio)", "Stretch to Fit"], value="Fit (preserve aspect ratio)", label="Resize Mode") |
| brc_format = gr.Dropdown(["JPG", "PNG", "WEBP"], value="JPG", label="Output Format") |
| brc_quality = gr.Slider(1, 100, 90, step=1, label="JPG/WEBP Quality", interactive=True) |
| |
| brc_btn = gr.Button("π Process Images", variant="primary") |
| with gr.Column(): |
| brc_output_gallery = gr.Gallery(label="Processed Images Preview", columns=4, object_fit="contain", height="auto") |
| brc_output_zip = gr.File(label="Download All as .zip", interactive=False) |
| |
| def update_resizer_from_preset(preset_str): |
| if preset_str == "Custom": |
| return gr.update(visible=True), gr.update(), gr.update() |
| |
| match = re.search(r'(\d+)\s*x\s*(\d+)', preset_str) |
| if match: |
| w, h = int(match.group(1)), int(match.group(2)) |
| return gr.update(visible=False), w, h |
| |
| |
| return gr.update(visible=False), 1024, 1024 |
|
|
| brc_preset.change( |
| fn=update_resizer_from_preset, |
| inputs=brc_preset, |
| outputs=[brc_custom_size_row, brc_max_w, brc_max_h] |
| ) |
|
|
| |
| demo.load( |
| fn=update_resizer_from_preset, |
| inputs=brc_preset, |
| outputs=[brc_custom_size_row, brc_max_w, brc_max_h] |
| ) |
|
|
| brc_format.change(lambda f: gr.update(visible=f in ["JPG", "WEBP"]), brc_format, brc_quality) |
| |
| brc_btn.click( |
| |
| lambda files, out_f, qual, w, h, mode: batch_resize_convert_images(files, out_f, qual, True, w, h, mode), |
| [brc_input_images, brc_format, brc_quality, brc_max_w, brc_max_h, brc_resize_mode], |
| [brc_output_gallery, brc_output_zip] |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("π₯ Video Utilities", elem_id="video_tab"): |
| gr.Markdown("## A collection of useful video tools.") |
| with gr.Tabs(): |
| with gr.TabItem("ποΈ Frame Tools"): |
| with gr.Tabs(): |
| with gr.TabItem("First & Last"): |
| gr.Markdown("### Extract the very first and very last frames of a video.") |
| with gr.Row(): |
| with gr.Column(): |
| input_video_fl = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| extract_fl_btn = gr.Button("π¬ Extract Frames", variant="primary") |
| with gr.Column(): |
| output_gallery_fl = gr.Gallery(label="Output Frames (First, Last)", columns=2, object_fit="contain", height="auto") |
| extract_fl_btn.click(fn=extract_first_last_frame, inputs=input_video_fl, outputs=output_gallery_fl).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("Batch First & Last"): |
| gr.Markdown("### Extract the first and last frames from multiple videos.") |
| with gr.Row(): |
| with gr.Column(): |
| batch_fl_input_videos = gr.File(label="Upload Videos", file_count="multiple", file_types=["video"]) |
| batch_fl_process_btn = gr.Button("π¬ Extract All Frames", variant="primary") |
| with gr.Column(): |
| batch_fl_output_gallery = gr.Gallery(label="Extracted Frames Preview", columns=6, object_fit="contain", height="auto") |
| batch_fl_output_zip = gr.File(label="Download All Frames (.zip)", interactive=False) |
| |
| batch_fl_process_btn.click( |
| fn=batch_extract_first_last_frames, |
| inputs=batch_fl_input_videos, |
| outputs=[batch_fl_output_gallery, batch_fl_output_zip], |
| show_progress="full" |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("Video to Frames"): |
| gr.Markdown("### Extract all individual frames from a video file.") |
| with gr.Row(): |
| with gr.Column(): |
| input_video_v2f = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| v2f_fps_display = gr.Textbox(label="Detected FPS", interactive=False, value="N/A") |
| with gr.Accordion("βοΈ Advanced Options", open=False): |
| v2f_skip_rate = gr.Slider(1, 30, 1, step=1, label="Extract Every Nth Frame") |
| v2f_rotation = gr.Dropdown(["None", "90 Degrees Clockwise", "90 Degrees Counter-Clockwise", "180 Degrees"], value="None", label="Rotation") |
| v2f_format = gr.Radio(["PNG", "JPG"], value="PNG", label="Output Format") |
| v2f_jpg_quality = gr.Slider(1, 100, 95, step=1, label="JPG Quality", interactive=False) |
| v2f_resize = gr.Checkbox(label="Resize all extracted frames", value=False) |
| with gr.Row(): |
| v2f_width = gr.Number(label="Output Width", value=1024, interactive=False) |
| v2f_height = gr.Number(label="Output Height", value=576, interactive=False) |
| extract_v2f_btn = gr.Button("πͺ Extract All Frames", variant="primary") |
| with gr.Column(): |
| output_gallery_v2f = gr.Gallery(label="Extracted Frames Preview (max 100 shown)", columns=8, object_fit="contain", height="auto") |
| output_zip_v2f = gr.File(label="Download All Frames (.zip)", interactive=False) |
| input_video_v2f.upload(lambda v: f"{get_video_fps(v):.2f} FPS", input_video_v2f, v2f_fps_display) |
| v2f_resize.change(lambda x: [gr.update(interactive=x), gr.update(interactive=x)], v2f_resize, [v2f_width, v2f_height]) |
| v2f_format.change(lambda x: gr.update(interactive=(x=="JPG")), v2f_format, v2f_jpg_quality) |
| extract_v2f_btn.click(video_to_frames_extractor, [input_video_v2f, v2f_skip_rate, v2f_rotation, v2f_resize, v2f_width, v2f_height, v2f_format, v2f_jpg_quality], [output_gallery_v2f, output_zip_v2f]).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("Frames to Video"): |
| gr.Markdown("### Compile a sequence of image frames into a video file.") |
| with gr.Row(): |
| with gr.Column(): |
| input_frames_f2v = gr.File(label="Upload Frames", file_count="multiple", file_types=["image"]) |
| fps_slider_f2v = gr.Slider(1, 60, 24, step=1, label="FPS") |
| with gr.Accordion("βοΈ Advanced Options", open=False): |
| f2v_rotation = gr.Dropdown(["None", "90 Degrees Clockwise", "90 Degrees Counter-Clockwise", "180 Degrees"], value="None", label="Rotation") |
| f2v_resize = gr.Checkbox(label="Resize all frames", value=False) |
| with gr.Row(): |
| f2v_width = gr.Number(label="Output Width", value=1024, interactive=False) |
| f2v_height = gr.Number(label="Output Height", value=576, interactive=False) |
| compile_f2v_btn = gr.Button("π½οΈ Create Video", variant="primary") |
| with gr.Column(): |
| output_video_f2v = gr.Video(label="Compiled Video", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
| f2v_resize.change(lambda x: [gr.update(interactive=x), gr.update(interactive=x)], f2v_resize, [f2v_width, f2v_height]) |
| compile_f2v_btn.click(create_video_from_frames, [input_frames_f2v, fps_slider_f2v, f2v_rotation, f2v_resize, f2v_width, f2v_height], output_video_f2v).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("βοΈ Editing"): |
| with gr.Tabs(): |
| with gr.TabItem("ποΈ Slo-Mo & Enhance"): |
| gr.Markdown("## AI Slow-Motion") |
| gr.Info( |
| "Create super smooth slow-motion videos. The 'AI-Enhanced' method uses frame interpolation for the best quality." |
| ) |
| if not ENHANCE_AI_AVAILABLE: |
| gr.Warning( |
| "AI models not found. The 'AI-Enhanced' option is disabled. Please install 'rife-ncnn-vulkan-python' to enable it." |
| ) |
| with gr.Row(): |
| with gr.Column(): |
| slowmo_input_videos = gr.File(label="Upload Videos", file_count="multiple", file_types=["video"]) |
| |
| with gr.Accordion("βοΈ Settings", open=True): |
| slowmo_factor = gr.Dropdown(["2x", "4x", "8x"], value="2x", label="Slowdown Factor") |
| |
| method_choices = ["Standard (Fast)"] |
| if ENHANCE_AI_AVAILABLE: |
| method_choices.append("AI-Enhanced (High Quality)") |
| |
| slowmo_method = gr.Radio( |
| method_choices, |
| value=method_choices[-1] if ENHANCE_AI_AVAILABLE else method_choices[0], |
| label="Interpolation Method" |
| ) |
| slowmo_btn = gr.Button("π Process Videos", variant="primary") |
| |
| with gr.Column(): |
| slowmo_output_gallery = gr.Gallery(label="Output Video Previews", columns=1, object_fit="contain") |
| slowmo_output_zip = gr.File(label="Download All as .zip", interactive=False) |
|
|
| slowmo_btn.click( |
| fn=batch_slowmo_enhance_videos, |
| inputs=[slowmo_input_videos, slowmo_factor, slowmo_method], |
| outputs=[slowmo_output_gallery, slowmo_output_zip], |
| show_progress="full" |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("πͺ Auto Jump-Cut"): |
| gr.Markdown("### Automatic Silence Remover (Jump-Cutter)") |
| gr.Info("1. Upload a video. A waveform will appear. 2. Adjust the 'Silence Threshold' slider so it's above the thin 'noise floor' line but below your voice peaks. 3. Process the video!") |
| with gr.Row(): |
| with gr.Column(scale=2): |
| jumpcut_input_video = gr.Video(label="Input Video (with audio)", height=UNIFIED_HEIGHT) |
| jc_waveform_preview = gr.Image(label="Audio Waveform Preview", interactive=False) |
| with gr.Accordion("βοΈ Cut Settings", open=True): |
| jumpcut_threshold = gr.Slider(minimum=-60, maximum=-20, value=-30, step=1, label="Silence Threshold (dB)", info="Anything quieter than this is 'silence'. Lower values are more strict.") |
| jumpcut_duration = gr.Slider(minimum=0.1, maximum=2.0, value=0.5, step=0.1, label="Minimum Silence Duration (s)", info="Silences shorter than this will be ignored.") |
| with gr.Accordion("π Output Resolution", open=False): |
| jc_resolution = gr.Radio( |
| ["Keep Original", "1080p (1920x1080)", "Portrait (1080x1920)", "Custom"], |
| value="Keep Original", label="Output Resolution" |
| ) |
| with gr.Row(visible=False) as jc_custom_res_row: |
| jc_custom_w = gr.Number(label="Custom Width", value=1920) |
| jc_custom_h = gr.Number(label="Custom Height", value=1080) |
| jumpcut_btn = gr.Button("πͺ Perform Jump-Cut", variant="primary") |
| with gr.Column(scale=3): |
| jumpcut_output_video = gr.Video(label="Edited Video Output", show_download_button=True, height=UNIFIED_HEIGHT) |
| |
| jumpcut_input_video.upload(fn=generate_waveform_preview, inputs=jumpcut_input_video, outputs=jc_waveform_preview) |
| |
| jc_resolution.change( |
| fn=lambda choice: gr.update(visible=(choice == "Custom")), |
| inputs=jc_resolution, |
| outputs=jc_custom_res_row |
| ) |
| jumpcut_btn.click( |
| fn=auto_jump_cut, |
| inputs=[jumpcut_input_video, jumpcut_threshold, jumpcut_duration, jc_resolution, jc_custom_w, jc_custom_h], |
| outputs=jumpcut_output_video, |
| show_progress="full" |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| |
| with gr.TabItem("βοΈ Video Silence Chopper"): |
| gr.Markdown("### Automatic Video Silence Chopper") |
| gr.Info("Splits a video into multiple clips, removing the silent parts. Ideal for isolating spoken phrases from interviews or lectures.") |
| with gr.Row(): |
| with gr.Column(scale=2): |
| video_chopper_input = gr.Video(label="Input Video (with audio)", height=UNIFIED_HEIGHT) |
| with gr.Accordion("βοΈ Silence Settings", open=True): |
| video_chopper_threshold = gr.Slider(minimum=-60, maximum=-20, value=-30, step=1, label="Silence Threshold (dB)") |
| video_chopper_duration = gr.Slider(minimum=0.1, maximum=2.0, value=0.5, step=0.1, label="Minimum Silence Duration (s)") |
| with gr.Accordion("π Output Resolution", open=False): |
| video_chopper_resolution = gr.Radio(["Keep Original", "1080p (1920x1080)", "Portrait (1080x1920)", "Custom"], value="Keep Original", label="Output Resolution") |
| with gr.Row(visible=False) as vc_custom_res_row: |
| vc_custom_w = gr.Number(label="Custom Width", value=1920) |
| vc_custom_h = gr.Number(label="Custom Height", value=1080) |
| video_chopper_btn = gr.Button("βοΈ Chop Video into Clips", variant="primary") |
| with gr.Column(scale=3): |
| video_chopper_gallery = gr.Gallery(label="Chopped Video Clips (Preview)", columns=2, object_fit="contain", height="auto") |
| video_chopper_zip = gr.File(label="Download All Clips as .zip", interactive=False) |
|
|
| video_chopper_resolution.change( |
| fn=lambda choice: gr.update(visible=(choice == "Custom")), |
| inputs=video_chopper_resolution, |
| outputs=vc_custom_res_row |
| ) |
| video_chopper_btn.click( |
| fn=chop_video_on_silence, |
| inputs=[video_chopper_input, video_chopper_threshold, video_chopper_duration, video_chopper_resolution, vc_custom_w, vc_custom_h], |
| outputs=[video_chopper_gallery, video_chopper_zip], |
| show_progress="full" |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("β¦ Video Grid"): |
| gr.Markdown("### Video Grid Compiler") |
| gr.Info("Combine multiple videos into a single-screen layout. Videos will be scaled to fit within their grid tile.") |
| with gr.Row(): |
| with gr.Column(): |
| grid_input_videos = gr.File(label="Upload Videos", file_count="multiple", file_types=["video"]) |
| grid_layout = gr.Dropdown( |
| ["2x1 (Side-by-Side)", "1x2 (Stacked)", "2x2 (Quad-View)", "4x4 (16-View)", "8x4 (32-View)"], |
| value="2x1 (Side-by-Side)", |
| label="Grid Layout" |
| ) |
| grid_w = gr.Slider(label="Output Width", minimum=256, maximum=4096, value=1920, step=64) |
| grid_h = gr.Slider(label="Output Height", minimum=256, maximum=4096, value=1080, step=64) |
| |
| grid_bg_color = gr.ColorPicker(value="#000000", label="Background Color") |
| |
| with gr.Accordion("Audio Settings", open=True): |
| grid_audio_choice = gr.Dropdown( |
| choices=["From Video 1", "None"], |
| value="From Video 1", |
| label="Video Audio Source", |
| info="Select which video's audio to use, or None." |
| ) |
| grid_music_track = gr.Audio(label="Add External Music (Overrides above)", type="filepath") |
|
|
| grid_btn = gr.Button("β¦ Compile Grid", variant="primary") |
| with gr.Column(): |
| grid_output_video = gr.Video(label="Grid Video Output", show_download_button=True, height=UNIFIED_HEIGHT) |
| |
| grid_input_videos.upload(update_audio_source_choices_for_grid, grid_input_videos, grid_audio_choice) |
| grid_btn.click( |
| fn=compile_video_grid, |
| inputs=[grid_input_videos, grid_layout, grid_w, grid_h, grid_bg_color, grid_audio_choice, grid_music_track], |
| outputs=grid_output_video |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("Manipulate"): |
| gr.Markdown("### Simple Video Manipulation") |
| gr.Info("Apply a single transformation like inverting colors, flipping, or rotating to every frame of a video.") |
| with gr.Row(): |
| with gr.Column(): |
| vmanip_input_video = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| vmanip_operation_radio = gr.Radio( |
| ["Invert Colors", "Flip Horizontal", "Flip Vertical", "Rotate 90Β° Right", "Rotate 90Β° Left"], |
| label="Select Operation", value="Invert Colors" |
| ) |
| vmanip_apply_btn = gr.Button("β¨ Apply Manipulation", variant="primary") |
| with gr.Column(): |
| vmanip_output_video = gr.Video(label="Output Video", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
| |
| vmanip_apply_btn.click(fn=manipulate_video, inputs=[vmanip_input_video, vmanip_operation_radio], outputs=vmanip_output_video).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("Ping-Pong"): |
| gr.Markdown("### Create a forward-then-reverse video loop (Boomerang).") |
| with gr.Row(): |
| with gr.Column(): |
| input_video_pingpong = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| audio_option_pingpong = gr.Radio(["Remove Audio", "Original Audio Only", "Reverse Audio"], value="Remove Audio", label="Audio Handling") |
| pingpong_btn = gr.Button("π Create Ping-Pong Video", variant="primary") |
| with gr.Column(): |
| output_video_pingpong = gr.Video(label="Ping-Pong Video", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
| pingpong_btn.click(fn=ping_pong_video, inputs=[input_video_pingpong, audio_option_pingpong], outputs=output_video_pingpong).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("Reverse"): |
| gr.Markdown("### Reverse a video clip.") |
| with gr.Row(): |
| with gr.Column(): |
| input_video_reverse = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| audio_option_reverse = gr.Radio(["Remove Audio", "Reverse Audio"], value="Remove Audio", label="Audio Handling") |
| reverse_btn = gr.Button("π Reverse Video", variant="primary") |
| with gr.Column(): |
| output_video_reverse = gr.Video(label="Reversed Video", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
| reverse_btn.click(fn=reverse_video, inputs=[input_video_reverse, audio_option_reverse], outputs=output_video_reverse).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("Merger"): |
| gr.Markdown("### Simple Video Merger") |
| gr.Info("Upload two or more video clips to join them together in sequence. All clips will be conformed to the resolution and framerate of the first video.") |
| with gr.Row(): |
| with gr.Column(): |
| merger_input_videos = gr.File(label="Upload Videos (2 or more)", file_count="multiple", file_types=["video"]) |
| merger_btn = gr.Button("π Merge Videos", variant="primary") |
| with gr.Column(): |
| merger_output_video = gr.Video(label="Merged Video", show_download_button=True, height=UNIFIED_HEIGHT) |
| merger_btn.click( |
| fn=merge_videos, |
| inputs=merger_input_videos, |
| outputs=merger_output_video |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("Stabilizer"): |
| gr.Markdown("### Smooth Out Shaky Video Footage") |
| gr.Info("This tool uses a two-pass process to analyze and stabilize shaky videos. Higher 'Shakiness' values are for very unstable footage. Higher 'Smoothing' creates a more fluid, gliding look but can introduce slight cropping/warping.") |
| with gr.Row(): |
| with gr.Column(): |
| stab_input_video = gr.Video(label="Input Shaky Video", height=UNIFIED_HEIGHT) |
| stab_shakiness = gr.Slider(minimum=1, maximum=10, value=5, step=1, label="Shakiness Level", info="How shaky is the source video? (1=low, 10=high)") |
| stab_smoothing = gr.Slider(minimum=1, maximum=50, value=10, step=1, label="Smoothing Amount", info="How much smoothing to apply. (More = smoother but might feel 'floaty')") |
| stab_btn = gr.Button("β¨ Stabilize Video", variant="primary") |
| with gr.Column(): |
| stab_output_video = gr.Video(label="Stabilized Output Video", show_download_button=True, height=UNIFIED_HEIGHT) |
| stab_btn.click( |
| fn=stabilize_video, |
| inputs=[stab_input_video, stab_shakiness, stab_smoothing], |
| outputs=stab_output_video |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("Trimmer"): |
| gr.Markdown("### Visually trim a video. Use the player to find a frame, then set it as the start or end point.") |
| gr.Info("Keyboard hotkeys enabled: K = Play/Pause, J = Back 1 Frame, L = Forward 1 Frame (hover mouse over video)") |
| with gr.Row(): |
| with gr.Column(scale=2): |
| input_video_trim = gr.Video(label="Input Video", elem_id="video-trim-input", height=UNIFIED_HEIGHT) |
| with gr.Row(): |
| set_start_btn = gr.Button("Set Current Frame as START") |
| set_end_btn = gr.Button("Set Current Frame as END") |
| trim_btn = gr.Button("βοΈ Trim Video", variant="primary") |
| with gr.Column(scale=1): |
| gr.Markdown("#### Trim Points") |
| start_frame_img = gr.Image(label="Start Frame", interactive=False) |
| trim_start_time_display = gr.Textbox(label="Start Time (s)", interactive=False) |
| end_frame_img = gr.Image(label="End Frame", interactive=False) |
| trim_end_time_display = gr.Textbox(label="End Time (s)", interactive=False) |
| trim_start_time = gr.Number(value=0, visible=False) |
| trim_end_time = gr.Number(value=0, visible=False) |
| with gr.Row(): |
| output_video_trim = gr.Video(label="Trimmed Video", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
| get_current_time_js = """()=>{const e=document.querySelector("#video-trim-input video");return e?e.currentTime:0}""" |
| def get_frame_from_time_wrapper(v,t): return get_frame_at_time(v,t), f"{t:.3f}" |
| set_start_btn.click(fn=None, js=get_current_time_js, outputs=[trim_start_time]) |
| set_end_btn.click(fn=None, js=get_current_time_js, outputs=[trim_end_time]) |
| trim_start_time.change(fn=get_frame_from_time_wrapper, inputs=[input_video_trim, trim_start_time], outputs=[start_frame_img, trim_start_time_display]) |
| trim_end_time.change(fn=get_frame_from_time_wrapper, inputs=[input_video_trim, trim_end_time], outputs=[end_frame_img, trim_end_time_display]) |
| trim_btn.click(fn=trim_video, inputs=[input_video_trim, trim_start_time, trim_end_time], outputs=output_video_trim).then(fn=None, js=fire_confetti_and_sound_js) |
| input_video_trim.clear(fn=lambda: (None, "0.00", None, "0.00", 0, 0), outputs=[start_frame_img, trim_start_time_display, end_frame_img, trim_end_time_display, trim_start_time, trim_end_time]) |
| |
| with gr.TabItem("Crop & Resize"): |
| gr.Markdown("### Visually crop a video.") |
| gr.Info("Upload a video to see a preview frame. Adjust the sliders to define the crop area, then process.") |
| video_crop_original_preview_state = gr.State() |
| with gr.Row(): |
| with gr.Column(scale=1): |
| video_crop_input_video = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| gr.Markdown("#### Crop Box Settings") |
| video_crop_w = gr.Slider(label="Width", minimum=64, maximum=4096, step=8, value=1280) |
| video_crop_h = gr.Slider(label="Height", minimum=64, maximum=4096, step=8, value=720) |
| video_crop_x = gr.Slider(label="X Offset", minimum=0, maximum=4096, step=8, value=0) |
| video_crop_y = gr.Slider(label="Y Offset", minimum=0, maximum=4096, step=8, value=0) |
| |
| with gr.Accordion("Optional: Resize after cropping", open=False): |
| video_crop_do_resize = gr.Checkbox(label="Enable Resizing", value=False) |
| video_crop_resize_w = gr.Number(label="Output Width", value=1024, interactive=False) |
| video_crop_resize_h = gr.Number(label="Output Height", value=576, interactive=False) |
| |
| video_crop_btn = gr.Button("π Crop Video", variant="primary") |
|
|
| with gr.Column(scale=2): |
| video_crop_preview_image = gr.Image(label="Crop Preview", type="pil", interactive=False, height=UNIFIED_HEIGHT) |
| video_crop_output_video = gr.Video(label="Cropped Video", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
|
|
| def setup_video_crop_preview(video_path): |
| if not video_path: |
| return None, None, gr.update(), gr.update(), gr.update(), gr.update() |
| try: |
| img = get_frame_at_time(video_path, 0) |
| w, h = img.size |
| return img, img, gr.update(maximum=w, value=w), gr.update(maximum=h, value=h), gr.update(maximum=w), gr.update(maximum=h) |
| except Exception as e: |
| gr.Warning(f"Could not load preview frame: {e}") |
| return None, None, gr.update(), gr.update(), gr.update(), gr.update() |
| |
| video_crop_input_video.upload( |
| fn=setup_video_crop_preview, |
| inputs=video_crop_input_video, |
| outputs=[video_crop_preview_image, video_crop_original_preview_state, video_crop_w, video_crop_h, video_crop_x, video_crop_y] |
| ) |
|
|
| video_crop_sliders = [video_crop_x, video_crop_y, video_crop_w, video_crop_h] |
| for slider in video_crop_sliders: |
| slider.release( |
| fn=update_crop_preview, |
| inputs=[video_crop_original_preview_state] + video_crop_sliders, |
| outputs=video_crop_preview_image |
| ) |
| |
| video_crop_do_resize.change(lambda x: [gr.update(interactive=x), gr.update(interactive=x)], inputs=video_crop_do_resize, outputs=[video_crop_resize_w, video_crop_resize_h]) |
| |
| video_crop_btn.click( |
| fn=crop_video, |
| inputs=[video_crop_input_video, video_crop_x, video_crop_y, video_crop_w, video_crop_h, video_crop_do_resize, video_crop_resize_w, video_crop_resize_h], |
| outputs=video_crop_output_video |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("Change Speed"): |
| gr.Markdown("### Create slow-motion or fast-forward videos.") |
| with gr.Row(): |
| with gr.Column(): |
| input_video_speed = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| speed_multiplier = gr.Slider(0.1, 10.0, 1.0, step=0.1, label="Speed Multiplier") |
| speed_btn = gr.Button("π Change Speed", variant="primary") |
| with gr.Column(): |
| output_video_speed = gr.Video(label="Modified Video", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
| speed_btn.click(fn=change_video_speed, inputs=[input_video_speed, speed_multiplier], outputs=output_video_speed).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("π¨ Effects & Overlays"): |
| with gr.Tabs(): |
| with gr.TabItem("Fader"): |
| gr.Markdown("### Apply Fade-In and/or Fade-Out to a Video") |
| with gr.Row(): |
| with gr.Column(): |
| fade_input_video = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| with gr.Row(): |
| fade_in_slider = gr.Slider(0.0, 10.0, 1.0, step=0.1, label="Fade-In Duration (s)") |
| fade_out_slider = gr.Slider(0.0, 10.0, 1.0, step=0.1, label="Fade-Out Duration (s)") |
| fade_video_btn = gr.Button("β¨ Apply Fade", variant="primary") |
| with gr.Column(): |
| fade_output_video = gr.Video(label="Faded Video", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
| fade_video_btn.click(apply_video_fade, [fade_input_video, fade_in_slider, fade_out_slider], fade_output_video).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("π¨ Color Grading"): |
| gr.Markdown("### Adjust Video Color, Contrast, and Sharpness") |
| gr.Info("Upload a video, load a preview frame, adjust the sliders for a live preview, and then apply the changes to the full video. The preview is 100% accurate to the final render.") |
| with gr.Row(equal_height=False): |
| with gr.Column(scale=1): |
| color_grade_input_video = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| load_preview_btn = gr.Button("πΌοΈ Load/Reset Preview Frame") |
| |
| gr.Markdown("#### Adjustments") |
| cg_brightness = gr.Slider(minimum=-0.5, maximum=0.5, value=0.0, step=0.05, label="Brightness") |
| cg_contrast = gr.Slider(minimum=0.5, maximum=2.0, value=1.0, step=0.05, label="Contrast") |
| cg_saturation = gr.Slider(minimum=0.0, maximum=3.0, value=1.0, step=0.1, label="Saturation") |
| cg_sharpness = gr.Slider(minimum=0.0, maximum=1.5, value=0.0, step=0.1, label="Sharpness") |
| |
| apply_grading_btn = gr.Button("π¬ Apply Grading to Full Video", variant="primary") |
|
|
| with gr.Column(scale=2): |
| with gr.Row(): |
| cg_before_preview = gr.Image(label="Before", type="numpy", interactive=False) |
| cg_after_preview = gr.Image(label="After (Accurate Preview)", type="pil", interactive=False) |
| color_grade_output_video = gr.Video(label="Graded Video Output", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
| |
| grading_inputs = [cg_before_preview, cg_brightness, cg_contrast, cg_saturation, cg_sharpness] |
| |
| cg_brightness.release(preview_color_grading_ffmpeg, inputs=grading_inputs, outputs=cg_after_preview) |
| cg_contrast.release(preview_color_grading_ffmpeg, inputs=grading_inputs, outputs=cg_after_preview) |
| cg_saturation.release(preview_color_grading_ffmpeg, inputs=grading_inputs, outputs=cg_after_preview) |
| cg_sharpness.release(preview_color_grading_ffmpeg, inputs=grading_inputs, outputs=cg_after_preview) |
|
|
| load_preview_btn.click( |
| fn=get_frame_at_time, |
| inputs=color_grade_input_video, |
| outputs=cg_before_preview |
| ).then( |
| fn=preview_color_grading_ffmpeg, |
| inputs=grading_inputs, |
| outputs=cg_after_preview |
| ) |
| color_grade_input_video.upload( |
| fn=get_frame_at_time, |
| inputs=color_grade_input_video, |
| outputs=cg_before_preview |
| ).then( |
| fn=preview_color_grading_ffmpeg, |
| inputs=grading_inputs, |
| outputs=cg_after_preview |
| ) |
|
|
| apply_grading_btn.click( |
| fn=apply_color_grading, |
| inputs=[color_grade_input_video, cg_brightness, cg_contrast, cg_saturation, cg_sharpness], |
| outputs=color_grade_output_video |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("BG Remover"): |
| gr.Markdown("## Video Background Remover") |
| gr.Warning("This is a very slow process. A short video can take several minutes. Output is a .webm file.") |
| with gr.Row(): |
| with gr.Column(): |
| vbg_input_video = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| vbg_btn = gr.Button("βοΈ Remove Video Background", variant="primary") |
| with gr.Column(): |
| vbg_output_video = gr.Video(label="Output Video with Transparency (.webm)", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
| vbg_btn.click(remove_video_background, vbg_input_video, vbg_output_video).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("Watermark"): |
| gr.Markdown("### Apply a text watermark to a video.") |
| with gr.Row(): |
| with gr.Column(): |
| wm_input_video = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| wm_text = gr.Textbox(label="Watermark Text", placeholder="(c) My Video 2025") |
| wm_pos = gr.Radio(["Top-Left", "Top-Right", "Bottom-Left", "Bottom-Right", "Center"], value="Bottom-Right", label="Position") |
| wm_opacity = gr.Slider(0, 100, 70, step=1, label="Opacity (%)") |
| with gr.Accordion("Advanced Options", open=False): |
| wm_size = gr.Slider(1, 10, 5, step=1, label="Relative Font Size") |
| wm_color = gr.ColorPicker(value="#FFFFFF", label="Font Color") |
| wm_btn = gr.Button("ποΈ Apply Watermark", variant="primary") |
| with gr.Column(): |
| wm_output_video = gr.Video(label="Watermarked Video", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
| wm_btn.click(apply_video_watermark, [wm_input_video, wm_text, wm_pos, wm_opacity, wm_size, wm_color], wm_output_video).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("Create GIF"): |
| gr.Markdown("### Convert a video clip into a high-quality animated GIF.") |
| with gr.Row(): |
| with gr.Column(): |
| input_video_gif = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| with gr.Row(): |
| gif_start_time = gr.Number(value=0, label="Start Time (s)") |
| gif_end_time = gr.Number(value=0, label="End Time (s)", info="Set to 0 for full duration") |
| gif_btn = gr.Button("πΌοΈ Create GIF", variant="primary") |
| with gr.Column(): |
| output_gif = gr.Image(label="Output GIF", show_download_button=True, height=UNIFIED_HEIGHT) |
| gif_btn.click(create_gif_from_video, [input_video_gif, gif_start_time, gif_end_time], output_gif).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("π Audio & Transcription"): |
| with gr.Tabs(): |
| with gr.TabItem("Add Audio"): |
| gr.Markdown("### Combine a silent video with an audio file.") |
| with gr.Row(): |
| with gr.Column(): |
| input_video_audio = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| input_audio = gr.Audio(type="filepath", label="Input Audio") |
| add_audio_btn = gr.Button("πΆ Add Audio", variant="primary") |
| with gr.Column(): |
| output_video_audio = gr.Video(label="Final Video with Audio", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
| add_audio_btn.click(add_audio_to_video, [input_video_audio, input_audio], output_video_audio).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("Extract Audio"): |
| gr.Markdown("### Strip the audio track from a video file.") |
| with gr.Row(): |
| with gr.Column(): |
| extract_audio_input_video = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| extract_audio_format = gr.Dropdown(["mp3", "wav", "aac"], value="mp3", label="Output Audio Format") |
| extract_audio_btn = gr.Button("π΅ Extract Audio", variant="primary") |
| with gr.Column(): |
| extract_audio_output = gr.Audio(label="Extracted Audio", type="filepath") |
| extract_audio_btn.click(extract_audio, [extract_audio_input_video, extract_audio_format], extract_audio_output).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("Silence Chopper"): |
| gr.Markdown("### Automatic Silence Chopper") |
| gr.Info("Automatically split an audio file into multiple smaller files, removing the silent parts.") |
| with gr.Row(): |
| with gr.Column(): |
| chopper_input_audio = gr.Audio(type="filepath", label="Input Audio File") |
| with gr.Accordion("βοΈ Silence Settings", open=True): |
| chopper_thresh = gr.Slider(minimum=-70, maximum=-20, value=-40, step=1, label="Silence Threshold (dBFS)", info="Anything quieter than this is considered silence. Lower numbers are stricter.") |
| chopper_min_len = gr.Slider(minimum=100, maximum=2000, value=500, step=50, label="Minimum Silence Length (ms)", info="Silences shorter than this will be ignored.") |
| chopper_btn = gr.Button("βοΈ Chop Audio", variant="primary") |
| with gr.Column(): |
| chopper_output_gallery = gr.Gallery(label="Chopped Audio Files (Preview)", columns=2, object_fit="contain", height="auto", allow_preview=False) |
| chopper_output_zip = gr.File(label="Download All Chunks as .zip", interactive=False) |
| |
| chopper_btn.click( |
| fn=chop_audio_on_silence, |
| inputs=[chopper_input_audio, chopper_thresh, chopper_min_len], |
| outputs=[chopper_output_gallery, chopper_output_zip], |
| show_progress="full" |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("Audio Editor"): |
| gr.Markdown("### Trim and Apply Fades to an Audio File") |
| gr.Info("Set start/end times to trim the clip, then apply optional fades.") |
| with gr.Row(): |
| with gr.Column(): |
| audio_trim_input = gr.Audio(type="filepath", label="Input Audio") |
| with gr.Row(): |
| audio_start_time = gr.Number(label="Start Time (s)", value=0) |
| audio_end_time = gr.Number(label="End Time (s)", info="Set to 0 for full duration") |
| with gr.Row(): |
| audio_fade_in = gr.Slider(0.0, 10.0, 0.5, step=0.1, label="Fade-In Duration (s)") |
| audio_fade_out = gr.Slider(0.0, 10.0, 1.0, step=0.1, label="Fade-Out Duration (s)") |
| audio_trim_fade_btn = gr.Button("βοΈ Process Audio", variant="primary") |
| with gr.Column(): |
| audio_trim_output = gr.Audio(label="Processed Audio", type="filepath") |
| audio_trim_fade_btn.click(trim_and_fade_audio, [audio_trim_input, audio_start_time, audio_end_time, audio_fade_in, audio_fade_out], audio_trim_output).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("π₯ BPM & Speed Tool"): |
| gr.Markdown("### Analyze Audio BPM and Adjust Speed") |
| gr.Info("Upload an audio file to find its BPM. Then, use the slider to create a speed-adjusted version.") |
| with gr.Row(): |
| with gr.Column(scale=2): |
| bpm_input_audio = gr.Audio(type="filepath", label="Input Audio") |
| with gr.Row(): |
| bpm_detect_btn = gr.Button("π₯ Detect BPM") |
| bpm_display_box = gr.Textbox(label="Detected BPM", interactive=False) |
| |
| gr.Markdown("#### Speed Adjustment") |
| with gr.Row(): |
| bpm_speed_slider = gr.Slider(minimum=0.5, maximum=2.0, value=1.0, step=0.05, label="Speed Multiplier") |
| bpm_new_display = gr.Textbox(label="New BPM Estimate", interactive=False, value="---") |
| |
| bpm_apply_speed_btn = gr.Button("π Apply Speed Change", variant="primary") |
| |
| with gr.Column(scale=3): |
| bpm_output_audio = gr.Audio(label="Processed Audio Output", type="filepath") |
| |
| bpm_input_audio.upload(lambda: ("", "---"), outputs=[bpm_display_box, bpm_new_display]) |
|
|
| bpm_detect_btn.click( |
| fn=detect_bpm, |
| inputs=bpm_input_audio, |
| outputs=bpm_display_box |
| ).then( |
| fn=update_new_bpm_display, |
| inputs=[bpm_display_box, bpm_speed_slider], |
| outputs=bpm_new_display |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| bpm_speed_slider.release( |
| fn=update_new_bpm_display, |
| inputs=[bpm_display_box, bpm_speed_slider], |
| outputs=bpm_new_display |
| ) |
|
|
| bpm_apply_speed_btn.click( |
| fn=change_audio_speed, |
| inputs=[bpm_input_audio, bpm_speed_slider], |
| outputs=bpm_output_audio |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("Transcription", visible=(whisper is not None)): |
| gr.Markdown("## Transcribe Speech and Burn Subtitles") |
| gr.Info("Uses OpenAI's Whisper model with word-level timestamps. First run will download model files.") |
| transcribed_video_path_state = gr.State(None) |
| with gr.Row(): |
| with gr.Column(): |
| transcribe_input = gr.File(label="Upload Video or Audio File", file_types=["video", "audio"]) |
| transcribe_model = gr.Dropdown(["tiny", "base", "small", "medium", "large"], value="base", label="Whisper Model Size") |
| transcribe_btn = gr.Button("ποΈ Transcribe", variant="primary") |
| with gr.Column(): |
| with gr.Row(): |
| transcribe_text = gr.Textbox(label="Transcription Result", lines=10, interactive=True, elem_id="transcription_textbox") |
| copy_transcription_btn = gr.Button("π Copy") |
| transcribe_files = gr.File(label="Download Subtitle Files (.srt, .vtt, .ass)", file_count="multiple", interactive=False) |
| |
| with gr.Accordion("π₯ Burn Subtitles onto Video", open=True, visible=False) as burn_accordion: |
| gr.Markdown("Set styling and burn the generated subtitles into the video.") |
| with gr.Row(): |
| burn_style = gr.Radio(["Block", "Karaoke"], value="Block", label="Subtitle Style") |
| burn_font_size = gr.Slider(1, 10, 5, step=1, label="Relative Font Size") |
| with gr.Row(visible=True) as block_style_row: |
| burn_words_per_line = gr.Slider(1, 20, 7, step=1, label="Max Words Per Line", info="Splits long subtitle lines for better readability.") |
| burn_block_font_color = gr.ColorPicker(value="#FFFFFF", label="Font Color") |
| with gr.Row(visible=False) as karaoke_style_row: |
| burn_karaoke_base_color = gr.ColorPicker(value="#FFFFFF", label="Base Color") |
| burn_karaoke_highlight_color = gr.ColorPicker(value="#FFFF00", label="Highlight Color") |
|
|
| burn_btn = gr.Button("π₯ Burn Subtitles", variant="primary") |
| burn_output_video = gr.Video(label="Video with Burned-in Subtitles", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
|
|
| def toggle_subtitle_styles(style_choice): |
| return gr.update(visible=(style_choice == "Block")), gr.update(visible=(style_choice == "Karaoke")) |
| |
| burn_style.change(toggle_subtitle_styles, burn_style, [block_style_row, karaoke_style_row]) |
| copy_transcription_btn.click(fn=None, js=copy_transcription_js) |
| transcribe_btn.click( |
| fn=transcribe_and_prep_burn, |
| inputs=[transcribe_input, transcribe_model], |
| outputs=[transcribe_text, transcribe_files, transcribed_video_path_state, burn_accordion] |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
| burn_btn.click( |
| fn=burn_subtitles_wrapper, |
| inputs=[transcribed_video_path_state, transcribe_files, burn_style, burn_font_size, burn_block_font_color, burn_words_per_line, burn_karaoke_base_color, burn_karaoke_highlight_color], |
| outputs=burn_output_video |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
|
|
| with gr.TabItem("π§ ControlNet", elem_id="controlnet_tab"): |
| gr.Markdown("## ControlNet Preprocessing") |
| with gr.Tabs(): |
| with gr.TabItem("Process a Video"): |
| gr.Markdown("### Convert a Video into a ControlNet-Ready Map") |
| with gr.Row(): |
| with gr.Column(): |
| input_video_cn = gr.Video(label="Input Video", height=UNIFIED_HEIGHT) |
| detector_dropdown_cn = gr.Dropdown(choices=list(DETECTOR_CONFIG.keys()), value="Canny", label="Choose Detector") |
| process_btn_cn = gr.Button("β¨ Process Video", variant="primary") |
| with gr.Column(): |
| output_video_cn = gr.Video(label="Output ControlNet Video", interactive=True, show_download_button=True, height=UNIFIED_HEIGHT) |
| process_btn_cn.click(fn=process_video_with_detector, inputs=[input_video_cn, detector_dropdown_cn], outputs=output_video_cn).then(fn=None, js=fire_confetti_and_sound_js) |
| with gr.TabItem("Process Batch Images"): |
| gr.Markdown("### Generate ControlNet Maps from one or more images.") |
| with gr.Row(): |
| with gr.Column(): |
| input_images_cn = gr.File(label="Upload Images or Folder", file_count="multiple", file_types=["image"]) |
| detector_dropdown_img = gr.Dropdown(choices=list(DETECTOR_CONFIG.keys()), value="Canny", label="Choose Detector") |
| process_btn_img = gr.Button("β¨ Process Images", variant="primary") |
| with gr.Column(): |
| output_gallery_cn = gr.Gallery(label="Output ControlNet Images", columns=4, object_fit="contain", height="auto") |
| output_zip_cn = gr.File(label="Download All as .zip", interactive=False) |
| process_btn_img.click(fn=process_batch_images_with_detector, inputs=[input_images_cn, detector_dropdown_img], outputs=[output_gallery_cn, output_zip_cn]).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("ποΈ Converter", elem_id="converter_tab"): |
| gr.Markdown("## Universal Media Converter & Compressor") |
| gr.Info("Convert your video or audio files to different formats, change codecs, and adjust quality to save space.") |
| |
| with gr.Tabs(): |
| with gr.TabItem("Batch Video Converter"): |
| with gr.Row(): |
| with gr.Column(): |
| conv_input_videos = gr.File(label="Upload Videos", file_count="multiple", file_types=["video"]) |
| conv_btn = gr.Button("βοΈ Convert & Compress Videos", variant="primary") |
| conv_output_gallery = gr.Gallery(label="Converted Videos Preview", columns=2, object_fit="contain") |
| conv_output_zip = gr.File(label="Download All as .zip", interactive=False) |
| with gr.Column(): |
| gr.Markdown("#### Output Settings") |
| with gr.Row(): |
| conv_format = gr.Dropdown(["mp4", "mkv", "webm", "mov"], value="mp4", label="Output Format") |
| conv_vcodec = gr.Dropdown(["libx264", "libx265", "vp9"], value="libx264", label="Video Codec") |
| conv_crf = gr.Slider(minimum=18, maximum=30, value=23, step=1, label="Quality (CRF)", info="Lower = higher quality/size, Higher = lower quality/size. 23 is a good default.") |
| conv_scale = gr.Dropdown(["Original", "1080p", "720p", "480p"], value="Original", label="Downscale Resolution (optional)") |
| gr.Markdown("##### Audio Settings") |
| with gr.Row(): |
| conv_acodec = gr.Dropdown(["copy", "aac", "opus"], value="copy", label="Audio Codec", info="'copy' is fastest and preserves quality.") |
| conv_abitrate = gr.Dropdown([96, 128, 192, 256, 320], value=192, label="Audio Bitrate (kbps)", interactive=False) |
| |
| conv_acodec.change(lambda x: gr.update(interactive=(x != "copy")), conv_acodec, conv_abitrate) |
| conv_btn.click( |
| fn=batch_convert_compress_videos, |
| inputs=[conv_input_videos, conv_format, conv_vcodec, conv_crf, conv_scale, conv_acodec, conv_abitrate], |
| outputs=[conv_output_gallery, conv_output_zip], |
| show_progress="full" |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("Batch Audio Converter / Extractor"): |
| gr.Markdown("### Batch Audio Converter & Extractor") |
| gr.Info("Upload multiple audio OR video files. The audio will be extracted from videos and all files will be converted to your chosen format.") |
| with gr.Row(): |
| with gr.Column(scale=2): |
| audio_conv_inputs = gr.File(label="Upload Audio or Video Files", file_count="multiple", file_types=["audio", "video"]) |
| with gr.Row(): |
| audio_conv_format = gr.Dropdown(["mp3", "wav", "aac", "flac", "ogg"], value="mp3", label="Output Format") |
| audio_conv_bitrate = gr.Dropdown([96, 128, 192, 256, 320], value=192, label="Bitrate (kbps)", info="Higher = better quality. Not used for WAV/FLAC.") |
| audio_conv_btn = gr.Button("π΅ Convert All Files", variant="primary") |
| with gr.Column(scale=3): |
| audio_conv_output_player = gr.Audio(label="Preview First Converted File", type="filepath") |
| audio_conv_output_zip = gr.File(label="Download All as .zip", interactive=False) |
| |
| audio_conv_format.change( |
| fn=lambda fmt: gr.update(interactive=(fmt not in ["wav", "flac"])), |
| inputs=audio_conv_format, |
| outputs=audio_conv_bitrate |
| ) |
| audio_conv_btn.click( |
| fn=batch_convert_audio, |
| inputs=[audio_conv_inputs, audio_conv_format, audio_conv_bitrate], |
| outputs=[audio_conv_output_player, audio_conv_output_zip], |
| show_progress="full" |
| ).then(fn=None, js=fire_confetti_and_sound_js) |
| |
| with gr.TabItem("π Transfer", elem_id="transfer_tab"): |
| gr.Markdown("## Image & Link Transfer Utility") |
| gr.Info("Drop images below, manage URL presets, and open the target application in a new tab.") |
| |
| link_presets = gr.State(DEFAULT_LINK_PRESETS.copy()) |
| |
| with gr.Row(): |
| with gr.Column(scale=1): |
| transfer_gallery = gr.Gallery(label="Drop Images Here", height=300, columns=3, object_fit="contain") |
| with gr.Column(scale=2): |
| gr.Markdown("### Link Preset Management") |
| target_url = gr.Textbox(label="Target URL", value="https://huggingface.co/spaces/bep40/FramePack_rotate_landscape", interactive=True, elem_id="transfer_target_url") |
| search_bar = gr.Textbox(label="Search Presets", placeholder="Type to filter...", interactive=True) |
| with gr.Row(): |
| preset_dropdown = gr.Dropdown( |
| label="Load Link Preset", |
| choices=sorted(list(DEFAULT_LINK_PRESETS.keys())), |
| interactive=True |
| ) |
| delete_preset_btn = gr.Button("ποΈ Delete", variant="stop") |
| with gr.Accordion("Create a new preset", open=False): |
| with gr.Row(): |
| new_preset_name = gr.Textbox(label="New Preset Name", placeholder="e.g., My Favorite App") |
| save_preset_btn = gr.Button("πΎ Save") |
| open_link_btn = gr.Button("π Open in New Tab", variant="primary") |
| |
| search_bar.input(fn=filter_presets, inputs=[search_bar, link_presets], outputs=[preset_dropdown]) |
| preset_dropdown.change(fn=load_preset, inputs=[link_presets, preset_dropdown], outputs=[target_url]) |
| save_preset_btn.click( |
| fn=save_preset, inputs=[link_presets, new_preset_name, target_url], outputs=[link_presets, preset_dropdown] |
| ).then(lambda: ("", ""), outputs=[new_preset_name, search_bar]) |
| delete_confirm_js = """(name) => { if (!name) { alert('Please select a preset to delete.'); return false; } return confirm(`Are you sure you want to delete the preset: '` + name + `'?`); }""" |
| delete_preset_btn.click(fn=None, js=delete_confirm_js, inputs=[preset_dropdown]).then( |
| fn=delete_preset, inputs=[link_presets, preset_dropdown], outputs=[link_presets, preset_dropdown, target_url] |
| ).then(lambda: "", outputs=[search_bar]) |
| open_link_btn.click(fn=None, js="()=>{const url=document.getElementById('transfer_target_url').querySelector('textarea').value;if(url){window.open(url,'_blank')}else{alert('Target URL is empty.')}}") |
|
|
| main_tabs.select(fn=None, inputs=main_tabs, js="(tab) => { window.skriptz_bling.update_title(tab); }") |
|
|
| gr.HTML('<a href="https://linktr.ee/skylinkd" target="_blank" style="color: #94a3b8; text-decoration: none;">skylinkd production 2025 (c)</a>', elem_id="custom-footer") |
|
|
| if __name__ == "__main__": |
| if os.path.exists(TEMP_DIR): |
| try: shutil.rmtree(TEMP_DIR) |
| except OSError as e: print(f"Error removing temp directory {TEMP_DIR}: {e}") |
| os.makedirs(TEMP_DIR, exist_ok=True) |
| if whisper: |
| load_whisper_model("base") |
| demo.launch(inbrowser=True) |