|
|
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() |