Spaces:
Sleeping
Sleeping
Download app.py from ryuhan/Rimuru-Compressor: direct link, hf CLI and curl.
- Browser
- Download file 17.4 kB
-
https://huggingface.co/spaces/ryuhan/Rimuru-Compressor/resolve/main/app.py
- Command line
-
hf download hf://spaces/ryuhan/Rimuru-Compressor/app.py
-
curl -L -o app.py https://huggingface.co/spaces/ryuhan/Rimuru-Compressor/resolve/main/app.py
17.4 kB
| import gradio as gr | |
| import subprocess | |
| import tempfile | |
| import shutil | |
| import os | |
| import struct | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # MP4 ATOM PATCHING (Python port of C# code) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def index_of_sequence(buffer: bytes, pattern: bytes, start: int) -> int: | |
| if not buffer or not pattern or len(pattern) > len(buffer) - start: | |
| return -1 | |
| for i in range(start, len(buffer) - len(pattern) + 1): | |
| if buffer[i:i+len(pattern)] == pattern: | |
| return i | |
| return -1 | |
| def patch_atom(atom_name: str, data: bytearray, scale_factor: float | None, log: list) -> int: | |
| patch_count = 0 | |
| start_index = 0 | |
| atom_bytes = atom_name.encode("utf-8") | |
| while True: | |
| found_index = index_of_sequence(bytes(data), atom_bytes, start_index) | |
| if found_index == -1: | |
| break | |
| header_offset = found_index - 4 | |
| if header_offset < 0: | |
| start_index = found_index + 4 | |
| continue | |
| if header_offset + 4 > len(data): | |
| start_index = found_index + 4 | |
| continue | |
| box_size = struct.unpack_from(">I", data, header_offset)[0] | |
| if box_size < 8: | |
| start_index = found_index + 4 | |
| continue | |
| if header_offset + 8 >= len(data): | |
| start_index = found_index + 4 | |
| continue | |
| version = data[header_offset + 8] | |
| if version == 0: | |
| ts_off, dur_off = header_offset + 20, header_offset + 24 | |
| if dur_off + 4 > len(data): | |
| start_index = found_index + 4 | |
| continue | |
| old_ts = struct.unpack_from(">I", data, ts_off)[0] | |
| old_dur = struct.unpack_from(">I", data, dur_off)[0] | |
| chosen = scale_factor if scale_factor is not None else (30000.0 / old_ts) | |
| new_ts = int(old_ts * chosen) | |
| new_dur = int(old_dur * chosen) | |
| struct.pack_into(">I", data, ts_off, new_ts) | |
| struct.pack_into(">I", data, dur_off, new_dur) | |
| log.append(f" β Patched '{atom_name}' (v0): timescale {old_ts}β{new_ts}, duration {old_dur}β{new_dur}") | |
| patch_count += 1 | |
| elif version == 1: | |
| ts_off, dur_off = header_offset + 28, header_offset + 32 | |
| if dur_off + 8 > len(data): | |
| start_index = found_index + 4 | |
| continue | |
| old_ts = struct.unpack_from(">I", data, ts_off)[0] | |
| old_dur = struct.unpack_from(">Q", data, dur_off)[0] | |
| chosen = scale_factor if scale_factor is not None else (30000.0 / old_ts) | |
| new_ts = int(old_ts * chosen) | |
| new_dur = int(old_dur * chosen) | |
| struct.pack_into(">I", data, ts_off, new_ts) | |
| struct.pack_into(">Q", data, dur_off, new_dur) | |
| log.append(f" β Patched '{atom_name}' (v1): timescale {old_ts}β{new_ts}, duration {old_dur}β{new_dur}") | |
| patch_count += 1 | |
| else: | |
| log.append(f" β '{atom_name}' (v{version}) skipped β unknown version") | |
| start_index = found_index + 4 | |
| return patch_count | |
| def patch_mp4(input_path: str, output_path: str, scale_factor: float | None) -> list[str]: | |
| log = [] | |
| with open(input_path, "rb") as f: | |
| data = bytearray(f.read()) | |
| log.append(f"π Read {len(data):,} bytes") | |
| n_mvhd = patch_atom("mvhd", data, scale_factor, log) | |
| n_mdhd = patch_atom("mdhd", data, scale_factor, log) | |
| total = n_mvhd + n_mdhd | |
| if total == 0: | |
| log.append("β No atoms patched β file may be unchanged") | |
| else: | |
| log.append(f"β Total atoms patched: {total}") | |
| with open(output_path, "wb") as f: | |
| f.write(data) | |
| log.append(f"πΎ Saved to {output_path}") | |
| return log | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # FFMPEG HELPERS | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def ffmpeg_available() -> bool: | |
| return shutil.which("ffmpeg") is not None | |
| def detect_fps(input_path: str) -> float: | |
| try: | |
| result = subprocess.run( | |
| ["ffprobe", "-v", "error", "-select_streams", "v:0", | |
| "-show_entries", "stream=r_frame_rate", | |
| "-of", "default=noprint_wrappers=1:nokey=1", input_path], | |
| capture_output=True, text=True, timeout=30 | |
| ) | |
| num, den = result.stdout.strip().split("/") | |
| return round(float(num) / float(den), 2) | |
| except Exception: | |
| return 0.0 | |
| def get_video_info(input_path: str) -> dict: | |
| try: | |
| import json | |
| result = subprocess.run( | |
| ["ffprobe", "-v", "error", "-print_format", "json", | |
| "-show_streams", "-show_format", input_path], | |
| capture_output=True, text=True, timeout=30 | |
| ) | |
| data = json.loads(result.stdout) | |
| fmt = data.get("format", {}) | |
| vstream = next((s for s in data.get("streams", []) if s.get("codec_type") == "video"), {}) | |
| return { | |
| "duration": float(fmt.get("duration", 0)), | |
| "size_mb": round(os.path.getsize(input_path) / 1e6, 2), | |
| "width": vstream.get("width", "?"), | |
| "height": vstream.get("height", "?"), | |
| "codec": vstream.get("codec_name", "?"), | |
| "fps": vstream.get("r_frame_rate", "?"), | |
| } | |
| except Exception: | |
| return {} | |
| def reencode_video(input_path: str, output_path: str, | |
| crf: int, fps_out: float, | |
| use_nvenc: bool, log: list) -> bool: | |
| codec = "h264_nvenc" if use_nvenc else "libx264" | |
| preset = "p4" if use_nvenc else "slow" | |
| cmd = [ | |
| "ffmpeg", "-y", "-i", input_path, | |
| "-c:v", codec, | |
| "-preset", preset, | |
| "-r", str(fps_out), | |
| "-pix_fmt", "yuv420p", | |
| "-movflags", "+faststart", | |
| ] | |
| if use_nvenc: | |
| cmd += ["-cq", str(crf)] | |
| else: | |
| cmd += ["-crf", str(crf)] | |
| cmd += ["-c:a", "aac", "-b:a", "192k", output_path] | |
| log.append(f"βΆ FFmpeg: {' '.join(cmd)}") | |
| try: | |
| result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) | |
| if result.returncode != 0: | |
| log.append(f"β FFmpeg error:\n{result.stderr[-800:]}") | |
| return False | |
| log.append("β FFmpeg encoding complete") | |
| return True | |
| except subprocess.TimeoutExpired: | |
| log.append("β FFmpeg timed out") | |
| return False | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # MAIN PIPELINE | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| FPS_SCALE = { | |
| "120fps β 60fps": (120, 0.5), | |
| "120fps β 30fps": (120, 0.25), | |
| "90fps β 60fps": (90, 0.5), | |
| "90fps β 30fps": (90, 0.25), | |
| "60fps β 60fps": (60, 1.0), | |
| "60fps β 30fps": (60, 0.5), | |
| "30fps β 30fps": (30, 1.0), | |
| "Auto (target 30k timescale)": (None, None), | |
| } | |
| def process_video( | |
| video_file, | |
| fps_mode: str, | |
| crf: int, | |
| use_nvenc: bool, | |
| enable_reencode: bool, | |
| enable_patch: bool, | |
| ): | |
| if video_file is None: | |
| return None, "β Please upload a video first." | |
| input_path = video_file | |
| logs: list[str] = [] | |
| logs.append("β" * 48) | |
| logs.append("π¬ TikTok HD Video Compressor β Starting...") | |
| logs.append("β" * 48) | |
| if not ffmpeg_available(): | |
| logs.append("β ffmpeg / ffprobe not found in PATH β skipping encode step") | |
| enable_reencode = False | |
| info = get_video_info(input_path) | |
| if info: | |
| logs.append(f"π Input : {info.get('width')}Γ{info.get('height')} " | |
| f"{info.get('fps')} fps {info.get('codec')} " | |
| f"{info.get('size_mb')} MB {info.get('duration'):.1f}s") | |
| tmp_dir = tempfile.mkdtemp() | |
| try: | |
| step1 = os.path.join(tmp_dir, "step1_encoded.mp4") | |
| step2 = os.path.join(tmp_dir, "step2_patched.mp4") | |
| # Determine output FPS from fps_mode | |
| _, scale_factor = FPS_SCALE.get(fps_mode, (None, None)) | |
| fps_map = { | |
| "120fps β 60fps": 60, | |
| "120fps β 30fps": 30, | |
| "90fps β 60fps": 60, | |
| "90fps β 30fps": 30, | |
| "60fps β 60fps": 60, | |
| "60fps β 30fps": 30, | |
| "30fps β 30fps": 30, | |
| "Auto (target 30k timescale)": 30, | |
| } | |
| fps_out = fps_map.get(fps_mode, 30) | |
| # ββ Step 1: Re-encode with FFmpeg ββββββββββββββββββ | |
| if enable_reencode: | |
| logs.append("\nββ Step 1: FFmpeg Re-encode ββββββββββββββββββ") | |
| ok = reencode_video(input_path, step1, crf, fps_out, use_nvenc, logs) | |
| if not ok: | |
| return None, "\n".join(logs) | |
| encoded_path = step1 | |
| else: | |
| logs.append("\nββ Step 1: Re-encode skipped βββββββββββββββββ") | |
| encoded_path = input_path | |
| # ββ Step 2: Atom Patching ββββββββββββββββββββββββββ | |
| if enable_patch: | |
| logs.append("\nββ Step 2: MP4 Atom Patching βββββββββββββββββ") | |
| patch_logs = patch_mp4(encoded_path, step2, scale_factor) | |
| logs.extend(patch_logs) | |
| final_path = step2 | |
| else: | |
| logs.append("\nββ Step 2: Atom patch skipped ββββββββββββββββ") | |
| final_path = encoded_path | |
| # ββ Final output βββββββββββββββββββββββββββββββββββ | |
| out_dir = tempfile.mkdtemp() | |
| out_path = os.path.join(out_dir, "tiktok_hd_output.mp4") | |
| shutil.copy2(final_path, out_path) | |
| out_info = get_video_info(out_path) | |
| if out_info: | |
| logs.append(f"\nπ Output : {out_info.get('width')}Γ{out_info.get('height')} " | |
| f"{out_info.get('fps')} fps {out_info.get('codec')} " | |
| f"{out_info.get('size_mb')} MB") | |
| size_in = info.get("size_mb", 0) | |
| size_out = out_info.get("size_mb", 0) if out_info else 0 | |
| if size_in and size_out: | |
| pct = round((1 - size_out / size_in) * 100, 1) | |
| logs.append(f"π Size reduction: {size_in} MB β {size_out} MB ({pct}% smaller)") | |
| logs.append("\nβ Done! Download your TikTok-ready video below.") | |
| return out_path, "\n".join(logs) | |
| except Exception as e: | |
| logs.append(f"\nβ Unexpected error: {e}") | |
| return None, "\n".join(logs) | |
| finally: | |
| shutil.rmtree(tmp_dir, ignore_errors=True) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # GRADIO UI | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:wght@300;400;600&display=swap'); | |
| :root { | |
| --bg: #0a0a0f; | |
| --surface: #12121a; | |
| --surface2: #1c1c28; | |
| --accent: #ff2d55; | |
| --accent2: #0af; | |
| --text: #f0f0f8; | |
| --muted: #888; | |
| --border: #2a2a3a; | |
| --radius: 12px; | |
| --mono: 'Space Mono', monospace; | |
| --sans: 'DM Sans', sans-serif; | |
| } | |
| body, .gradio-container { | |
| background: var(--bg) !important; | |
| font-family: var(--sans) !important; | |
| color: var(--text) !important; | |
| } | |
| .gradio-container { max-width: 860px !important; margin: 0 auto !important; } | |
| /* Header */ | |
| .app-header { | |
| text-align: center; | |
| padding: 2.5rem 1rem 1.5rem; | |
| border-bottom: 1px solid var(--border); | |
| margin-bottom: 2rem; | |
| } | |
| .app-header h1 { | |
| font-family: var(--mono) !important; | |
| font-size: 2rem; | |
| letter-spacing: -0.02em; | |
| color: var(--text); | |
| margin: 0 0 .4rem; | |
| } | |
| .app-header h1 span { color: var(--accent); } | |
| .app-header p { color: var(--muted); font-size: .9rem; margin: 0; } | |
| /* Panels */ | |
| .panel { | |
| background: var(--surface) !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: var(--radius) !important; | |
| padding: 1.25rem !important; | |
| margin-bottom: 1rem !important; | |
| } | |
| .panel-title { | |
| font-family: var(--mono); | |
| font-size: .7rem; | |
| letter-spacing: .12em; | |
| text-transform: uppercase; | |
| color: var(--accent2); | |
| margin-bottom: .75rem; | |
| } | |
| /* Inputs */ | |
| label span, .gr-form label span { | |
| font-family: var(--sans) !important; | |
| font-size: .8rem !important; | |
| color: var(--muted) !important; | |
| font-weight: 600 !important; | |
| letter-spacing: .04em !important; | |
| text-transform: uppercase !important; | |
| } | |
| input, select, textarea, .gr-input, .gr-dropdown { | |
| background: var(--surface2) !important; | |
| border: 1px solid var(--border) !important; | |
| color: var(--text) !important; | |
| border-radius: 8px !important; | |
| font-family: var(--sans) !important; | |
| } | |
| /* Buttons */ | |
| button.primary, .gr-button-primary { | |
| background: var(--accent) !important; | |
| border: none !important; | |
| color: #fff !important; | |
| font-family: var(--mono) !important; | |
| font-size: .85rem !important; | |
| letter-spacing: .05em !important; | |
| border-radius: 8px !important; | |
| padding: .7rem 1.5rem !important; | |
| transition: opacity .2s !important; | |
| } | |
| button.primary:hover { opacity: .85 !important; } | |
| /* Log box */ | |
| .log-box textarea { | |
| font-family: var(--mono) !important; | |
| font-size: .75rem !important; | |
| background: #08080e !important; | |
| color: #7dff8a !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: 8px !important; | |
| line-height: 1.7 !important; | |
| } | |
| /* Info badge */ | |
| .info-badge { | |
| display: inline-flex; | |
| gap: .4rem; | |
| align-items: center; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| padding: .3rem .7rem; | |
| font-family: var(--mono); | |
| font-size: .7rem; | |
| color: var(--muted); | |
| margin-right: .5rem; | |
| margin-bottom: .5rem; | |
| } | |
| """ | |
| HEADER_HTML = """ | |
| <div class="app-header"> | |
| <h1>π¬ TikTok<span>HD</span> Compressor</h1> | |
| <p>MP4 Atom Patching Β· FFmpeg H.264 Β· Smooth FPS for TikTok upload</p> | |
| </div> | |
| """ | |
| INFO_HTML = """ | |
| <div style="display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem"> | |
| <span class="info-badge">β‘ MP4 Atom Patch</span> | |
| <span class="info-badge">π FFmpeg H.264</span> | |
| <span class="info-badge">π― TikTok-optimised</span> | |
| <span class="info-badge">π₯ CPU + NVIDIA GPU</span> | |
| </div> | |
| """ | |
| FPS_CHOICES = list(FPS_SCALE.keys()) | |
| with gr.Blocks(css=CSS, title="TikTok HD Compressor") as demo: | |
| gr.HTML(HEADER_HTML) | |
| gr.HTML(INFO_HTML) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.HTML('<div class="panel-title">π Input Video</div>') | |
| video_input = gr.Video(label="Upload MP4 / MOV / AVI", sources=["upload"]) | |
| gr.HTML('<div class="panel-title" style="margin-top:1.25rem">β Encoding Settings</div>') | |
| fps_mode = gr.Dropdown( | |
| label="FPS Mode (Atom Patch Scale)", | |
| choices=FPS_CHOICES, | |
| value="60fps β 30fps", | |
| ) | |
| crf_slider = gr.Slider( | |
| label="CRF Quality (lower = better, larger file)", | |
| minimum=15, maximum=35, value=23, step=1, | |
| ) | |
| with gr.Row(): | |
| enable_reencode = gr.Checkbox(label="Re-encode with FFmpeg", value=True) | |
| enable_patch = gr.Checkbox(label="MP4 Atom Patch", value=True) | |
| use_nvenc = gr.Checkbox(label="NVIDIA GPU (h264_nvenc)", value=False) | |
| run_btn = gr.Button("βΆ Compress for TikTok", variant="primary", size="lg") | |
| with gr.Column(scale=1): | |
| gr.HTML('<div class="panel-title">π€ Output</div>') | |
| video_output = gr.Video(label="Processed Video") | |
| gr.HTML('<div class="panel-title" style="margin-top:1.25rem">π Log</div>') | |
| log_output = gr.Textbox( | |
| label="", | |
| lines=18, | |
| interactive=False, | |
| elem_classes=["log-box"], | |
| placeholder="Logs will appear here after processing...", | |
| ) | |
| gr.HTML(""" | |
| <div style="margin-top:1.5rem;border-top:1px solid #2a2a3a;padding-top:1rem; | |
| text-align:center;font-size:.72rem;color:#555;font-family:'Space Mono',monospace;"> | |
| MP4 Atom Patching based on Editing News / Evilfy (GPLv3) Β· | |
| FFmpeg licensed under LGPL/GPL Β· | |
| Built for TikTok HD upload optimisation | |
| </div> | |
| """) | |
| run_btn.click( | |
| fn=process_video, | |
| inputs=[video_input, fps_mode, crf_slider, use_nvenc, enable_reencode, enable_patch], | |
| outputs=[video_output, log_output], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() | |