| import gradio as gr |
| import os |
| import subprocess |
| import uuid |
| import zipfile |
| from datetime import datetime |
| import pandas as pd |
|
|
| |
| OUTPUT_DIR = "outputs" |
| os.makedirs(OUTPUT_DIR, exist_ok=True) |
|
|
| |
| |
| |
|
|
| def map_level_to_crf(level: str, hevc: bool) -> int: |
| """ |
| Map user-friendly compression levels to CRF values. |
| HEVC often achieves similar quality at slightly higher CRF than H.264, |
| but we keep scales aligned for simplicity. |
| """ |
| level = (level or "Medium").strip().lower() |
| if level == "low": |
| return 22 if not hevc else 24 |
| elif level == "high": |
| return 32 if not hevc else 34 |
| else: |
| return 28 if not hevc else 30 |
|
|
| def ffmpeg_available() -> bool: |
| try: |
| subprocess.run(["ffmpeg", "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) |
| return True |
| except Exception: |
| return False |
|
|
| def resolution_to_height(res_choice: str) -> int | None: |
| """ |
| Map UI selection to target height for downscaling. |
| Using '-vf scale=-2:HEIGHT' ensures even width while preserving aspect. |
| """ |
| if not res_choice or "No downscaling" in res_choice: |
| return None |
| table = { |
| "720p (1280x720)": 720, |
| "1080p (1920x1080)": 1080, |
| "1440p (2560x1440)": 1440, |
| "4K (3840x2160)": 2160, |
| } |
| return table.get(res_choice) |
|
|
| |
| |
| |
|
|
| def compress_single_video( |
| infile_path: str, |
| use_hevc: bool, |
| crf: int, |
| preset: str = "fast", |
| audio_bitrate_kbps: int = 128, |
| target_height: int | None = None, |
| progress: gr.Progress | None = None, |
| fallback_to_h264_on_error: bool = True |
| ): |
| """ |
| Compress a single MP4 using FFmpeg. |
| - Video codec: H.264 (libx264) or H.265 (libx265) |
| - Optional downscale using '-vf scale=-2:HEIGHT' |
| Returns: (output_path, original_size_bytes, compressed_size_bytes, codec_used) |
| """ |
| base = os.path.basename(infile_path) |
| out_name = f"{os.path.splitext(base)[0]}_compressed_{uuid.uuid4().hex[:8]}.mp4" |
| out_path = os.path.join(OUTPUT_DIR, out_name) |
|
|
| original_size = os.path.getsize(infile_path) |
|
|
| |
| vf_args = [] |
| if target_height: |
| |
| vf_args = ["-vf", f"scale=-2:{target_height}"] |
|
|
| vcodec = "libx265" if use_hevc else "libx264" |
| codec_used = "H.265 (libx265)" if use_hevc else "H.264 (libx264)" |
|
|
| |
| cmd = [ |
| "ffmpeg", "-y", |
| "-i", infile_path, |
| *(vf_args), |
| "-vcodec", vcodec, |
| "-crf", str(crf), |
| "-preset", preset, |
| |
| "-pix_fmt", "yuv420p", |
| |
| "-acodec", "aac", "-b:a", f"{audio_bitrate_kbps}k", |
| |
| "-movflags", "+faststart", |
| ] |
|
|
| |
| if use_hevc: |
| cmd += ["-tag:v", "hvc1"] |
|
|
| cmd += [out_path] |
|
|
| if progress: |
| progress(0.2, desc=f"Compressing {base} with {codec_used} (CRF {crf}, preset {preset})…") |
|
|
| proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
| if proc.returncode != 0 and use_hevc and fallback_to_h264_on_error: |
| |
| vcodec = "libx264" |
| codec_used = "H.264 (libx264)" |
| fallback_cmd = [ |
| "ffmpeg", "-y", |
| "-i", infile_path, |
| *(vf_args), |
| "-vcodec", vcodec, |
| "-crf", str(crf), |
| "-preset", preset, |
| "-pix_fmt", "yuv420p", |
| "-acodec", "aac", "-b:a", f"{audio_bitrate_kbps}k", |
| "-movflags", "+faststart", |
| out_path |
| ] |
| proc2 = subprocess.run(fallback_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
| if proc2.returncode != 0: |
| raise RuntimeError( |
| f"FFmpeg failed on {base}.\n\n" |
| f"HEVC STDERR:\n{proc.stderr.decode(errors='ignore')}\n\n" |
| f"H.264 STDERR:\n{proc2.stderr.decode(errors='ignore')}" |
| ) |
|
|
| elif proc.returncode != 0: |
| raise RuntimeError( |
| f"FFmpeg failed on {base}.\n\nSTDERR:\n{proc.stderr.decode(errors='ignore')}\n\n" |
| f"STDOUT:\n{proc.stdout.decode(errors='ignore')}" |
| ) |
|
|
| compressed_size = os.path.getsize(out_path) |
| if progress: |
| progress(0.95, desc=f"Finalizing {base}") |
|
|
| return out_path, original_size, compressed_size, codec_used |
|
|
| def validate_inputs(files): |
| if not files or len(files) == 0: |
| raise gr.Error("Please upload at least one MP4 file.") |
| paths = [] |
| for f in files: |
| path = getattr(f, "name", None) or (isinstance(f, dict) and f.get("name")) or None |
| if not path: |
| raise gr.Error("Could not read uploaded file path.") |
| if not path.lower().endswith(".mp4"): |
| raise gr.Error(f"Unsupported file type: {os.path.basename(path)}. Please upload .mp4 only.") |
| paths.append(path) |
| return paths |
|
|
| def compress_videos( |
| files, |
| level: str, |
| use_advanced: bool, |
| crf_value: int, |
| preset: str, |
| audio_bitrate_kbps: int, |
| use_hevc: bool, |
| max_resolution_choice: str, |
| progress: gr.Progress = gr.Progress() |
| ): |
| """ |
| Multi-file compression with: |
| - H.264 vs H.265 toggle |
| - Optional downscaling |
| - Progress & summary report |
| """ |
| if not ffmpeg_available(): |
| raise gr.Error("FFmpeg is not installed. Add it in packages.txt.") |
|
|
| input_paths = validate_inputs(files) |
| total_files = len(input_paths) |
| progress(0, desc=f"Starting compression for {total_files} file(s)…") |
|
|
| target_height = resolution_to_height(max_resolution_choice) |
| crf = crf_value if use_advanced else map_level_to_crf(level, hevc=use_hevc) |
| preset = preset or "fast" |
| audio_bitrate_kbps = max(64, int(audio_bitrate_kbps or 128)) |
|
|
| records = [] |
| compressed_paths = [] |
|
|
| for idx, in_path in enumerate(input_paths, start=1): |
| base = os.path.basename(in_path) |
| try: |
| progress((idx - 1) / total_files, desc=f"[{idx}/{total_files}] Processing {base}") |
| out_path, orig_size, comp_size, codec_used = compress_single_video( |
| infile_path=in_path, |
| use_hevc=use_hevc, |
| crf=crf, |
| preset=preset, |
| audio_bitrate_kbps=audio_bitrate_kbps, |
| target_height=target_height, |
| progress=progress |
| ) |
| reduction_bytes = orig_size - comp_size |
| reduction_pct = (reduction_bytes / orig_size * 100.0) if orig_size > 0 else 0.0 |
| records.append({ |
| "File": base, |
| "Codec": codec_used, |
| "Downscale": max_resolution_choice if target_height else "None", |
| "Original Size (MB)": round(orig_size / (1024 * 1024), 3), |
| "Compressed Size (MB)": round(comp_size / (1024 * 1024), 3), |
| "Size Reduced (MB)": round(reduction_bytes / (1024 * 1024), 3), |
| "Reduction (%)": round(reduction_pct, 2), |
| "Output": os.path.basename(out_path) |
| }) |
| compressed_paths.append(out_path) |
| progress(idx / total_files, desc=f"[{idx}/{total_files}] Done {base} (↓ {round(reduction_pct,2)}%)") |
| except Exception as e: |
| records.append({ |
| "File": base, |
| "Codec": "—", |
| "Downscale": max_resolution_choice if target_height else "None", |
| "Original Size (MB)": None, |
| "Compressed Size (MB)": None, |
| "Size Reduced (MB)": None, |
| "Reduction (%)": None, |
| "Output": f"ERROR: {str(e).splitlines()[0][:180]}" |
| }) |
|
|
| df = pd.DataFrame.from_records(records) |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| report_name = f"compression_report_{timestamp}.csv" |
| report_path = os.path.join(OUTPUT_DIR, report_name) |
| df.to_csv(report_path, index=False) |
|
|
| zip_name = f"compressed_bundle_{timestamp}.zip" |
| zip_path = os.path.join(OUTPUT_DIR, zip_name) |
| with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: |
| zf.write(report_path, arcname=report_name) |
| for p in compressed_paths: |
| zf.write(p, arcname=os.path.basename(p)) |
|
|
| progress(1.0, desc="All done! Download your results below.") |
| return zip_path, df, compressed_paths |
|
|
| |
| |
| |
|
|
| with gr.Blocks(title = "BG CoRe.Vi MP4 (Multi-file)", css=""".summary-table {max-height: 280px; overflow-y: auto;}""") as demo: |
| gr.Markdown("# BG CoRe.Vi MP4 (H.264 / H.265, Downscaling, Multi-file)") |
| gr.Markdown( |
| "Upload one or more MP4s, choose compression level or advanced settings, optionally downscale, and download the compressed files.\n" |
| "Includes a progress bar and a CSV summary (sizes & reduction)." |
| ) |
|
|
| with gr.Row(): |
| with gr.Column(): |
| files_in = gr.Files( |
| label="Upload MP4 Videos", |
| file_count="multiple", |
| file_types=[".mp4"] |
| ) |
|
|
| level = gr.Radio( |
| label="Compression Level", |
| choices=["Low", "Medium", "High"], |
| value="Medium", |
| info="Low = best quality, High = smallest files" |
| ) |
|
|
| |
| use_hevc = gr.Checkbox(label="Use HEVC (H.265)", value=False, |
| info="Smaller files, slower to encode") |
| max_res = gr.Dropdown( |
| label="Optional Downscaling", |
| choices=[ |
| "No downscaling", |
| "720p (1280x720)", |
| "1080p (1920x1080)", |
| "1440p (2560x1440)", |
| "4K (3840x2160)" |
| ], |
| value="No downscaling" |
| ) |
|
|
| with gr.Accordion("Advanced Options (optional)", open=False): |
| use_adv = gr.Checkbox(label="Use advanced settings", value=False) |
| crf_val = gr.Slider(minimum=18, maximum=36, step=1, value=28, label="CRF (Quality/Compression)") |
| preset = gr.Dropdown( |
| label="FFmpeg Preset", |
| choices=["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"], |
| value="fast", |
| allow_custom_value=False |
| ) |
| audio_bitrate = gr.Slider( |
| minimum=64, maximum=320, step=16, value=128, label="Audio Bitrate (kbps)" |
| ) |
|
|
| run_btn = gr.Button("🔄 Compress", variant="primary") |
|
|
| with gr.Column(): |
| zip_out = gr.File(label="Download ZIP (compressed videos + CSV report)") |
| summary = gr.Dataframe(label="Compression Summary", interactive=False, wrap=True, elem_classes="summary-table") |
| files_out = gr.Files(label="Download Individual Compressed Files") |
|
|
| run_btn.click( |
| fn=compress_videos, |
| inputs=[files_in, level, use_adv, crf_val, preset, audio_bitrate, use_hevc, max_res], |
| outputs=[zip_out, summary, files_out] |
| ) |
|
|
| if __name__ == "__main__": |
| demo.launch() |