import streamlit as st
import tempfile
import os
import subprocess
import imageio_ffmpeg
import time
import gc
import shutil
import yt_dlp
from PIL import Image
# --- 1. CẤU HÌNH & CSS ---
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
st.set_page_config(page_title="Subtitles Pro Fixed", layout="wide", page_icon="🎬")
st.markdown("""
""", unsafe_allow_html=True)
# --- 2. SESSION STATE ---
if 'preview_img_path' not in st.session_state: st.session_state.preview_img_path = None
if 'final_video_path' not in st.session_state: st.session_state.final_video_path = None
if 'sub_configs' not in st.session_state: st.session_state.sub_configs = {}
if 'input_video_path' not in st.session_state: st.session_state.input_video_path = None
if 'custom_font_name' not in st.session_state: st.session_state.custom_font_name = None
# --- 3. HÀM HỖ TRỢ ---
def hex_to_ass_color(hex_color, opacity_percent=100):
"""
Chuyển đổi màu Hex sang chuẩn ASS (&HAABBGGRR).
opacity_percent:
- 100: Màu đậm đặc (Solid) -> Alpha 00
- 0: Trong suốt (Invisible) -> Alpha FF
"""
# Tính giá trị Alpha (đảo ngược: 100% hiện = 00 alpha)
alpha_val = 255 - int(opacity_percent * 255 / 100)
alpha_hex = f"{alpha_val:02X}"
hex_color = hex_color.lstrip('#')
if len(hex_color) != 6: return "&H00FFFFFF"
# Thứ tự ASS là Blue-Green-Red
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"&H{alpha_hex}{b}{g}{r}"
def get_linux_path(path): return path.replace("\\", "/")
def install_custom_font(uploaded_font):
font_dir = os.path.join(os.getcwd(), "fonts")
os.makedirs(font_dir, exist_ok=True)
font_path = os.path.join(font_dir, uploaded_font.name)
with open(font_path, "wb") as f:
f.write(uploaded_font.getvalue())
font_name = os.path.splitext(uploaded_font.name)[0]
return font_path, font_dir, font_name
def download_video_from_url(url):
temp_dir = tempfile.gettempdir()
ydl_opts = {
'format': 'bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720][ext=mp4]',
'outtmpl': os.path.join(temp_dir, 'dl_video_%(id)s.%(ext)s'),
'quiet': True, 'no_warnings': True, 'socket_timeout': 30,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
filename = ydl.prepare_filename(info)
return filename
def create_static_video(image_file, audio_file, resolution_mode):
temp_dir = tempfile.gettempdir()
img_path = os.path.join(temp_dir, "temp_bg.jpg")
audio_path = os.path.join(temp_dir, "temp_audio.mp3")
out_path = os.path.join(temp_dir, f"static_vid_{int(time.time())}.mp4")
with open(img_path, "wb") as f: f.write(image_file.getvalue())
with open(audio_path, "wb") as f: f.write(audio_file.getvalue())
if resolution_mode == "1080p (Full HD)": vf = "scale=-2:1080"
elif resolution_mode == "720p (HD)": vf = "scale=-2:720"
elif resolution_mode == "Vuông 1:1": vf = "scale=1080:1080:force_original_aspect_ratio=decrease,pad=1080:1080:(ow-iw)/2:(oh-ih)/2"
elif resolution_mode == "Dọc 9:16": vf = "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2"
else: vf = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
cmd = [
ffmpeg_exe, "-y", "-loop", "1", "-i", img_path, "-i", audio_path,
"-c:v", "libx264", "-tune", "stillimage", "-c:a", "aac", "-b:a", "192k",
"-vf", vf, "-pix_fmt", "yuv420p", "-shortest", "-preset", "ultrafast", out_path
]
subprocess.run(cmd, check=True)
return out_path
def generate_preview_multi(video_path, sub_configs_list, font_dir=None):
out_img = os.path.join(tempfile.gettempdir(), f"prev_{int(time.time())}.jpg")
filter_chain = []
for conf in sub_configs_list:
p = get_linux_path(conf['path'])
s = conf['style']
if font_dir:
filter_chain.append(f"subtitles='{p}':fontsdir='{get_linux_path(font_dir)}':force_style='{s}'")
else:
filter_chain.append(f"subtitles='{p}':force_style='{s}'")
full_filter = ",".join(filter_chain)
cmd = [
ffmpeg_exe, "-y", "-ss", "00:00:05", "-i", video_path,
"-vf", full_filter, "-vframes", "1", "-q:v", "5", out_img
]
subprocess.run(cmd, capture_output=True)
return out_img
# --- 4. GIAO DIỆN CHÍNH ---
st.title("🎬 Subtitles Pro (Fixed Colors)")
col_sidebar, col_main = st.columns([1, 2.5])
with col_sidebar:
st.header("1. Nguồn Video")
source_type = st.radio("Chọn nguồn:", ["Upload từ máy", "Link URL", "Tạo từ Ảnh + Nhạc"])
input_ready = False
if source_type == "Upload từ máy":
v_file = st.file_uploader("Chọn file", type=["mp4", "avi", "mov", "mkv"])
if v_file:
t_dir = os.path.join(tempfile.gettempdir(), "uploads")
os.makedirs(t_dir, exist_ok=True)
v_path = os.path.join(t_dir, v_file.name)
with open(v_path, "wb") as f: f.write(v_file.getvalue())
st.session_state.input_video_path = v_path
input_ready = True
elif source_type == "Link URL":
url = st.text_input("Dán link:")
if url and st.button("📥 Tải Video"):
with st.spinner("Đang tải..."):
try:
v_path = download_video_from_url(url)
st.session_state.input_video_path = v_path
input_ready = True
st.success("OK!")
except Exception as e: st.error(f"Lỗi: {e}")
elif st.session_state.input_video_path and "dl_video" in st.session_state.input_video_path:
input_ready = True
elif source_type == "Tạo từ Ảnh + Nhạc":
bg_img = st.file_uploader("1. Ảnh", type=["jpg", "png"])
audio_file = st.file_uploader("2. Nhạc", type=["mp3", "wav"])
res_choice = st.selectbox("Độ phân giải", ["Giữ nguyên", "1080p (Full HD)", "720p (HD)", "Dọc 9:16", "Vuông 1:1"])
if bg_img and audio_file and st.button("🎞️ Tạo Video"):
with st.spinner("Đang tạo..."):
v_path = create_static_video(bg_img, audio_file, res_choice)
st.session_state.input_video_path = v_path
input_ready = True
st.success("OK!")
elif st.session_state.input_video_path and "static_vid" in st.session_state.input_video_path:
input_ready = True
st.divider()
st.header("2. Subtitle & Font")
subtitle_files = st.file_uploader("Upload Sub", type=["srt", "ass"], accept_multiple_files=True)
custom_font_file = st.file_uploader("Upload Font (.ttf)", type=["ttf", "otf"])
font_dir_local = None
if custom_font_file:
f_path, f_dir, f_name = install_custom_font(custom_font_file)
st.session_state.custom_font_name = f_name
font_dir_local = f_dir
st.success(f"Font: {f_name}")
if input_ready and subtitle_files and st.session_state.input_video_path:
video_path = st.session_state.input_video_path
t_dir = os.path.join(tempfile.gettempdir(), "subs")
os.makedirs(t_dir, exist_ok=True)
saved_subs = []
for f in subtitle_files:
path = os.path.join(t_dir, f.name)
with open(path, "wb") as file: file.write(f.getvalue())
saved_subs.append({"name": f.name, "path": path})
with col_main:
c_settings, c_preview = st.columns([1.3, 1.5], gap="medium")
with c_settings:
st.subheader("3. Tùy chỉnh Sub")
tabs = st.tabs([s['name'] for s in saved_subs])
current_configs = []
with st.form(key='main_form'):
for i, tab in enumerate(tabs):
with tab:
font_opts = ["Arial (Mặc định)", "Times New Roman", "Noto Sans CJK SC (Trung)", "Noto Sans CJK KR (Hàn)", "DejaVu Sans (Nga)", "Monospace"]
if st.session_state.custom_font_name: font_opts.insert(0, f"CUSTOM: {st.session_state.custom_font_name}")
c1, c2 = st.columns(2)
with c1: font_choice = st.selectbox(f"Font", font_opts, key=f"fn_{i}")
with c2: f_size = st.number_input(f"Cỡ chữ", 1, 200, 7, key=f"fs_{i}")
col_text, col_style = st.columns(2)
with col_text:
st.markdown('Văn bản', unsafe_allow_html=True)
text_color = st.color_picker(f"Màu chữ", "#FFFFFF", key=f"tc_{i}")
is_bold = st.checkbox("In Đậm", False, key=f"bd_{i}")
is_italic = st.checkbox("In Nghiêng", False, key=f"it_{i}")
with col_style:
st.markdown('Nền & Viền', unsafe_allow_html=True)
# Màu nền: Mặc định Đen
bg_color = st.color_picker(f"Màu Nền (Hộp)", "#000000", key=f"bgc_{i}")
# Opacity: Mặc định 0 (Tắt)
bg_opacity = st.number_input(f"Độ đậm nền %", 0, 100, 0, key=f"bgo_{i}", help="0=Không nền, 100=Nền đặc")
outline_color = st.color_picker(f"Màu Viền", "#000000", key=f"oc_{i}")
outline_width = st.number_input(f"Độ dày viền", 0.0, 20.0, 1.0, step=0.1, key=f"ow_{i}")
st.write("---")
st.markdown('Vị trí', unsafe_allow_html=True)
cp1, cp2 = st.columns(2)
with cp1: v_pos = st.selectbox(f"Vị trí Dọc", ["Dưới cùng", "Trên cùng", "Giữa"], 0, key=f"vp_{i}")
with cp2: align = st.selectbox(f"Căn lề Ngang", ["Giữa", "Trái", "Phải"], key=f"al_{i}")
cm1, cm2 = st.columns(2)
with cm1: marg_v = st.number_input(f"Cách lề Dọc", 0, 1000, 10, key=f"mv_{i}")
with cm2: marg_h = st.number_input(f"Cách lề Ngang", 0, 1000, 10, key=f"mh_{i}")
# --- LOGIC XỬ LÝ STYLE (ĐÃ SỬA LỖI MÀU) ---
final_font = "sans-serif"
if "CUSTOM:" in font_choice: final_font = st.session_state.custom_font_name
elif "Times" in font_choice: final_font = "serif"
elif "Trung" in font_choice: final_font = "Noto Sans CJK SC"
elif "Hàn" in font_choice: final_font = "Noto Sans CJK KR"
elif "Nga" in font_choice: final_font = "DejaVu Sans"
a_code = 2
if v_pos == "Dưới cùng": a_code = 1 if align=="Trái" else (3 if align=="Phải" else 2)
elif v_pos == "Trên cùng": a_code = 5 if align=="Trái" else (7 if align=="Phải" else 6)
else: a_code = 9 if align=="Trái" else (11 if align=="Phải" else 10)
# LOGIC QUAN TRỌNG:
# Nếu Opacity > 0 -> Dùng BorderStyle=3 (Hộp).
# Lúc này BackColour sẽ là màu của Hộp Nền.
if bg_opacity > 0:
border_style = 3
back_colour_ass = hex_to_ass_color(bg_color, bg_opacity)
else:
# Nếu Opacity = 0 -> Dùng BorderStyle=1 (Chữ thường).
border_style = 1
# BackColour lúc này là màu bóng đổ (Shadow), ta để trong suốt để ẩn bóng đi
back_colour_ass = "&HFF000000"
# OutlineColour luôn là màu viền chữ, độ đậm luôn là 100% (Solid)
outline_colour_ass = hex_to_ass_color(outline_color, 100)
ass_style = (
f"FontName={final_font},FontSize={f_size},"
f"Bold={'-1' if is_bold else '0'},Italic={'-1' if is_italic else '0'},"
f"PrimaryColour={hex_to_ass_color(text_color, 100)},"
f"OutlineColour={outline_colour_ass},"
f"BackColour={back_colour_ass},"
f"Outline={outline_width},BorderStyle={border_style},"
f"Alignment={a_code},MarginV={marg_v},MarginL={marg_h},MarginR={marg_h},"
f"Shadow=0" # Tắt bóng đổ mặc định để tránh nhiễu
)
current_configs.append({"path": saved_subs[i]['path'], "style": ass_style})
st.write("")
submit_btn = st.form_submit_button("📸 CẬP NHẬT PREVIEW", type="primary")
if submit_btn:
st.session_state.sub_configs = current_configs
st.toast("Đang tạo preview...", icon="⏳")
f_dir = font_dir_local if st.session_state.custom_font_name else None
new_img = generate_preview_multi(video_path, current_configs, f_dir)
st.session_state.preview_img_path = new_img
st.toast("Đã xong!", icon="✅")
with c_preview:
st.subheader("4. Xem trước")
with st.container(height=650, border=True):
if st.session_state.preview_img_path and os.path.exists(st.session_state.preview_img_path):
st.image(st.session_state.preview_img_path, use_container_width=True)
else:
st.info("👈 Bấm Cập nhật để xem")
st.divider()
if st.button("🚀 XUẤT VIDEO (FULL)", type="secondary", use_container_width=True):
if st.session_state.sub_configs:
gc.collect()
out_path = os.path.join(tempfile.gettempdir(), f"final_{int(time.time())}.mp4")
with st.status("Đang Render...", expanded=True) as status:
filter_chain = []
f_dir = font_dir_local if st.session_state.custom_font_name else None
for conf in st.session_state.sub_configs:
p = get_linux_path(conf['path'])
s = conf['style']
if f_dir:
filter_chain.append(f"subtitles='{p}':fontsdir='{get_linux_path(f_dir)}':force_style='{s}'")
else:
filter_chain.append(f"subtitles='{p}':force_style='{s}'")
full_filter = ",".join(filter_chain)
cmd = [
ffmpeg_exe, "-y", "-i", video_path,
"-vf", full_filter, "-c:a", "copy", "-c:v", "libx264",
"-preset", "ultrafast", "-crf", "28", out_path
]
res = subprocess.run(cmd, capture_output=True)
if res.returncode == 0:
status.update(label="Xong!", state="complete", expanded=False)
st.session_state.final_video_path = out_path
else:
status.update(label="Lỗi!", state="error")
st.error(f"Render lỗi: {res.stderr}")
else:
st.error("Chưa cấu hình sub!")
if st.session_state.final_video_path and os.path.exists(st.session_state.final_video_path):
c1, c2 = st.columns([2, 1])
with c1: st.video(st.session_state.final_video_path)
with c2:
with open(st.session_state.final_video_path, "rb") as f:
st.download_button("⬇️ Tải về", f, "video_final.mp4", "video/mp4", type="primary")
else:
st.info("👈 Chọn nguồn Video để bắt đầu.")