Spaces:
Running on Zero
Running on Zero
Download app.py.bak from hoangtaiii/DRIPPY4: direct link, hf CLI and curl.
- Browser
- Download file 39.5 kB
-
https://huggingface.co/spaces/hoangtaiii/DRIPPY4/resolve/main/app.py.bak
- Command line
-
hf download hf://spaces/hoangtaiii/DRIPPY4/app.py.bak
-
curl -L -o app.py.bak https://huggingface.co/spaces/hoangtaiii/DRIPPY4/resolve/main/app.py.bak
39.5 kB
| """ | |
| app.py — Gradio Cloud Studio for Hugging Face Spaces | |
| ──────────────────────────────────────────────────── | |
| Features: | |
| - Instant Live Video Preview with Bounding Box Overlay (100% reliable across all Gradio versions). | |
| - 1-Click Fast Presets (Bottom, Bottom-Low, Middle) + Step Nudge Buttons for mobile touch. | |
| - True Unicode Vietnamese Fonts (DejaVu Sans) — No more square [][][][][] boxes. | |
| - 90% Solid Masking Box to 100% cover old Chinese subtitles. | |
| - 25 AI Models Matrix (Groq, Gemini, OpenRouter, NVIDIA NIM, xKiro). | |
| """ | |
| import os | |
| import sys | |
| import time | |
| from pathlib import Path | |
| # Add current directory to sys.path | |
| BASE_DIR = Path(__file__).resolve().parent | |
| if str(BASE_DIR) not in sys.path: | |
| sys.path.insert(0, str(BASE_DIR)) | |
| import gradio as gr | |
| try: | |
| import spaces | |
| except ImportError: | |
| class MockSpaces: | |
| def GPU(*args, **kwargs): | |
| def decorator(f): | |
| return f | |
| return decorator | |
| spaces = MockSpaces() | |
| from app.core.cloud_pipeline import CloudPipeline | |
| pipeline = CloudPipeline(base_dir=BASE_DIR) | |
| def _zerogpu_keepalive(): | |
| return True | |
| def get_all_video_paths(video_file, video_urls=""): | |
| """Robust extraction of all video filepaths across all Gradio versions.""" | |
| paths = [] | |
| def _extract_single(v): | |
| if isinstance(v, str) and Path(v).exists(): | |
| return v | |
| if hasattr(v, "path") and v.path and Path(str(v.path)).exists(): | |
| return str(v.path) | |
| if hasattr(v, "name") and v.name and Path(str(v.name)).exists(): | |
| return str(v.name) | |
| if isinstance(v, dict): | |
| p = v.get("path") or v.get("name") | |
| if p and Path(str(p)).exists(): | |
| return str(p) | |
| try: | |
| cand = str(v) | |
| if Path(cand).exists(): | |
| return cand | |
| except Exception: | |
| pass | |
| return None | |
| if video_file is not None: | |
| if isinstance(video_file, (list, tuple)): | |
| for v in video_file: | |
| p = _extract_single(v) | |
| if p: paths.append(p) | |
| else: | |
| p = _extract_single(video_file) | |
| if p: paths.append(p) | |
| if video_urls and video_urls.strip(): | |
| urls = [u.strip() for u in video_urls.split('\n') if u.strip()] | |
| for url in urls: | |
| saved_path = BASE_DIR / "temp" / f"url_video_{int(time.time()*1000)}_{len(paths)}.mp4" | |
| saved_path.parent.mkdir(parents=True, exist_ok=True) | |
| try: | |
| import subprocess | |
| cmd = ["yt-dlp", "-f", "best[ext=mp4]/best", "-o", str(saved_path), url] | |
| res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120) | |
| if res.returncode == 0 and saved_path.exists(): | |
| paths.append(str(saved_path)) | |
| except Exception: | |
| pass | |
| return paths | |
| def get_actual_path(video_file, video_url=""): | |
| """Backward compatibility for preview/logo extraction.""" | |
| paths = get_all_video_paths(video_file, video_url) | |
| return paths[0] if paths else None | |
| def _resolve_logo_path(logo_file): | |
| if logo_file is not None: | |
| p = get_actual_path(logo_file, "") | |
| if p and Path(p).exists(): | |
| return p | |
| default = BASE_DIR / "assets" / "logo_ins_drippy4.png" | |
| if default.exists(): | |
| return str(default) | |
| alt = Path(r"C:\Users\Admin\Pictures\_imagine_prompt__The_letters__INS_202606162118.jpeg") | |
| if alt.exists(): | |
| return str(alt) | |
| return "" | |
| def _resolve_cta_path(cta_file): | |
| if cta_file is not None: | |
| p = get_actual_path(cta_file, "") | |
| if p and Path(p).exists(): | |
| return p | |
| default = BASE_DIR / "assets" / "cta_ins_drippy4.mp4" | |
| if default.exists(): | |
| return str(default) | |
| alt = Path(r"C:\Users\Admin\Downloads\ODR QUẦN ÁO, PKIỆN, TẢ GHÉ INS DRIPPY.4 #unbo(4).mp4") | |
| if alt.exists(): | |
| return str(alt) | |
| return "" | |
| def _error_image(msg): | |
| import numpy as np | |
| import cv2 | |
| import base64 | |
| img = np.zeros((720, 1280, 3), dtype=np.uint8) | |
| font = cv2.FONT_HERSHEY_SIMPLEX | |
| cv2.putText(img, "LOI PREVIEW:", (50, 300), font, 1.5, (0, 0, 255), 3, cv2.LINE_AA) | |
| cv2.putText(img, str(msg)[:80], (50, 380), font, 1.2, (0, 0, 255), 2, cv2.LINE_AA) | |
| ret, buffer = cv2.imencode('.jpg', img) | |
| if ret: | |
| return "data:image/jpeg;base64," + base64.b64encode(buffer).decode('utf-8') | |
| return "" | |
| def update_inputs_state(video_file, url_input): | |
| paths = get_all_video_paths(video_file, url_input) | |
| new_mask_state = {} | |
| for p in paths: | |
| new_mask_state[p] = {"y": 72, "h": 22} | |
| import os | |
| choices = [os.path.basename(p) for p in paths] | |
| selected = choices[0] if choices else None | |
| status_lines = [] | |
| for p in paths: | |
| status_lines.append(f"- 🎬 **{os.path.basename(p)}** `[Y=72%, H=22%]`") | |
| status_text = "\n".join(status_lines) if status_lines else "*Chưa có video nào*" | |
| return paths, new_mask_state, 0, gr.update(choices=choices, value=selected), status_text | |
| def on_selector_change(selected_name, paths, mask_state): | |
| if not paths or not selected_name: | |
| return 0, 72, 22 | |
| import os | |
| try: | |
| idx = next(i for i, p in enumerate(paths) if os.path.basename(p) == selected_name) | |
| except StopIteration: | |
| idx = 0 | |
| p = paths[idx] | |
| y = mask_state.get(p, {}).get("y", 72) | |
| h = mask_state.get(p, {}).get("h", 22) | |
| return idx, y, h | |
| def on_mask_change(y, h, apply_all, idx, paths, mask_state): | |
| if not paths or idx >= len(paths): | |
| return mask_state, "*Chưa có video nào*" | |
| if apply_all: | |
| for p in paths: | |
| if p not in mask_state: mask_state[p] = {} | |
| mask_state[p]["y"] = y | |
| mask_state[p]["h"] = h | |
| else: | |
| p = paths[idx] | |
| if p not in mask_state: mask_state[p] = {} | |
| mask_state[p]["y"] = y | |
| mask_state[p]["h"] = h | |
| import os | |
| status_lines = [] | |
| for p in paths: | |
| cy = mask_state.get(p, {}).get("y", 72) | |
| ch = mask_state.get(p, {}).get("h", 22) | |
| status_lines.append(f"- 🎬 **{os.path.basename(p)}** `[Y={cy}%, H={ch}%]`") | |
| return mask_state, "\n".join(status_lines) | |
| def parse_cropper_coords(json_str, paths, idx, apply_all, mask_state): | |
| import json | |
| if not json_str: | |
| import gradio as gr | |
| return gr.update(), gr.update(), mask_state, "" | |
| try: | |
| data = json.loads(json_str) | |
| y = int(data.get("y", 72)) | |
| h = int(data.get("h", 22)) | |
| mask_state, q_status = on_mask_change(y, h, apply_all, idx, paths, mask_state) | |
| return y, h, mask_state, q_status | |
| except: | |
| import gradio as gr | |
| return gr.update(), gr.update(), mask_state, "" | |
| def update_preview_box(paths, idx, custom_x, custom_y, custom_w, custom_h, preview_sec, logo_file=None, logo_enabled=False, logo_preset="bottom_right", logo_scale=18, cta_file=None, cta_enabled=False, cta_preset="bottom_center", cta_scale=48): | |
| try: | |
| from pathlib import Path | |
| if not paths or idx >= len(paths): | |
| return _error_image("Vui lòng chọn 1 video để xem trước") | |
| vpath = paths[idx] | |
| if not Path(vpath).exists(): | |
| return _error_image("Video không tồn tại") | |
| logo_path = _resolve_logo_path(logo_file) if logo_enabled else "" | |
| cta_path = _resolve_cta_path(cta_file) if cta_enabled else "" | |
| b64_str = pipeline.generate_preview_frame( | |
| video_path=str(vpath), | |
| region_preset="custom", | |
| custom_y_pct=float(custom_y), | |
| custom_h_pct=0.0, | |
| custom_x_pct=0.0, | |
| custom_w_pct=0.0, | |
| sec=float(preview_sec), | |
| return_type="base64", | |
| logo_enabled=bool(logo_enabled and logo_path and Path(logo_path).exists()), | |
| logo_path=str(logo_path) if logo_path else "", | |
| logo_preset=str(logo_preset), | |
| logo_scale=float(logo_scale), | |
| logo_opacity=1.0, | |
| logo_chromakey=True, | |
| cta_enabled=bool(cta_enabled and cta_path and Path(cta_path).exists()), | |
| cta_path=str(cta_path) if cta_path else "", | |
| cta_preset=str(cta_preset), | |
| cta_scale=float(cta_scale), | |
| cta_opacity=1.0, | |
| cta_interval=5.0, | |
| cta_duration=2.0, | |
| cta_chromakey=True | |
| ) | |
| if b64_str: | |
| return "data:image/jpeg;base64," + b64_str | |
| return "" | |
| except Exception as e: | |
| print(f"Lỗi preview: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| return _error_image(f"Loi: {e}") | |
| # Helper functions for 1-click preset buttons | |
| def preset_bottom(): | |
| return 0, 72, 100, 22 # x, y, w, h | |
| def preset_bottom_low(): | |
| return 0, 82, 100, 16 # x, y, w, h | |
| def preset_middle(): | |
| return 0, 38, 100, 24 # x, y, w, h | |
| def nudge_up(y): | |
| return max(0, y - 5) | |
| def nudge_down(y): | |
| return min(95, y + 5) | |
| def nudge_expand(h): | |
| return min(60, h + 4) | |
| def nudge_shrink(h): | |
| return max(4, h - 4) | |
| def process_batch( | |
| video_paths, | |
| mask_state, | |
| mode, | |
| sub_mask_mode, | |
| sub_style, | |
| custom_x, | |
| custom_w, | |
| voice, | |
| source_lang, | |
| speed, | |
| volume, | |
| mute_original=False, | |
| logo_file=None, | |
| logo_enabled=False, | |
| logo_preset="bottom_right", | |
| logo_scale=18, | |
| cta_file=None, | |
| cta_enabled=False, | |
| cta_preset="bottom_center", | |
| cta_scale=48, | |
| progress=gr.Progress() | |
| ): | |
| if not video_paths: | |
| yield None, "❌ Vui lòng chọn ít nhất 1 video hoặc nhập link hợp lệ!" | |
| return | |
| total = len(video_paths) | |
| logs = [f"🚀 Đã nhận {total} video để xử lý hàng loạt..."] | |
| yield [], "\n".join(logs) | |
| completed_files = [] | |
| logo_path = _resolve_logo_path(logo_file) if logo_enabled else "" | |
| cta_path = _resolve_cta_path(cta_file) if cta_enabled else "" | |
| for i, actual_video_path in enumerate(video_paths): | |
| logs.append(f"\n--- Đang xử lý video {i+1}/{total} ---") | |
| yield completed_files, "\n".join(logs) | |
| def log_cb(msg: str): | |
| logs.append(f"[{time.strftime('%H:%M:%S')}] {msg}") | |
| # Note: We can't yield from a callback directly in Gradio easily without Queue streaming, | |
| # but we can yield at the end of each video. The user will see progress via the gr.Progress. | |
| def progress_cb(pct: int, stage: str): | |
| progress(pct / 100.0, desc=f"Video {i+1}/{total}: {stage} ({pct}%)") | |
| exec_pipeline = CloudPipeline( | |
| base_dir=BASE_DIR, | |
| log_callback=log_cb, | |
| progress_callback=progress_cb | |
| ) | |
| cy = mask_state.get(actual_video_path, {}).get("y", 72) | |
| ch = mask_state.get(actual_video_path, {}).get("h", 22) | |
| out_video = exec_pipeline.run_video( | |
| video_path=str(actual_video_path), | |
| mode=mode, | |
| source_lang=source_lang, | |
| voice=voice, | |
| speed=speed, | |
| pitch=0, | |
| volume=int(volume), | |
| region_preset="custom", | |
| sub_mask_mode=sub_mask_mode, | |
| sub_style=sub_style, | |
| custom_y_pct=float(cy), | |
| custom_h_pct=float(ch), | |
| custom_x_pct=float(custom_x), | |
| custom_w_pct=float(custom_w), | |
| mute_original_audio=bool(mute_original), | |
| logo_enabled=bool(logo_enabled and logo_path and Path(logo_path).exists()), | |
| logo_path=str(logo_path) if logo_path else "", | |
| logo_preset=str(logo_preset), | |
| logo_scale=float(logo_scale), | |
| logo_opacity=1.0, | |
| cta_enabled=bool(cta_enabled and cta_path and Path(cta_path).exists()), | |
| cta_path=str(cta_path) if cta_path else "", | |
| cta_preset=str(cta_preset), | |
| cta_scale=float(cta_scale), | |
| cta_opacity=1.0, | |
| cta_interval=5.0, | |
| cta_duration=2.0 | |
| ) | |
| if out_video and Path(out_video).exists(): | |
| completed_files.append(str(out_video)) | |
| logs.append(f"✅ HOÀN THÀNH video {i+1}/{total}!") | |
| else: | |
| logs.append(f"❌ LỖI xử lý video {i+1}/{total}!") | |
| yield completed_files, "\n".join(logs) | |
| logs.append(f"\n🎉 XONG! Đã xử lý thành công {len(completed_files)}/{total} video.") | |
| yield completed_files, "\n".join(logs) | |
| head_html = """ | |
| <link href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.css" rel="stylesheet"> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js"></script> | |
| """ | |
| with gr.Blocks(title="Trung Sáng Việt Cloud Studio", head=head_html) as demo: | |
| gr.Markdown( | |
| """ | |
| # 🎬 TRUNG SÁNG VIỆT CLOUD STUDIO | |
| ### Dịch • Lồng Tiếng • Che Sub Cũ & Đè Sub Mới Chuẩn Studio • 100% Cloud-Native | |
| """ | |
| ) | |
| with gr.Row(): | |
| # Cột 1: Video & Live Preview Khung Vùng Che | |
| with gr.Column(scale=1): | |
| gr.Markdown("### 📥 1. Chọn Video & Xem Trước Trực Quan (Live Preview)") | |
| video_input = gr.File( | |
| label="📁 Bấm vào đây để chọn NHIỀU video từ Thư viện ảnh / Tệp tin điện thoại", | |
| file_types=["video", ".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"], | |
| file_count="multiple" | |
| ) | |
| url_input = gr.Textbox( | |
| label="Hoặc dán NHIỀU Link video (Douyin / TikTok), mỗi link 1 dòng", | |
| placeholder="https://v.douyin.com/...\nhttps://www.tiktok.com/...", | |
| lines=5 | |
| ) | |
| video_paths_state = gr.State([]) | |
| mask_state = gr.State({}) | |
| preview_idx_state = gr.State(0) | |
| with gr.Row(): | |
| video_selector = gr.Dropdown(label="🔍 Chọn Video để Căn Chỉnh", choices=[], interactive=True) | |
| apply_to_all = gr.Checkbox(label="✅ Áp dụng vị trí che này cho tất cả video trong danh sách", value=True) | |
| queue_status = gr.Markdown(value="*Chưa có video nào trong hàng đợi*") | |
| # Live preview frame with box | |
| preview_base64 = gr.Textbox(visible=True, elem_id="preview-base64") | |
| cropper_coords = gr.Textbox(visible=False, elem_id="cropper-coords") | |
| gr.HTML(""" | |
| <style> | |
| #preview-base64, #cropper-coords { display: none !important; } | |
| #cropper-container { width: 100%; background: #111; overflow: hidden; border-radius: 8px; border: 1px solid #333; position: relative; min-height: 200px; display: flex; justify-content: center; align-items: center; } | |
| #cropper-img { max-width: 100%; max-height: 55vh; display: block; } | |
| .cropper-view-box, .cropper-face { border-radius: 4px; } | |
| .cropper-line, .cropper-point { background-color: #ffcc00; } | |
| .cropper-view-box { outline: 2px solid #ffcc00; outline-color: rgba(255, 204, 0, 0.75); } | |
| </style> | |
| <div id="cropper-container"> | |
| <img id="cropper-img" src=""> | |
| </div> | |
| """) | |
| preview_sec = gr.Slider( | |
| minimum=0.0, maximum=30.0, value=3.0, step=0.5, | |
| label="⏱️ Tua giây video (để chọn khung hình có phụ đề rõ nhất)" | |
| ) | |
| # Fast 1-Click Alignment Buttons | |
| gr.Markdown("##### 🎯 Căn Chỉnh Vị Trí Nhanh (1-Chạm Trên Điện Thoại):") | |
| with gr.Row(): | |
| btn_preset_bottom = gr.Button("📍 Đáy Màn Hình (Chuẩn TikTok)", size="sm") | |
| btn_preset_bottom_low = gr.Button("📍 Đáy Sát Mép", size="sm") | |
| btn_preset_middle = gr.Button("📍 Giữa Video", size="sm") | |
| with gr.Row(): | |
| btn_up = gr.Button("🔼 Dịch Lên (-5%)", size="sm") | |
| btn_down = gr.Button("🔽 Dịch Xuống (+5%)", size="sm") | |
| btn_expand = gr.Button("➕ Cao Thêm (+4%)", size="sm") | |
| btn_shrink = gr.Button("➖ Thu Gọn (-4%)", size="sm") | |
| with gr.Row(): | |
| custom_y = gr.Slider(minimum=0, maximum=95, value=72, step=1, label="↕️ Vị trí Y (%)", elem_id="custom_y_slider") | |
| custom_h = gr.Slider(minimum=4, maximum=60, value=22, step=1, label="↕️ Chiều Cao H (%)", elem_id="custom_h_slider") | |
| with gr.Row(visible=False): | |
| custom_x = gr.Number(value=0) | |
| custom_w = gr.Number(value=100) | |
| mode_input = gr.Radio( | |
| choices=[ | |
| ("🎙️ Cloud ASR (Groq Whisper - Siêu tốc 1.5s/video)", "asr"), | |
| ("👁️ Cloud OCR (25 AI Vision Models - Quét chữ màn hình)", "ocr") | |
| ], | |
| value="asr", | |
| label="Phương thức bóc tách phụ đề" | |
| ) | |
| # Cột 2: Cấu hình Giọng Đọc & Sub Mới | |
| with gr.Column(scale=1): | |
| gr.Markdown("### 🎙️ 2. Cấu Hình Che Sub Cũ & Giọng Đọc Tiếng Việt") | |
| sub_mask_mode = gr.Dropdown( | |
| choices=[ | |
| ("⬛ Hộp Đen Mờ Che Kín (Phủ 90% - Che sạch 100% chữ Hán)", "box"), | |
| ("🌫️ Làm Mờ Sub Cũ (Delogo Blur) - Tự nhiên", "delogo"), | |
| ("🚫 Không Che (Chỉ Đè Sub Mới Viền Đậm)", "none") | |
| ], | |
| value="box", | |
| label="Kiểu che / xóa phụ đề cũ" | |
| ) | |
| voice_input = gr.Dropdown( | |
| choices=[ | |
| ("🎙️ Nam Minh (Edge-TTS - Trầm ấm, chuyên nghiệp)", "vi-VN-NamMinhNeural"), | |
| ("🎙️ Hoài My (Edge-TTS - Truyền cảm, ngọt ngào)", "vi-VN-HoaiMyNeural") | |
| ], | |
| value="vi-VN-NamMinhNeural", | |
| label="Giọng đọc tiếng Việt (Edge-TTS Miễn Phí)" | |
| ) | |
| source_lang_input = gr.Dropdown( | |
| choices=[("🇨🇳 Tiếng Trung (Chinese - zh)", "zh"), ("🇺🇸 Tiếng Anh (English - en)", "en")], | |
| value="zh", | |
| label="Ngôn ngữ gốc của video" | |
| ) | |
| sub_style = gr.Dropdown( | |
| choices=[ | |
| ("✨ Chữ Trắng Viền Đen Nổi Bật (Arial Bold 24)", "white_bold"), | |
| ("🟡 Chữ Vàng Viền Đen (Review/Phim)", "yellow_bold"), | |
| ("🟢 Chữ Xanh Neon Cyberpunk", "neon_cyan") | |
| ], | |
| value="white_bold", | |
| label="Kiểu chữ phụ đề tiếng Việt mới (Hardsub Style)" | |
| ) | |
| with gr.Row(): | |
| speed_input = gr.Slider(minimum=0.8, maximum=1.5, value=1.0, step=0.05, label="Tốc độ đọc (Speed)") | |
| vol_input = gr.Slider(minimum=50, maximum=150, value=100, step=5, label="Âm lượng (%)") | |
| mute_original = gr.Checkbox(label="🔇 Tắt tiếng gốc (chỉ giữ giọng Việt)", value=False) | |
| gr.Markdown("#### 🖼️ Logo INS DRIPPY 4 (nền xanh tự xóa)") | |
| logo_file = gr.File(label="Chọn logo (PNG/JPG) - để trống dùng logo mặc định", file_types=["image", ".png", ".jpg", ".jpeg"]) | |
| logo_enabled = gr.Checkbox(label="✅ Bật logo", value=False) | |
| logo_preset = gr.Dropdown(choices=["top_left","top_right","bottom_left","bottom_right","center","top_center","bottom_center","custom"], value="bottom_right", label="Vị trí logo (bắt buộc chọn 1 ví dụ)") | |
| logo_scale = gr.Slider(minimum=5, maximum=40, value=18, step=1, label="Kích thước logo (% chiều rộng) - TikTok safe") | |
| gr.Markdown("#### 🎬 CTA Video ODR #unbo(4) (nền xanh, hiện mỗi 5s - TikTok 48% rõ hơn)") | |
| cta_file = gr.File(label="Chọn CTA video (MP4) - để trống dùng CTA mặc định 2.5s", file_types=["video", ".mp4", ".mov"]) | |
| cta_enabled = gr.Checkbox(label="✅ Bật CTA video (mỗi 5s)", value=False) | |
| cta_preset = gr.Dropdown(choices=["top_left","top_right","bottom_left","bottom_right","center","top_center","bottom_center","custom"], value="bottom_center", label="Vị trí CTA (bắt buộc chọn 1 ví dụ)") | |
| cta_scale = gr.Slider(minimum=15, maximum=60, value=48, step=1, label="Kích thước CTA (% chiều rộng) - TikTok 48%") | |
| start_btn = gr.Button("🚀 BẮT ĐẦU DỊCH & LỒNG TIẾNG CLOUD", variant="primary") | |
| # ── Live Preview Dynamic Event Listeners (logo + CTA) ───────────────────── | |
| preview_inputs = [ | |
| video_paths_state, | |
| preview_idx_state, | |
| custom_x, | |
| custom_y, | |
| custom_w, | |
| custom_h, | |
| preview_sec, | |
| logo_file, | |
| logo_enabled, | |
| logo_preset, | |
| logo_scale, | |
| cta_file, | |
| cta_enabled, | |
| cta_preset, | |
| cta_scale | |
| ] | |
| # When inputs change, update states | |
| update_inputs_ev = dict( | |
| fn=update_inputs_state, | |
| inputs=[video_input, url_input], | |
| outputs=[video_paths_state, mask_state, preview_idx_state, video_selector, queue_status] | |
| ) | |
| js_init = """(b64) => { | |
| if (typeof window.initCropper === 'function') { | |
| window.initCropper(b64); | |
| } else { | |
| const container = document.getElementById('cropper-container'); | |
| if (container) container.innerHTML = '<h3 style="color:red;text-align:center;padding:20px;">Lỗi: Javascript chưa tải xong (window.initCropper is undefined). Hãy refresh lại trang!</h3>'; | |
| } | |
| return []; | |
| }""" | |
| js_update = "(y, h) => { if(window.updateCropperBox) window.updateCropperBox(y, h); return []; }" | |
| video_input.upload(**update_inputs_ev).then(fn=update_preview_box, inputs=preview_inputs, outputs=preview_base64).then(fn=None, inputs=[preview_base64], outputs=None, js=js_init) | |
| video_input.clear(**update_inputs_ev).then(fn=update_preview_box, inputs=preview_inputs, outputs=preview_base64).then(fn=None, inputs=[preview_base64], outputs=None, js=js_init) | |
| url_input.change(**update_inputs_ev).then(fn=update_preview_box, inputs=preview_inputs, outputs=preview_base64).then(fn=None, inputs=[preview_base64], outputs=None, js=js_init) | |
| # When selector changes, update preview_idx and UI sliders | |
| video_selector.change( | |
| fn=on_selector_change, | |
| inputs=[video_selector, video_paths_state, mask_state], | |
| outputs=[preview_idx_state, custom_y, custom_h] | |
| ).then(fn=update_preview_box, inputs=preview_inputs, outputs=preview_base64).then(fn=None, inputs=[preview_base64], outputs=None, js=js_init) | |
| # When Y, H sliders change, update mask_state and Cropper box | |
| mask_change_ev = dict( | |
| fn=on_mask_change, | |
| inputs=[custom_y, custom_h, apply_to_all, preview_idx_state, video_paths_state, mask_state], | |
| outputs=[mask_state, queue_status] | |
| ) | |
| custom_y.change(**mask_change_ev).then(fn=None, inputs=[custom_y, custom_h], outputs=None, js=js_update) | |
| custom_h.change(**mask_change_ev).then(fn=None, inputs=[custom_y, custom_h], outputs=None, js=js_update) | |
| # Handle JS Cropper to Python via hidden coords textbox | |
| cropper_coords.change( | |
| fn=parse_cropper_coords, | |
| inputs=[cropper_coords, video_paths_state, preview_idx_state, apply_to_all, mask_state], | |
| outputs=[custom_y, custom_h, mask_state, queue_status] | |
| ) | |
| # Visual updates | |
| preview_sec.change(fn=update_preview_box, inputs=preview_inputs, outputs=preview_base64).then(fn=None, inputs=[preview_base64], outputs=None, js=js_init) | |
| logo_file.change(fn=update_preview_box, inputs=preview_inputs, outputs=preview_base64).then(fn=None, inputs=[preview_base64], outputs=None, js=js_init) | |
| logo_enabled.change(fn=update_preview_box, inputs=preview_inputs, outputs=preview_base64).then(fn=None, inputs=[preview_base64], outputs=None, js=js_init) | |
| logo_preset.change(fn=update_preview_box, inputs=preview_inputs, outputs=preview_base64).then(fn=None, inputs=[preview_base64], outputs=None, js=js_init) | |
| logo_scale.change(fn=update_preview_box, inputs=preview_inputs, outputs=preview_base64).then(fn=None, inputs=[preview_base64], outputs=None, js=js_init) | |
| cta_file.change(fn=update_preview_box, inputs=preview_inputs, outputs=preview_base64).then(fn=None, inputs=[preview_base64], outputs=None, js=js_init) | |
| cta_enabled.change(fn=update_preview_box, inputs=preview_inputs, outputs=preview_base64).then(fn=None, inputs=[preview_base64], outputs=None, js=js_init) | |
| cta_preset.change(fn=update_preview_box, inputs=preview_inputs, outputs=preview_base64).then(fn=None, inputs=[preview_base64], outputs=None, js=js_init) | |
| cta_scale.change(fn=update_preview_box, inputs=preview_inputs, outputs=preview_base64).then(fn=None, inputs=[preview_base64], outputs=None, js=js_init) | |
| # Fast Preset Button Events | |
| btn_preset_bottom.click( | |
| fn=preset_bottom, | |
| outputs=[custom_x, custom_y, custom_w, custom_h] | |
| ) | |
| btn_preset_bottom_low.click( | |
| fn=preset_bottom_low, | |
| outputs=[custom_x, custom_y, custom_w, custom_h] | |
| ) | |
| btn_preset_middle.click( | |
| fn=preset_middle, | |
| outputs=[custom_x, custom_y, custom_w, custom_h] | |
| ) | |
| # Nudge Button Events | |
| btn_up.click(fn=nudge_up, inputs=[custom_y], outputs=[custom_y]) | |
| btn_down.click(fn=nudge_down, inputs=[custom_y], outputs=[custom_y]) | |
| btn_expand.click(fn=nudge_expand, inputs=[custom_h], outputs=[custom_h]) | |
| btn_shrink.click(fn=nudge_shrink, inputs=[custom_h], outputs=[custom_h]) | |
| with gr.Row(): | |
| with gr.Column(): | |
| gr.Markdown("### 🎉 3. Video Thành Phẩm & Nhật Ký Xử Lý") | |
| file_output = gr.File(label="Tải về các video đã hoàn thành (Nhiều file sẽ có nút Tải Tất Cả .ZIP)", file_count="multiple") | |
| log_output = gr.Textbox(label="Nhật ký tiến độ tổng thể (Live Logs)", lines=8) | |
| start_btn.click( | |
| fn=process_batch, | |
| inputs=[ | |
| video_paths_state, | |
| mask_state, | |
| mode_input, | |
| sub_mask_mode, | |
| sub_style, | |
| custom_x, | |
| custom_w, | |
| voice_input, | |
| source_lang_input, | |
| speed_input, | |
| vol_input, | |
| mute_original, | |
| logo_file, | |
| logo_enabled, | |
| logo_preset, | |
| logo_scale, | |
| cta_file, | |
| cta_enabled, | |
| cta_preset, | |
| cta_scale | |
| ], | |
| outputs=[file_output, log_output] | |
| ) | |
| # ── Secure Authentication via Hugging Face Space Secrets ────────────────────── | |
| # Mật khẩu được ẩn hoàn toàn trong mục Settings > Secrets của Hugging Face | |
| ACCOUNTS = {} | |
| env_auth = os.environ.get("AUTH_USERS", "") | |
| if env_auth: | |
| for item in env_auth.split(","): | |
| if ":" in item: | |
| u, p = item.strip().split(":", 1) | |
| ACCOUNTS[u.strip().lower()] = p.strip() | |
| # Fallback nếu chưa cấu hình Secret | |
| if not ACCOUNTS: | |
| default_user = os.environ.get("AUTH_USER", "admin") | |
| default_pass = os.environ.get("AUTH_PASSWORD", "DrippySecret2026@") | |
| ACCOUNTS[default_user.lower()] = default_pass | |
| def verify_login(username: str, password: str) -> bool: | |
| """ | |
| Xác thực đăng nhập an toàn chống nhìn trộm mã nguồn: | |
| - Bỏ qua chữ hoa/chữ thường ở username. | |
| - Tự động cắt khoảng trắng thừa ở đầu/cuối trên điện thoại. | |
| """ | |
| if not username or not password: | |
| return False | |
| clean_user = username.strip().lower() | |
| clean_pass = password.strip() | |
| return ACCOUNTS.get(clean_user) == clean_pass | |
| # --- AUTO-SAVE SETTINGS FEATURE --- | |
| settings_comps = [ | |
| mode_input, sub_mask_mode, voice_input, source_lang_input, sub_style, | |
| speed_input, vol_input, mute_original, logo_enabled, logo_preset, logo_scale, | |
| cta_enabled, cta_preset, cta_scale | |
| ] | |
| load_js = ''' | |
| function() { | |
| let saved = localStorage.getItem('drippy4_settings'); | |
| if (saved) { | |
| try { | |
| let s = JSON.parse(saved); | |
| return [ | |
| s.mode !== undefined ? s.mode : "asr", | |
| s.mask !== undefined ? s.mask : "box", | |
| s.voice !== undefined ? s.voice : "vi-VN-NamMinhNeural", | |
| s.lang !== undefined ? s.lang : "zh", | |
| s.style !== undefined ? s.style : "white_bold", | |
| s.speed !== undefined ? s.speed : 1.15, | |
| s.vol !== undefined ? s.vol : 100, | |
| s.mute !== undefined ? s.mute : false, | |
| s.logo_e !== undefined ? s.logo_e : false, | |
| s.logo_p !== undefined ? s.logo_p : "bottom_right", | |
| s.logo_s !== undefined ? s.logo_s : 18.0, | |
| s.cta_e !== undefined ? s.cta_e : false, | |
| s.cta_p !== undefined ? s.cta_p : "bottom_center", | |
| s.cta_s !== undefined ? s.cta_s : 48.0 | |
| ]; | |
| } catch(e) {} | |
| } | |
| return ["asr", "box", "vi-VN-NamMinhNeural", "zh", "white_bold", 1.0, 100, false, false, "bottom_right", 18.0, false, "bottom_center", 48.0]; | |
| } | |
| ''' | |
| save_js = ''' | |
| function(v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14) { | |
| let settings = { | |
| mode: v1, mask: v2, voice: v3, lang: v4, style: v5, speed: v6, vol: v7, mute: v8, | |
| logo_e: v9, logo_p: v10, logo_s: v11, cta_e: v12, cta_p: v13, cta_s: v14 | |
| }; | |
| localStorage.setItem('drippy4_settings', JSON.stringify(settings)); | |
| return []; | |
| } | |
| ''' | |
| cropper_js = """ | |
| function() { | |
| window.cropperInstance = null; | |
| window.isCropperUpdating = false; | |
| // Foolproof watcher for base64 changes | |
| setInterval(() => { | |
| let tb = null; | |
| const container = document.querySelector('#preview-base64'); | |
| if (container) { | |
| if (container.shadowRoot) { | |
| tb = container.shadowRoot.querySelector('textarea') || container.shadowRoot.querySelector('input'); | |
| } else { | |
| tb = container.querySelector('textarea') || container.querySelector('input'); | |
| } | |
| } | |
| if (tb && tb.value && tb.value !== window.lastB64) { | |
| window.lastB64 = tb.value; | |
| window.initCropper(tb.value); | |
| } | |
| }, 500); | |
| // Watcher for Slider changes (to sync UI buttons back to Cropper) | |
| setInterval(() => { | |
| if (window.isCropperUpdating || !window.cropperInstance) return; | |
| let custom_y_el = null; | |
| const y_container = document.querySelector('#custom_y_slider'); | |
| if (y_container) { | |
| custom_y_el = y_container.shadowRoot ? y_container.shadowRoot.querySelector('input[type="range"]') || y_container.shadowRoot.querySelector('input') : y_container.querySelector('input[type="range"]') || y_container.querySelector('input'); | |
| } | |
| let custom_h_el = null; | |
| const h_container = document.querySelector('#custom_h_slider'); | |
| if (h_container) { | |
| custom_h_el = h_container.shadowRoot ? h_container.shadowRoot.querySelector('input[type="range"]') || h_container.shadowRoot.querySelector('input') : h_container.querySelector('input[type="range"]') || h_container.querySelector('input'); | |
| } | |
| if (custom_y_el && custom_h_el) { | |
| let y_val = parseFloat(custom_y_el.value); | |
| let h_val = parseFloat(custom_h_el.value); | |
| if (y_val !== window.lastSliderY || h_val !== window.lastSliderH) { | |
| window.lastSliderY = y_val; | |
| window.lastSliderH = h_val; | |
| window.updateCropperBox(y_val, h_val); | |
| } | |
| } | |
| }, 300); | |
| window.initCropper = function(b64_or_url) { | |
| const img = document.getElementById('cropper-img'); | |
| if (!img) return; | |
| const setupCropper = () => { | |
| if (window.cropperInstance) { | |
| window.cropperInstance.destroy(); | |
| } | |
| let custom_y_el = null; | |
| const y_container = document.querySelector('#custom_y_slider'); | |
| if (y_container) { | |
| custom_y_el = y_container.shadowRoot ? y_container.shadowRoot.querySelector('input[type="range"]') || y_container.shadowRoot.querySelector('input') : y_container.querySelector('input[type="range"]') || y_container.querySelector('input'); | |
| } | |
| let custom_h_el = null; | |
| const h_container = document.querySelector('#custom_h_slider'); | |
| if (h_container) { | |
| custom_h_el = h_container.shadowRoot ? h_container.shadowRoot.querySelector('input[type="range"]') || h_container.shadowRoot.querySelector('input') : h_container.querySelector('input[type="range"]') || h_container.querySelector('input'); | |
| } | |
| let init_y = custom_y_el ? parseFloat(custom_y_el.value) : 72; | |
| let init_h = custom_h_el ? parseFloat(custom_h_el.value) : 22; | |
| window.cropperInstance = new Cropper(img, { | |
| zoomable: false, | |
| viewMode: 1, | |
| autoCropArea: 1, | |
| background: false, | |
| ready() { | |
| const imgData = this.cropper.getImageData(); | |
| this.cropper.setData({ | |
| x: 0, | |
| y: (init_y / 100) * imgData.naturalHeight, | |
| width: imgData.naturalWidth, | |
| height: (init_h / 100) * imgData.naturalHeight | |
| }); | |
| }, | |
| cropend(event) { | |
| window.isCropperUpdating = true; | |
| const data = this.cropper.getData(true); | |
| const imgData = this.cropper.getImageData(); | |
| let y_pct = (data.y / imgData.naturalHeight) * 100; | |
| let h_pct = (data.height / imgData.naturalHeight) * 100; | |
| y_pct = Math.max(0, Math.min(100, Math.round(y_pct))); | |
| h_pct = Math.max(1, Math.min(100, Math.round(h_pct))); | |
| let coords_tb = null; | |
| const coords_container = document.querySelector('#cropper-coords'); | |
| if (coords_container) { | |
| coords_tb = coords_container.shadowRoot ? coords_container.shadowRoot.querySelector('textarea') || coords_container.shadowRoot.querySelector('input') : coords_container.querySelector('textarea') || coords_container.querySelector('input'); | |
| } | |
| if(coords_tb) { | |
| coords_tb.value = JSON.stringify({y: y_pct, h: h_pct, rand: Math.random()}); | |
| coords_tb.dispatchEvent(new Event('input', { bubbles: true })); | |
| } | |
| setTimeout(() => window.isCropperUpdating = false, 300); | |
| } | |
| }); | |
| }; | |
| if (img.src !== b64_or_url && b64_or_url) { | |
| img.onload = setupCropper; | |
| img.src = b64_or_url; | |
| } else if (b64_or_url) { | |
| setupCropper(); | |
| } | |
| }; | |
| window.updateCropperBox = function(y_pct, h_pct) { | |
| if (window.cropperInstance && !window.isCropperUpdating) { | |
| const imgData = window.cropperInstance.getImageData(); | |
| window.cropperInstance.setData({ | |
| x: 0, | |
| y: (y_pct / 100) * imgData.naturalHeight, | |
| width: imgData.naturalWidth, | |
| height: (h_pct / 100) * imgData.naturalHeight | |
| }); | |
| } | |
| } | |
| return []; | |
| } | |
| """ | |
| demo.load(fn=None, inputs=None, outputs=None, js=cropper_js) | |
| demo.load(fn=None, inputs=None, outputs=settings_comps, js=load_js) | |
| for comp in settings_comps: | |
| comp.change(fn=None, inputs=settings_comps, outputs=None, js=save_js) | |
| if __name__ == "__main__": | |
| demo.queue(max_size=20).launch( | |
| auth=verify_login, | |
| auth_message="🎬 TRUNG SÁNG VIỆT STUDIO • Vui lòng đăng nhập để sử dụng\n📱 Lưu ý: Hãy mở qua link https://hoangtaiii-drippy4.hf.space để đăng nhập mượt nhất trên điện thoại" | |
| ) | |