"""
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:
@staticmethod
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)
@spaces.GPU(duration=10)
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 = """
"""
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("""
""")
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 = '
Lỗi: Javascript chưa tải xong (window.initCropper is undefined). Hãy refresh lại trang!
';
}
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"
)