ViralCutterPRO / webui /subtitle_handler.py
RafaG's picture
Upload 3 files
d0d86a9 verified
import os
import re
import subprocess
import gradio as gr
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
WORKING_DIR = os.path.dirname(CURRENT_DIR) # ViralCutter root
import sys
sys.path.append(WORKING_DIR)
from i18n.i18n import I18nAuto
i18n = I18nAuto()
# Subtitle Presets
SUBTITLE_PRESETS = {
"MrBeast Clean Hook": {
"font_name": "Montserrat-ExtraBold",
"font_size": 32,
"base_color": "#FFFFFF",
"highlight_color": "#FFD700",
"outline_color": "#000000",
"outline_thickness": 3,
"shadow_color": "#000000",
"shadow_size": 2,
"bold": True,
"italic": False,
"uppercase": True,
"highlight_size": 38,
"words_per_block": 3,
"gap_limit": 0.25,
"mode": "highlight",
"underline": False,
"strikeout": False,
"border_style": 1,
"vertical_position": 180,
"alignment": 2,
"remove_punctuation": True
},
"Hormozi (Classic)": {
"font_name": "Montserrat-ExtraBold",
"font_size": 30,
"base_color": "#FFFFFF",
"highlight_color": "#00FF00",
"outline_color": "#000000",
"outline_thickness": 3,
"shadow_color": "#000000",
"shadow_size": 0,
"bold": True,
"italic": False,
"uppercase": True,
"highlight_size": 35,
"words_per_block": 2,
"gap_limit": 0.5,
"mode": "highlight",
"underline": False,
"strikeout": False,
"border_style": 1,
"vertical_position": 200,
"alignment": 2,
"remove_punctuation": True
},
"Beasty (Loud)": {
"font_name": "Arial",
"font_size": 34,
"base_color": "#FFFFFF",
"highlight_color": "#FF0000",
"outline_color": "#000000",
"outline_thickness": 3,
"shadow_color": "#000000",
"shadow_size": 3,
"bold": True,
"italic": False,
"uppercase": True,
"highlight_size": 40,
"words_per_block": 3,
"gap_limit": 0.4,
"mode": "highlight",
"underline": False,
"strikeout": False,
"border_style": 1,
"vertical_position": 190,
"alignment": 2,
"remove_punctuation": True
},
"Word Killer (TikTok)": {
"font_name": "Impact",
"font_size": 38,
"base_color": "#FF0000",
"highlight_color": "#FF0000",
"outline_color": "#000000",
"outline_thickness": 3,
"shadow_color": "#000000",
"shadow_size": 3,
"bold": True,
"italic": False,
"uppercase": True,
"highlight_size": 45,
"words_per_block": 1,
"gap_limit": 0.2,
"mode": "word_by_word",
"underline": False,
"strikeout": False,
"border_style": 1,
"vertical_position": 210,
"alignment": 2,
"remove_punctuation": True
},
"Rapid Fire (Sprint)": {
"font_name": "Impact",
"font_size": 36,
"base_color": "#FFFF00",
"highlight_color": "#FFFF00",
"outline_color": "#000000",
"outline_thickness": 2,
"shadow_color": "#000000",
"shadow_size": 2,
"bold": True,
"italic": True,
"uppercase": True,
"highlight_size": 42,
"words_per_block": 1,
"gap_limit": 0.3,
"mode": "word_by_word",
"underline": False,
"strikeout": False,
"border_style": 1,
"vertical_position": 210,
"alignment": 2,
"remove_punctuation": True
},
"Educational Fast": {
"font_name": "Roboto-Bold",
"font_size": 28,
"base_color": "#FFFFFF",
"highlight_color": "#00BFFF",
"outline_color": "#000000",
"outline_thickness": 2,
"shadow_color": "#000000",
"shadow_size": 1,
"bold": True,
"italic": False,
"uppercase": False,
"highlight_size": 34,
"words_per_block": 3,
"gap_limit": 0.45,
"mode": "highlight",
"underline": False,
"strikeout": False,
"border_style": 1,
"vertical_position": 220,
"alignment": 2,
"remove_punctuation": False
},
"Podcast Viral (Centered)": {
"font_name": "Arial",
"font_size": 26,
"base_color": "#FFFFFF",
"highlight_color": "#00FFAA",
"outline_color": "#000000",
"outline_thickness": 2,
"shadow_color": "#000000",
"shadow_size": 1,
"bold": True,
"italic": False,
"uppercase": False,
"highlight_size": 30,
"words_per_block": 4,
"gap_limit": 0.55,
"mode": "highlight",
"underline": False,
"strikeout": False,
"border_style": 1,
"vertical_position": 240,
"alignment": 2,
"remove_punctuation": True
},
"Drama Emocional": {
"font_name": "Arial",
"font_size": 28,
"base_color": "#EAEAEA",
"highlight_color": "#FF5555",
"outline_color": "#000000",
"outline_thickness": 2,
"shadow_color": "#000000",
"shadow_size": 2,
"bold": True,
"italic": False,
"uppercase": False,
"highlight_size": 34,
"words_per_block": 2,
"gap_limit": 0.6,
"mode": "highlight",
"underline": False,
"strikeout": False,
"border_style": 1,
"vertical_position": 235,
"alignment": 2,
"remove_punctuation": True
},
"Story Subtitle (Netflix Style)": {
"font_name": "Arial",
"font_size": 24,
"base_color": "#FFFFFF",
"highlight_color": "#FFFFFF",
"outline_color": "#000000",
"outline_thickness": 0,
"shadow_color": "#000000",
"shadow_size": 1,
"bold": True,
"italic": False,
"uppercase": False,
"highlight_size": 24,
"words_per_block": 7,
"gap_limit": 0.7,
"mode": "no_highlight",
"underline": False,
"strikeout": False,
"border_style": 3,
"vertical_position": 250,
"alignment": 2,
"remove_punctuation": False
},
"Neon Cyber": {
"font_name": "Arial",
"font_size": 30,
"base_color": "#FF00FF",
"highlight_color": "#00FFFF",
"outline_color": "#FFFFFF",
"outline_thickness": 1,
"shadow_color": "#000000",
"shadow_size": 3,
"bold": True,
"italic": False,
"uppercase": True,
"highlight_size": 36,
"words_per_block": 2,
"gap_limit": 0.5,
"mode": "highlight",
"underline": True,
"strikeout": False,
"border_style": 1,
"vertical_position": 205,
"alignment": 2,
"remove_punctuation": True
},
"Retro Pixel": {
"font_name": "Consolas",
"font_size": 26,
"base_color": "#00FF00",
"highlight_color": "#00FF00",
"outline_color": "#000000",
"outline_thickness": 2,
"shadow_color": "#000000",
"shadow_size": 0,
"bold": False,
"italic": False,
"uppercase": True,
"highlight_size": 26,
"words_per_block": 1,
"gap_limit": 0.5,
"mode": "word_by_word",
"underline": False,
"strikeout": False,
"border_style": 3,
"vertical_position": 215,
"alignment": 2
}
}
def generate_preview_html(font, size, color, highlight, outline, outline_thick, shadow, shadow_sz, bold, italic, upper,
h_size, w_block, gap, mode, under, strike, border_s, vert_pos, align, remove_punc):
# Debug inputs
#print(f"DEBUG_HTML: Inputs - Color: {color}, Highlight: {highlight}, Outline: {outline}")
def sanitize_color(c):
if not c: return "#FFFFFF"
clean = c.lstrip('#').strip()
# Handle RGB/RGBA
if clean.lower().startswith("rgb"):
try:
nums = re.findall(r"[\d\.]+", clean)
if len(nums) >= 3:
r = int(float(nums[0]))
g = int(float(nums[1]))
b = int(float(nums[2]))
r = max(0, min(255, r))
g = max(0, min(255, g))
b = max(0, min(255, b))
ret = f"#{r:02X}{g:02X}{b:02X}"
# print(f"DEBUG_HTML: Sanitized {c} -> {ret}")
return ret
except Exception as e:
print(f"DEBUG_HTML: Sanitize Error: {e}")
pass
# Ensure # prefix for standard hex if missing
if not c.startswith("#") and not c.startswith("rgb"):
return f"#{c}"
return c
color = sanitize_color(color)
highlight = sanitize_color(highlight)
outline = sanitize_color(outline)
shadow = sanitize_color(shadow)
#print(f"DEBUG_HTML: Final Colors - Color: {color}, Highlight: {highlight}")
weight = "bold" if bold else "normal"
style = "italic" if italic else "normal"
transform = "uppercase" if upper else "none"
decorations = []
if under: decorations.append("underline")
if strike: decorations.append("line-through")
decoration = " ".join(decorations) if decorations else "none"
# Force larger preview size regardless of input size
# We maintain ratio between highlight and base
base_preview_px = 40
ratio = 1.0
if size > 0:
ratio = h_size / size
highlight_preview_px = base_preview_px * ratio
# Avoid extreme ratios in preview
if highlight_preview_px > base_preview_px * 2: highlight_preview_px = base_preview_px * 2
# Border Style 3 is Opaque Box usually in ASS, here we can simulate background
bg_style = "background-color: rgba(0,0,0,0.6); padding: 5px 10px; border-radius: 4px;" if border_s == 3 else ""
# Handle Content based on Mode
# Handle Content based on Mode
content_html = ""
preview_word = i18n("PREVIEW")
if mode == "word_by_word":
# Only show the active word
content_html = f'<span style="font-size: {highlight_preview_px}px; color: {highlight}; -webkit-text-stroke: {outline_thick}px {outline};">{preview_word}</span>'
elif mode == "no_highlight":
# No highlight difference
span_html = f'<span style="font-size: {base_preview_px}px; color: {color}; -webkit-text-stroke: {outline_thick}px {outline};">{preview_word}</span>'
content_html = i18n("This is a {} of your subtitles").format(span_html)
else:
# Default Highlight mode
span_html = f'<span style="font-size: {highlight_preview_px}px; color: {highlight}; -webkit-text-stroke: {outline_thick}px {outline};">{preview_word}</span>'
content_html = i18n("This is a {} of your subtitles").format(span_html)
html = f"""
<div style="
background-color: #222;
background-image: linear-gradient(45deg, #2a2a2a 25%, transparent 25%, transparent 75%, #2a2a2a 75%, #2a2a2a),
linear-gradient(45deg, #2a2a2a 25%, transparent 25%, transparent 75%, #2a2a2a 75%, #2a2a2a);
background-size: 20px 20px;
background-position: 0 0, 10px 10px;
padding: 40px;
border-radius: 8px;
text-align: center;
font-family: '{font}', sans-serif;
margin-bottom: 10px;
border: 1px solid #444;
display: flex;
justify-content: center;
align-items: center;
min-height: 120px;
">
<span style="
font-size: {base_preview_px}px;
color: {color};
font-weight: {weight};
font-style: {style};
text-transform: {transform};
text-decoration: {decoration};
-webkit-text-stroke: {outline_thick}px {outline};
text-shadow: {shadow_sz}px {shadow_sz}px 0 {shadow};
{bg_style}
line-height: 1.2;
">
{content_html}
</span>
</div>
"""
return html
def apply_preset(preset):
if preset in SUBTITLE_PRESETS:
p = SUBTITLE_PRESETS[preset]
return (
p["font_name"], p["font_size"], p["base_color"], p["highlight_color"],
p["outline_color"], p["outline_thickness"], p["shadow_color"],
p["shadow_size"], p["bold"], p["italic"], p["uppercase"],
p["highlight_size"], p["words_per_block"], p["gap_limit"], p["mode"],
p["underline"], p["strikeout"], p["border_style"],
p.get("vertical_position", 210), p.get("alignment", 2),
p.get("remove_punctuation", True)
)
return (gr.skip(),) * 21
import scripts.adjust_subtitles as adjust
def render_preview_video(font, size, color, highlight, outline, outline_thick, shadow, shadow_sz, bold, italic, upper,
h_size, w_block, gap, mode, under, strike, border_s, vert_pos, align, remove_punc):
# Helper to convert HEX to ASS color &HBBGGRR&
def hex_to_ass(h):
try:
with open("debug_preview.log", "a") as f:
f.write(f"PREVIEW INPUT: '{h}'\n")
except: pass
if not h: return "&H00FFFFFF&"
hex_clean = h.lstrip('#').strip()
# Handle rgb/rgba
if hex_clean.lower().startswith("rgb"):
try:
# regex to capture numbers including floats
nums = re.findall(r"[\d\.]+", hex_clean)
if len(nums) >= 3:
r = int(float(nums[0]))
g = int(float(nums[1]))
b = int(float(nums[2]))
# Clamp just in case
r = max(0, min(255, r))
g = max(0, min(255, g))
b = max(0, min(255, b))
return f"&H00{b:02X}{g:02X}{r:02X}&".upper()
except: pass
if len(hex_clean) == 3:
hex_clean = "".join([c*2 for c in hex_clean])
if len(hex_clean) == 6:
return f"&H00{hex_clean[4:6]}{hex_clean[2:4]}{hex_clean[0:2]}&".upper()
return "&H00FFFFFF&"
base_c = hex_to_ass(color)
high_c = hex_to_ass(highlight)
out_c = hex_to_ass(outline)
shad_c = hex_to_ass(shadow)
# Paths
preview_dir = os.path.join(CURRENT_DIR, "PREVIEW")
os.makedirs(preview_dir, exist_ok=True)
json_template = os.path.join(CURRENT_DIR, "preview.json")
if not os.path.exists(json_template):
print(f"Error: {json_template} not found.")
return None
ass_path = os.path.join(preview_dir, "preview.ass")
out_vid_path = os.path.join(preview_dir, "preview_render.mp4")
# Prepare ASS Bool Values (-1=True, 0=False)
bold_val = "-1" if bold else "0"
italic_val = "-1" if italic else "0"
under_val = "-1" if under else "0"
strike_val = "-1" if strike else "0"
try:
# Generate ASS from JSON using the shared script logic
# this ensures consistency with the actual video generation
adjust.generate_ass_from_file(
input_path=json_template,
output_path=ass_path,
project_folder=preview_dir, # Dummy folder
base_color=base_c,
base_size=size,
highlight_size=h_size,
highlight_color=high_c,
words_per_block=int(w_block),
gap_limit=gap,
mode=mode,
vertical_position=vert_pos,
alignment=align,
font=font,
outline_color=out_c,
shadow_color=shad_c,
bold=bold_val,
italic=italic_val,
underline=under_val,
strikeout=strike_val,
border_style=border_s,
outline_thickness=outline_thick,
shadow_size=shadow_sz,
uppercase=upper,
face_modes={},
remove_punctuation=remove_punc
)
# Prepare safe path for ffmpeg filter: escape windows backslashes and colon
safe_ass_path = ass_path.replace('\\', '/').replace(':', '\\:')
# Render with ffmpeg
# Background color #333333 to match UI roughly.
# Resolution 480x854 (9:16)
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-i", "color=c=0x333333:s=480x854:d=2.4",
"-vf", f"ass='{safe_ass_path}'",
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-an",
out_vid_path
]
subprocess.run(cmd, cwd=WORKING_DIR, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if os.path.exists(out_vid_path):
import shutil
# Create a timestamped copy to force browser cache refresh
import time
timestamp = int(time.time())
cache_bust_path = os.path.join(preview_dir, f"preview_render_{timestamp}.mp4")
shutil.copy(out_vid_path, cache_bust_path)
# Clean old files
try:
for f in os.listdir(preview_dir):
if f.startswith("preview_render_") and f.endswith(".mp4") and f != os.path.basename(cache_bust_path):
try:
os.remove(os.path.join(preview_dir, f))
except: pass
except: pass
return gr.update(value=cache_bust_path, autoplay=True)
except Exception as e:
print(f"Preview Gen Error: {e}")
import traceback
traceback.print_exc()
return None