diff --git "a/webui/app.py" "b/webui/app.py"
--- "a/webui/app.py"
+++ "b/webui/app.py"
@@ -1,927 +1,927 @@
-import gradio as gr
-import subprocess
-import os
-import sys
-import json
-import psutil
-import shutil
-import datetime
-import time
-import urllib.parse
-from fastapi import FastAPI
-from fastapi.staticfiles import StaticFiles
-import uvicorn
-
-
-import re
-import library # Module for Library Logic
-import subtitle_handler as subs # Module for Subtitles
-import subtitle_editor as editor # Module for Editor Logic
-
-# Path to the main script
-MAIN_SCRIPT_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "main_improved.py")
-WORKING_DIR = os.path.dirname(MAIN_SCRIPT_PATH)
-sys.path.append(WORKING_DIR)
-
-from i18n.i18n import I18nAuto
-i18n = I18nAuto()
-
-# --- PRESETS DEFINITIONS ---
-FACE_PRESETS = {
- "Default (Balanced)": {"thresh": 0.35, "two_face": 0.60, "conf": 0.40, "dead_zone": 150},
- "Stable (Focus Main)": {"thresh": 0.60, "two_face": 0.80, "conf": 0.60, "dead_zone": 200},
- "Sensitive (Catch All)": {"thresh": 0.10, "two_face": 0.40, "conf": 0.30, "dead_zone": 100},
- "High Precision": {"thresh": 0.40, "two_face": 0.65, "conf": 0.75, "dead_zone": 150},
-}
-
-EXPERIMENTAL_PRESETS = {
- "Default (Off)": {"focus": False, "mar": 0.03, "score": 1.5, "motion": False, "motion_th": 3.0, "motion_sens": 0.05, "decay": 2.0},
- "Active Speaker (Balanced)": {"focus": True, "mar": 0.03, "score": 1.5, "motion": True, "motion_th": 3.0, "motion_sens": 0.05, "decay": 2.0},
- "Active Speaker (Sensitive)": {"focus": True, "mar": 0.02, "score": 1.0, "motion": True, "motion_th": 2.0, "motion_sens": 0.10, "decay": 1.0},
- "Active Speaker (Stable)": {"focus": True, "mar": 0.05, "score": 2.5, "motion": False, "motion_th": 5.0, "motion_sens": 0.02, "decay": 3.0},
-}
-# ---------------------------
-
-VIRALS_DIR = os.path.join(WORKING_DIR, "VIRALS")
-MODELS_DIR = os.path.join(WORKING_DIR, "models")
-
-# Ensure directories exist
-if not os.path.exists(VIRALS_DIR):
- os.makedirs(VIRALS_DIR, exist_ok=True)
-if not os.path.exists(MODELS_DIR):
- os.makedirs(MODELS_DIR, exist_ok=True)
-
-# Global variables
-current_process = None
-
-# Helpers
-def convert_color_to_ass(hex_color, alpha="00"):
- try:
- with open("debug_colors.log", "a") as f:
- f.write(f"INPUT: '{hex_color}'\n")
- except: pass
-
- if not hex_color:
- return f"&H{alpha}FFFFFF&"
-
- hex_clean = hex_color.lstrip('#').strip()
-
- # Handle rgb/rgba format: rgb(255, 215, 0)
- if hex_clean.lower().startswith("rgb"):
- try:
- # Extract 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
- r = max(0, min(255, r))
- g = max(0, min(255, g))
- b = max(0, min(255, b))
- # Convert to hex
- ret = f"&H{alpha}{b:02X}{g:02X}{r:02X}&".upper()
- try:
- with open("debug_colors.log", "a") as f:
- f.write(f"PARSED RGB: {ret}\n")
- except: pass
- return ret
- except Exception as e:
- try:
- with open("debug_colors.log", "a") as f:
- f.write(f"RGB ERROR: {e}\n")
- except: pass
-
- # Handle 3-digit hex (e.g. F00 -> FF0000)
- if len(hex_clean) == 3:
- hex_clean = "".join([c*2 for c in hex_clean])
-
- if len(hex_clean) == 6:
- r = hex_clean[0:2]
- g = hex_clean[2:4]
- b = hex_clean[4:6]
- # Uppercase just in case
- ret = f"&H{alpha}{b}{g}{r}&".upper()
- try:
- with open("debug_colors.log", "a") as f:
- f.write(f"PARSED HEX: {ret}\n")
- except: pass
- return ret
-
- try:
- with open("debug_colors.log", "a") as f:
- f.write(f"INVALID: Defaulting to White\n")
- except: pass
- return f"&H{alpha}FFFFFF&"
-
-def kill_process():
- global current_process
- if current_process:
- try:
- parent = psutil.Process(current_process.pid)
- for child in parent.children(recursive=True):
- child.kill()
- parent.kill()
- current_process = None
- return i18n("Process terminated.")
- except Exception as e:
- return i18n("Error terminating process: {}").format(e)
- return i18n("No process running.")
-
-GEMINI_MODELS = [
- 'gemini-3-pro-preview',
- 'gemini-2.5-flash',
- 'gemini-2.5-flash-preview-09-2025',
- 'gemini-2.5-flash-lite',
- 'gemini-2.5-flash-lite-preview-09-2025',
- 'gemini-2.5-pro',
- 'gemini-2.0-flash',
- 'gemini-2.0-flash-lite'
-]
-
-G4F_MODELS = [
- 'gpt-4o',
- 'gpt-4o-mini',
- 'gpt-4',
- 'o1-mini',
- 'o1',
- 'deepseek-r1',
- 'deepseek-v3',
- 'llama-3.3-70b',
- 'llama-3.1-405b',
- 'claude-3.5-sonnet',
- 'claude-3.7-sonnet',
- 'gemini-2.0-flash',
- 'qwen-2.5-72b'
-]
-
-def get_local_models():
- if not os.path.exists(MODELS_DIR): return []
- return [f for f in os.listdir(MODELS_DIR) if f.endswith(".gguf")]
-
-
-
-def apply_face_preset(preset_name):
- if preset_name not in FACE_PRESETS:
- return [gr.update() for _ in range(4)] # No change
-
- p = FACE_PRESETS[preset_name]
- return p["thresh"], p["two_face"], p["conf"], p["dead_zone"]
-
-def apply_experimental_preset(preset_name):
- if preset_name not in EXPERIMENTAL_PRESETS:
- return [gr.update() for _ in range(7)] # No change
-
- p = EXPERIMENTAL_PRESETS[preset_name]
- return p["focus"], p["mar"], p["score"], p["motion"], p["motion_th"], p["motion_sens"], p["decay"]
-
-# Subtitle logic moved to subtitle_handler.py
-
-
-def run_viral_cutter(input_source, project_name, url, video_file, segments, viral, themes, min_duration, max_duration, model, ai_backend, api_key, ai_model_name, chunk_size, workflow, face_model, face_mode, face_detect_interval, no_face_mode,
- face_filter_thresh, face_two_thresh, face_conf_thresh, face_dead_zone, focus_active_speaker, active_speaker_mar, active_speaker_score_diff, include_motion, active_speaker_motion_threshold, active_speaker_motion_sensitivity, active_speaker_decay,
- use_custom_subs, font_name, font_size, font_color, highlight_color, outline_color, outline_thickness, shadow_color, shadow_size, is_bold, is_italic, is_uppercase, vertical_pos, alignment,
- h_size, w_block, gap, mode, under, strike, border_s, remove_punc, video_quality, use_youtube_subs, translate_target):
-
- global current_process
- yield "", gr.update(value=i18n("Running..."), interactive=False), gr.update(visible=True), None
-
- cmd = [sys.executable, MAIN_SCRIPT_PATH]
-
- # Input Source Logic
- if input_source == "Existing Project":
- if not project_name:
- yield i18n("Error: No project selected."), gr.update(value=i18n("Start Processing"), interactive=True), gr.update(visible=False), None
- return
- full_project_path = os.path.join(VIRALS_DIR, project_name)
- cmd.extend(["--project-path", full_project_path])
- elif input_source == "Upload Video":
- if not video_file:
- yield i18n("Error: No video file uploaded."), gr.update(value=i18n("Start Processing"), interactive=True), gr.update(visible=False), None
- return
-
- # Determine project name from filename
- original_filename = os.path.basename(video_file)
- name_no_ext = os.path.splitext(original_filename)[0]
- # Sanitize: Allow alphanumeric, space, dash, underscore
- safe_name = "".join([c for c in name_no_ext if c.isalnum() or c in " _-"]).strip()
- if not safe_name: safe_name = "Untitled_Upload"
-
- # Always append timestamp as requested
- timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
- project_name_upload = f"{safe_name}_{timestamp}"
- project_path = os.path.join(VIRALS_DIR, project_name_upload)
-
- os.makedirs(project_path, exist_ok=True)
-
- target_path = os.path.join(project_path, "input.mp4")
- shutil.copy(video_file, target_path)
-
- cmd.extend(["--project-path", project_path])
- # Skip YouTube subs as it is a local upload
- cmd.append("--skip-youtube-subs")
-
- else:
- if url: cmd.extend(["--url", url])
- # Pass Video Quality
- if video_quality: cmd.extend(["--video-quality", video_quality])
- # Pass Subtitle Option (if False, we skip)
- if not use_youtube_subs: cmd.append("--skip-youtube-subs")
-
- # Translation
- if translate_target and translate_target != "None":
- cmd.extend(["--translate-target", translate_target])
-
-
- cmd.extend(["--segments", str(int(segments))])
- if viral: cmd.append("--viral")
- if themes: cmd.extend(["--themes", themes])
- cmd.extend(["--min-duration", str(int(min_duration))])
- cmd.extend(["--max-duration", str(int(max_duration))])
- cmd.extend(["--model", model])
- cmd.extend(["--ai-backend", ai_backend])
- if api_key: cmd.extend(["--api-key", api_key])
-
- # New AI Params
- if ai_model_name: cmd.extend(["--ai-model-name", str(ai_model_name)])
- if chunk_size: cmd.extend(["--chunk-size", str(int(chunk_size))])
-
- workflow_map = {"Full": "1", "Cut Only": "2", "Subtitles Only": "3"}
- cmd.extend(["--workflow", workflow_map.get(workflow, "1")])
- cmd.extend(["--face-model", face_model])
- cmd.extend(["--face-mode", face_mode])
- if face_detect_interval: cmd.extend(["--face-detect-interval", str(face_detect_interval)])
- if no_face_mode: cmd.extend(["--no-face-mode", no_face_mode])
-
- # New Face Params
- if face_filter_thresh is not None: cmd.extend(["--face-filter-threshold", str(face_filter_thresh)])
- if face_two_thresh is not None: cmd.extend(["--face-two-threshold", str(face_two_thresh)])
- if face_conf_thresh is not None: cmd.extend(["--face-confidence-threshold", str(face_conf_thresh)])
- if face_dead_zone is not None: cmd.extend(["--face-dead-zone", str(face_dead_zone)])
-
-
-
- cmd.append("--skip-prompts")
-
- if focus_active_speaker:
- cmd.append("--focus-active-speaker")
- if active_speaker_mar is not None: cmd.extend(["--active-speaker-mar", str(active_speaker_mar)])
- if active_speaker_score_diff is not None: cmd.extend(["--active-speaker-score-diff", str(active_speaker_score_diff)])
- if include_motion: cmd.append("--include-motion")
- if active_speaker_motion_threshold is not None: cmd.extend(["--active-speaker-motion-threshold", str(active_speaker_motion_threshold)])
- if active_speaker_motion_sensitivity is not None: cmd.extend(["--active-speaker-motion-sensitivity", str(active_speaker_motion_sensitivity)])
- if active_speaker_decay is not None: cmd.extend(["--active-speaker-decay", str(active_speaker_decay)])
-
- cmd.append("--skip-prompts") # Always skip prompts in WebUI to prevent freezing
-
- if use_custom_subs:
- subtitle_config = {
- "font": font_name, "base_size": int(font_size), "base_color": convert_color_to_ass(font_color), "highlight_color": convert_color_to_ass(highlight_color),
- "outline_color": convert_color_to_ass(outline_color), "outline_thickness": outline_thickness, "shadow_color": convert_color_to_ass(shadow_color),
- "shadow_size": shadow_size, "vertical_position": vertical_pos, "alignment": alignment, "bold": 1 if is_bold else 0, "italic": 1 if is_italic else 0,
- "underline": 1 if under else 0, "strikeout": 1 if strike else 0, "border_style": border_s, "words_per_block": int(w_block), "gap_limit": gap,
- "mode": mode, "highlight_size": int(h_size), "remove_punctuation": remove_punc
- }
- # Uppercase is handled in main script or logic?
- # Actually subtitle_config doesn't seem to natively support "uppercase" in get_subtitle_config default, but app.py was using it.
- # I should probably add it back if I want to support it, but user said "PROHIBITED to remove existing ones".
- # I'll re-add 'uppercase': 1 if is_uppercase else 0 to the dict if the backend supports it, otherwise it's just ignored.
- # But wait, main_improved.py doesn't have 'uppercase' in get_subtitle_config.
- # I'll keep it in the dict just in case logic uses it elsewhere or if I missed it.
- # Actually, standard ASS doesn't support uppercase flag directly in Style, it needs to be text transform.
- # But I'll leave it in the dict.
- subtitle_config["uppercase"] = 1 if is_uppercase else 0
-
- subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
- try:
- with open(subtitle_config_path, "w", encoding="utf-8") as f:
- json.dump(subtitle_config, f, indent=4)
- cmd.extend(["--subtitle-config", subtitle_config_path])
- except Exception: pass
-
- env = os.environ.copy()
- env["PYTHONUNBUFFERED"] = "1"
- try:
- current_process = subprocess.Popen(cmd, cwd=WORKING_DIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True, env=env)
- logs = ""
- project_folder_path = None
- if input_source == "Existing Project" and project_name:
- # If using existing project, we already know the path, but let's see if logs confirm it
- project_folder_path = os.path.join(VIRALS_DIR, project_name)
-
- last_update_time = time.time()
-
- while True:
- line = current_process.stdout.readline()
- if not line and current_process.poll() is not None:
- break
-
- if line:
- logs += line
- if "Project Folder:" in line:
- parts = line.split("Project Folder:")
- if len(parts) > 1: project_folder_path = parts[1].strip()
-
- # Throttle updates to avoid browser freeze (0.2s interval)
- current_time = time.time()
- if current_time - last_update_time > 0.2:
- yield logs, gr.update(visible=True, interactive=False), gr.update(visible=True), None
- last_update_time = current_time
-
- # Final yield to ensure all logs are shown
- yield logs, gr.update(visible=True, interactive=False), gr.update(visible=True), None
- except Exception as e:
- logs += f"\nError running process: {str(e)}\n"
- yield logs, gr.update(visible=True, interactive=False), gr.update(visible=True), None
- finally:
- if current_process:
- if current_process.stdout:
- try:
- current_process.stdout.close()
- except Exception: pass
- if current_process.poll() is None:
- # If we are here, it means we finished reading or errored out, but process is still running.
- # If it was a normal break from loop, process should be done or close to done.
- # If we are stopping, current_process.terminate() might be needed outside?
- # But here we just wait.
- try:
- current_process.wait()
- except Exception: pass
- current_process = None
-
- # Wait to ensure filesystem flush
- time.sleep(1.0)
-
- html_output = ""
- if project_folder_path and os.path.exists(project_folder_path):
- html_output = library.generate_project_gallery(project_folder_path, is_full_path=True)
- else:
- html_output = f"
{i18n('Error: Project folder could not be determined from logs.')}
"
- yield logs, gr.update(value=i18n("Start Processing"), interactive=True), gr.update(visible=False), html_output
-
-css = """
-/* Global Dark Theme Overrides */
-body, .gradio-container {
- background-color: #0b0b0b !important;
- color: #ffffff !important;
-}
-
-/* Force dark background for specific inputs that might be white */
-input[type="password"], textarea, select {
- background-color: #1f1f1f !important;
- color: #ffffff !important;
- border: 1px solid #333 !important;
-}
-
-/* Hide Footer */
-footer {visibility: hidden}
-
-/* Container Width */
-.gradio-container {
- max-width: 98% !important;
- width: 98% !important;
- margin: 0 auto !important;
-}
-"""
-
-import header
-
-with gr.Blocks(title=i18n("ViralCutter WebUI"), theme=gr.themes.Default(primary_hue="orange", neutral_hue="slate"), css=css) as demo:
- gr.Markdown(header.badges)
- gr.Markdown(header.description)
- with gr.Tabs():
- with gr.Tab(i18n("Create New")):
- with gr.Row():
- with gr.Column(scale=1):
- input_source = gr.Radio([(i18n("YouTube URL"), "YouTube URL"), (i18n("Existing Project"), "Existing Project"), (i18n("Upload Video"), "Upload Video")], label=i18n("Input Source"), value="YouTube URL")
-
- url_input = gr.Textbox(label=i18n("YouTube URL"), placeholder="https://www.youtube.com/watch?v=...", visible=True)
- video_upload = gr.File(label=i18n("Upload Video"), file_count="single", file_types=["video"], visible=False)
-
- with gr.Row():
- video_quality_input = gr.Dropdown(choices=["best", "1080p", "720p", "480p"], label=i18n("Video Quality"), value="best")
- translate_input = gr.Dropdown(choices=["None", "pt", "en", "es", "fr", "de", "it", "ru", "ja", "ko", "zh-CN"], label=i18n("Translate Subtitles To"), value="None")
- use_youtube_subs_input = gr.Checkbox(label=i18n("Use YouTube Subs"), value=True, info=i18n("Download and use official subtitles if available. (Recommended, it speeds up the process)"))
-
- project_selector = gr.Dropdown(choices=[], label=i18n("Select Project"), visible=False)
-
- def on_source_change(source):
- if source == "YouTube URL":
- return gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(value="Full")
- elif source == "Upload Video":
- return gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value="Full")
- else:
- # Load projects
- projs = library.get_existing_projects()
- return gr.update(visible=False), gr.update(choices=projs, visible=True), gr.update(visible=False), gr.update(value="Subtitles Only")
-
-
- with gr.Row():
- segments_input = gr.Number(label=i18n("Segments"), value=3, precision=0)
- viral_input = gr.Checkbox(label=i18n("Viral Mode"), value=True)
- themes_input = gr.Textbox(label=i18n("Themes"), placeholder=i18n("funny, sad..."), visible=False)
- viral_input.change(lambda x: gr.update(visible=not x), viral_input, themes_input)
- with gr.Row():
- min_dur_input = gr.Number(label=i18n("Min Duration (s)"), value=15)
- max_dur_input = gr.Number(label=i18n("Max Duration (s)"), value=90)
- with gr.Column(scale=1):
- with gr.Row():
- ai_backend_input = gr.Dropdown(choices=[(i18n("Gemini"), "gemini"), (i18n("G4F"), "g4f"), (i18n("Local (GGUF)"), "local"), (i18n("Manual"), "manual")], label=i18n("AI Backend"), value="gemini", scale=2)
- api_key_input = gr.Textbox(label=i18n("Gemini API Key"), type="password", scale=3)
-
- # New Dynamic Inputs
- with gr.Row():
- ai_model_input = gr.Dropdown(choices=GEMINI_MODELS, label=i18n("AI Model"), value=GEMINI_MODELS[1], allow_custom_value=True, visible=True, scale=5)
- refresh_models_btn = gr.Button("🔄", size="sm", visible=False, scale=0, min_width=50) # Only local
- chunk_size_input = gr.Number(label=i18n("Chunk Size"), value=70000, precision=0, scale=2)
-
- # Update listeners with logic to hide/show API key
- def update_ai_ui(backend):
- show_api = (backend == "gemini")
- show_refresh = (backend == "local")
-
- # Definições padrão para evitar que fiquem vazios
- new_choices = []
- new_val = ""
- new_chunk = 70000
-
- if backend == "gemini":
- new_choices = GEMINI_MODELS
- new_val = GEMINI_MODELS[1]
- new_chunk = 70000
- elif backend == "g4f":
- new_choices = G4F_MODELS
- new_val = G4F_MODELS[5]
- new_chunk = 70000
- elif backend == "local":
- models = get_local_models()
- new_choices = models if models else [i18n("No models found")]
- new_val = new_choices[0]
- new_chunk = 30000
- else: # Manual
- pass
-
- return (
- gr.update(visible=show_api), # API Key Visibility (Fixes hole 1)
- gr.update(choices=new_choices, value=new_val, visible=(backend != "manual")), # Model Dropdown
- gr.update(visible=show_refresh), # Refresh Button
- gr.update(value=new_chunk) # Chunk Size
- )
-
- def refresh_local_models():
- models = get_local_models()
- val = models[0] if models else i18n("No models found")
- return gr.update(choices=models, value=val)
-
- refresh_models_btn.click(refresh_local_models, outputs=ai_model_input)
- ai_backend_input.change(update_ai_ui, inputs=ai_backend_input, outputs=[api_key_input, ai_model_input, refresh_models_btn, chunk_size_input])
-
- model_input = gr.Dropdown(["tiny", "small", "medium", "large", "large-v1", "large-v2", "large-v3", "turbo", "large-v3-turbo", "distil-large-v2", "distil-medium.en", "distil-small.en", "distil-large-v3"], label=i18n("Whisper Model"), value="large-v3-turbo")
- with gr.Row():
- workflow_input = gr.Dropdown(choices=[(i18n("Full"), "Full"), (i18n("Cut Only"), "Cut Only"), (i18n("Subtitles Only"), "Subtitles Only")], label=i18n("Workflow"), value="Full")
- face_model_input = gr.Dropdown(["insightface", "mediapipe"], label=i18n("Face Model"), value="insightface")
- with gr.Row():
- face_mode_input = gr.Dropdown(choices=[(i18n("Auto"), "auto"), ("1", "1"), ("2", "2")], label=i18n("Face Mode"), value="auto")
- face_detect_interval_input = gr.Textbox(label=i18n("Face Det. Interval"), value="0.17,1.0")
- no_face_mode_input = gr.Dropdown(choices=[(i18n("Padding (9:16)"), "padding"), (i18n("Zoom (Center)"), "zoom")], label=i18n("No Face Fallback"), value="zoom")
-
-
- # Update listeners now that all components are defined
- input_source.change(on_source_change, inputs=input_source, outputs=[url_input, project_selector, video_upload, workflow_input])
-
- with gr.Accordion(i18n("Advanced Face Settings"), open=False):
- face_preset_input = gr.Dropdown(choices=[(i18n(k), k) for k in FACE_PRESETS.keys()], label=i18n("Configuration Presets"), value="Default (Balanced)", interactive=True)
- with gr.Row():
- face_filter_thresh_input = gr.Slider(label=i18n("Ignore Small Faces (0.0 - 1.0)"), minimum=0.0, maximum=1.0, value=0.35, step=0.05, info=i18n("Relative size to ignore background."))
- face_two_thresh_input = gr.Slider(label=i18n("Threshold for 2 Faces (0.0 - 1.0)"), minimum=0.0, maximum=1.0, value=0.60, step=0.05, info=i18n("Size of 2nd face to activate split mode."))
- face_conf_thresh_input = gr.Slider(label=i18n("Minimum Confidence (0.0 - 1.0)"), minimum=0.0, maximum=1.0, value=0.40, step=0.05, info=i18n("Ignore detections with low confidence."))
- face_dead_zone_input = gr.Slider(label=i18n("Dead Zone (Stabilization)"), minimum=0, maximum=200, value=150, step=5, info=i18n("Movement pixels to ignore."))
-
- face_preset_input.change(apply_face_preset, inputs=face_preset_input, outputs=[face_filter_thresh_input, face_two_thresh_input, face_conf_thresh_input, face_dead_zone_input])
-
- with gr.Accordion(i18n("Experimental: Active Speaker & Motion"), open=False):
- experimental_preset_input = gr.Dropdown(choices=[(i18n(k), k) for k in EXPERIMENTAL_PRESETS.keys()], label=i18n("Configuration Presets"), value="Default (Off)", interactive=True)
- focus_active_speaker_input = gr.Checkbox(label=i18n("Experimental: Focus on Speaker"), value=False, info=i18n("Tries to focus only on the speaking person instead of split screen."))
- with gr.Row():
- active_speaker_mar_input = gr.Slider(label=i18n("MAR Threshold (Mouth Open)"), minimum=0.01, maximum=0.20, value=0.03, step=0.005, info=i18n("Mouth open sensitivity."))
- active_speaker_score_diff_input = gr.Slider(label=i18n("Score Difference"), minimum=0.5, maximum=10.0, value=1.5, step=0.5, info=i18n("Minimum difference to focus on 1 face."))
-
- with gr.Row():
- include_motion_input = gr.Checkbox(label=i18n("Consider Motion"), value=False, info=i18n("Increases score with motion (gestures)."))
-
- with gr.Row():
- active_speaker_motion_threshold_input = gr.Slider(label=i18n("Motion Dead Zone"), minimum=0.0, maximum=20.0, value=3.0, step=0.5, info=i18n("Pixels ignored."))
- active_speaker_motion_sensitivity_input = gr.Slider(label=i18n("Motion Sensitivity"), minimum=0.01, maximum=0.5, value=0.05, step=0.01, info=i18n("Points per pixel."))
- active_speaker_decay_input = gr.Slider(label=i18n("Switch Speed"), minimum=0.5, maximum=5.0, value=2.0, step=0.5, info=i18n("Speed to lose focus."))
-
- experimental_preset_input.change(apply_experimental_preset, inputs=experimental_preset_input, outputs=[focus_active_speaker_input, active_speaker_mar_input, active_speaker_score_diff_input, include_motion_input, active_speaker_motion_threshold_input, active_speaker_motion_sensitivity_input, active_speaker_decay_input])
- with gr.Accordion(i18n("Subtitle Settings (alpha)"), open=False):
- preset_input = gr.Dropdown(choices=[(i18n("Manual"), "Manual")] + [(i18n(k), k) for k in subs.SUBTITLE_PRESETS.keys()], label=i18n("Quick Presets"), value="Hormozi (Classic)")
- use_custom_subs = gr.Checkbox(label=i18n("Enable Subtitle Customization (Includes Preset)"), value=True)
-
- # Previews (Always Visible)
- preview_html = gr.HTML(value=f"{i18n('Select options or preset to preview')}
")
-
- with gr.Row():
- preview_vid_btn = gr.Button(i18n("🎬 Render Animated Preview (Slow)"), size="sm")
- preview_vid = gr.Video(label=i18n("Animated Preview"), height=300, autoplay=True, interactive=False)
-
- with gr.Accordion(i18n("Advanced Settings"), open=False):
- gr.Markdown(f"### {i18n('Appearance')}")
- with gr.Row():
- font_name_input = gr.Textbox(label=i18n("Font Name"), value="Montserrat-Regular")
- font_size_input = gr.Slider(label=i18n("Font Size (Base)"), minimum=8, maximum=80, value=12)
- highlight_size_input = gr.Slider(label=i18n("Highlight Size"), minimum=8, maximum=80, value=14)
-
- with gr.Row():
- font_color_input = gr.ColorPicker(label=i18n("Base Color"), value="#FFFFFF")
- highlight_color_input = gr.ColorPicker(label=i18n("Highlight Color"), value="#00FF00")
- outline_color_input = gr.ColorPicker(label=i18n("Outline Color"), value="#000000")
- shadow_color_input = gr.ColorPicker(label=i18n("Shadow Color"), value="#000000")
-
- gr.Markdown(f"### {i18n('Styling & Effects')}")
- with gr.Row():
- outline_thickness_input = gr.Slider(label=i18n("Outline Thickness"), minimum=0, maximum=10, value=1.5)
- shadow_size_input = gr.Slider(label=i18n("Shadow Size"), minimum=0, maximum=10, value=2)
- border_style_input = gr.Dropdown(choices=[(i18n("Outline"), 1), (i18n("Opaque Box"), 3)], label=i18n("Border Style"), value=1)
-
- with gr.Row():
- bold_input = gr.Checkbox(label=i18n("Bold"))
- italic_input = gr.Checkbox(label=i18n("Italic"))
- uppercase_input = gr.Checkbox(label=i18n("Uppercase"))
- remove_punc_input = gr.Checkbox(label=i18n("Remove Punctuation"), value=True)
- underline_input = gr.Checkbox(label=i18n("Underline"))
- strikeout_input = gr.Checkbox(label=i18n("Strikeout"))
-
- gr.Markdown(f"### {i18n('Positioning & Layout')}")
- with gr.Row():
- vertical_pos_input = gr.Slider(label=i18n("V-Pos (Margin V)"), minimum=0, maximum=500, value=210)
- alignment_input = gr.Dropdown(choices=[(i18n("Left"), 1), (i18n("Center"), 2), (i18n("Right"), 3)], label=i18n("Alignment"), value=2)
- gap_limit_input = gr.Slider(label=i18n("Gap Limit"), minimum=0.0, maximum=5.0, value=0.5, step=0.1)
- mode_input = gr.Dropdown(choices=[(i18n("Highlight"), "highlight"), (i18n("Word by Word"), "word_by_word"), (i18n("No Highlight"), "no_highlight")], label=i18n("Mode"), value="highlight")
- words_per_block_input = gr.Slider(label=i18n("Words per Block"), minimum=1, maximum=20, value=3, step=1)
-
- manual_inputs = [
- font_name_input, font_size_input, font_color_input, highlight_color_input,
- outline_color_input, outline_thickness_input, shadow_color_input, shadow_size_input,
- bold_input, italic_input, uppercase_input,
- highlight_size_input, words_per_block_input, gap_limit_input, mode_input,
- underline_input, strikeout_input, border_style_input,
- vertical_pos_input, alignment_input,
- remove_punc_input
- ]
-
- # Update manual inputs when preset changes
- preset_input.change(subs.apply_preset, inputs=[preset_input], outputs=manual_inputs)
-
- # Auto-update PREVIEW HTML on any change
- for inp in manual_inputs:
- inp.change(subs.generate_preview_html, inputs=manual_inputs, outputs=preview_html)
-
- # Render video button
- preview_vid_btn.click(
- subs.render_preview_video,
- inputs=manual_inputs,
- outputs=preview_vid
- )
-
- # Initial load
- demo.load(subs.generate_preview_html, inputs=manual_inputs, outputs=preview_html)
- demo.load(subs.apply_preset, inputs=[preset_input], outputs=manual_inputs) # Apply default preset on load
-
- with gr.Row():
- start_btn = gr.Button(i18n("Start Processing"), variant="primary")
- stop_btn = gr.Button(i18n("Stop"), variant="stop", visible=False)
- stop_btn.click(kill_process, outputs=[])
- logs_output = gr.Textbox(label=i18n("Logs"), lines=10, autoscroll=True, elem_id="logs_output")
-
- # Force scroll to bottom via JS
- logs_output.change(fn=None, inputs=[], outputs=[], js="""
- function() {
- var ta = document.querySelector('#logs_output textarea');
- if(ta) {
- // Setup scroll listener once to track user intent
- if (!ta._scrollerSetup) {
- ta._isSticky = true; // Default to sticky
- ta.addEventListener('scroll', function() {
- var diff = ta.scrollHeight - ta.scrollTop - ta.clientHeight;
- // If near bottom (<50px), enable sticky. Else disable.
- if (diff <= 50) {
- ta._isSticky = true;
- } else {
- ta._isSticky = false;
- }
- });
- ta._scrollerSetup = true;
- }
-
- // Apply scroll only if sticky
- if(ta._isSticky === undefined || ta._isSticky === true) {
- ta.scrollTop = ta.scrollHeight;
- }
- }
- }
- """)
- results_html = gr.HTML(label=i18n("Results"))
-
- # MUST pass all all new inputs to the run function
- start_btn.click(run_viral_cutter, inputs=[
- input_source, project_selector, url_input, video_upload, segments_input, viral_input, themes_input, min_dur_input, max_dur_input,
- model_input, ai_backend_input, api_key_input, ai_model_input, chunk_size_input,
- workflow_input, face_model_input, face_mode_input, face_detect_interval_input, no_face_mode_input,
- face_filter_thresh_input, face_two_thresh_input, face_conf_thresh_input, face_dead_zone_input, focus_active_speaker_input,
- active_speaker_mar_input, active_speaker_score_diff_input, include_motion_input, active_speaker_motion_threshold_input, active_speaker_motion_sensitivity_input, active_speaker_decay_input,
- use_custom_subs,
- # Expanded Manual Inputs mapping
- font_name_input, font_size_input, font_color_input, highlight_color_input,
- outline_color_input, outline_thickness_input, shadow_color_input, shadow_size_input,
- bold_input, italic_input, uppercase_input, vertical_pos_input, alignment_input,
- # New Inputs
- highlight_size_input, words_per_block_input, gap_limit_input, mode_input,
- underline_input, strikeout_input, border_style_input, remove_punc_input,
- video_quality_input, use_youtube_subs_input, translate_input
- ], outputs=[logs_output, start_btn, stop_btn, results_html])
-
-
- with gr.Tab(i18n("Subtitle Editor")):
- gr.Markdown(f"### {i18n('Edit Subtitles (Smart Mode)')}")
-
- with gr.Group():
- editor_project_dropdown = gr.Dropdown(choices=library.get_existing_projects(), label=i18n("Select Project"), value=None)
- editor_refresh_btn = gr.Button(i18n("Refresh"), size="sm")
-
- with gr.Group():
- editor_file_dropdown = gr.Dropdown(choices=[], label=i18n("Select Subtitle File"), interactive=True)
- editor_load_btn = gr.Button(i18n("Load Subtitles"), variant="secondary")
-
- # Hidden state to store full path of currently loaded JSON
- current_json_path = gr.State()
-
- # The Dataframe Editor
- # Headers: Start, End, Text
- subtitle_dataframe = gr.Dataframe(
- headers=["Start", "End", "Text"],
- datatype=["str", "str", "str"],
- col_count=(3, "fixed"),
- interactive=True,
- label=i18n("Subtitle Segments"),
- wrap=True
- )
-
- with gr.Row():
- editor_save_btn = gr.Button(i18n("💾 Save Changes"), variant="primary")
- editor_render_single_btn = gr.Button(i18n("âš¡ Render This Segment (Very-Fast)"), variant="secondary")
- editor_render_all_btn = gr.Button(i18n("🎬 Render All (Fast)"), variant="stop")
-
- editor_status = gr.Textbox(label=i18n("Status"), interactive=False)
-
- # --- Callbacks for Editor ---
- editor_refresh_btn.click(library.refresh_projects, outputs=editor_project_dropdown)
-
- def update_file_list(proj_name):
- if not proj_name: return gr.update(choices=[])
- proj_path = os.path.join(VIRALS_DIR, proj_name)
- files = editor.list_editable_files(proj_path)
- return gr.update(choices=files, value=files[0] if files else None)
-
- editor_project_dropdown.change(update_file_list, inputs=editor_project_dropdown, outputs=editor_file_dropdown)
-
- def load_subs(proj_name, file_name):
- if not proj_name or not file_name:
- return [], None, i18n("Please select project and file.")
-
- full_path = os.path.join(VIRALS_DIR, proj_name, 'subs', file_name)
- data = editor.load_transcription_for_editor(full_path)
- return data, full_path, i18n("Loaded {} segments.").format(len(data))
-
- editor_load_btn.click(load_subs, inputs=[editor_project_dropdown, editor_file_dropdown], outputs=[subtitle_dataframe, current_json_path, editor_status])
-
- def save_subs(json_path, df):
- if not json_path: return i18n("No file loaded.")
- data_list = df.values.tolist() if hasattr(df, 'values') else df
- msg = editor.save_editor_changes(json_path, data_list)
- return msg
-
- editor_save_btn.click(save_subs, inputs=[current_json_path, subtitle_dataframe], outputs=editor_status)
-
- def render_single(json_path, use_custom, font_name, font_size, font_color, highlight_color,
- outline_color, outline_thickness, shadow_color, shadow_size,
- is_bold, is_italic, is_uppercase,
- h_size, w_block, gap, mode, under, strike, border_s,
- vertical_pos, alignment, remove_punc):
-
- if not json_path: return i18n("No file loaded.")
-
- subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
-
- # Save config if custom subs enabled
- if use_custom:
- subtitle_config = {
- "font": font_name, "base_size": int(font_size),
- "base_color": convert_color_to_ass(font_color),
- "highlight_color": convert_color_to_ass(highlight_color),
- "outline_color": convert_color_to_ass(outline_color),
- "outline_thickness": outline_thickness,
- "shadow_color": convert_color_to_ass(shadow_color),
- "shadow_size": shadow_size, "vertical_position": vertical_pos,
- "alignment": alignment, "bold": 1 if is_bold else 0,
- "italic": 1 if is_italic else 0,
- "underline": 1 if under else 0, "strikeout": 1 if strike else 0,
- "border_style": border_s, "words_per_block": int(w_block),
- "gap_limit": gap, "mode": mode, "highlight_size": int(h_size),
- "uppercase": 1 if is_uppercase else 0,
- "remove_punctuation": remove_punc
- }
- try:
- with open(subtitle_config_path, "w", encoding="utf-8") as f:
- json.dump(subtitle_config, f, indent=4)
- except Exception: pass
- else:
- # Remove temp config if it exists to ensure defaults are used
- try:
- if os.path.exists(subtitle_config_path):
- os.remove(subtitle_config_path)
- except Exception: pass
-
- # We expect user to SAVE first, but we could auto-save.
- # For now assume saved.
- msg = editor.render_specific_video(json_path)
- return msg
-
- editor_render_single_btn.click(
- render_single,
- inputs=[current_json_path, use_custom_subs] + manual_inputs,
- outputs=editor_status
- )
-
- def render_all(proj_name, use_custom, font_name, font_size, font_color, highlight_color,
- outline_color, outline_thickness, shadow_color, shadow_size,
- is_bold, is_italic, is_uppercase,
- h_size, w_block, gap, mode, under, strike, border_s,
- vertical_pos, alignment, remove_punc):
- if not proj_name: return i18n("No project selected.")
-
- # Save config
- if use_custom:
- subtitle_config = {
- "font": font_name, "base_size": int(font_size),
- "base_color": convert_color_to_ass(font_color),
- "highlight_color": convert_color_to_ass(highlight_color),
- "outline_color": convert_color_to_ass(outline_color),
- "outline_thickness": outline_thickness,
- "shadow_color": convert_color_to_ass(shadow_color),
- "shadow_size": shadow_size, "vertical_position": vertical_pos,
- "alignment": alignment, "bold": 1 if is_bold else 0,
- "italic": 1 if is_italic else 0,
- "underline": 1 if under else 0, "strikeout": 1 if strike else 0,
- "border_style": border_s, "words_per_block": int(w_block),
- "gap_limit": gap, "mode": mode, "highlight_size": int(h_size),
- "uppercase": 1 if is_uppercase else 0,
- "remove_punctuation": remove_punc
- }
- subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
- try:
- with open(subtitle_config_path, "w", encoding="utf-8") as f:
- json.dump(subtitle_config, f, indent=4)
- except Exception: pass
-
- proj_path = os.path.join(VIRALS_DIR, proj_name)
-
- # IMPORTANT: Pass the config file path to the command
- subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
- cmd = [sys.executable, MAIN_SCRIPT_PATH, "--project-path", proj_path, "--workflow", "3", "--skip-prompts"]
-
- if use_custom and os.path.exists(subtitle_config_path):
- cmd.extend(["--subtitle-config", subtitle_config_path])
-
- try:
- subprocess.Popen(cmd, cwd=WORKING_DIR)
- return i18n("Render All started in background... Check terminal/logs.")
- except Exception as e:
- return i18n("Error starting render: {}").format(e)
-
- editor_render_all_btn.click(
- render_all,
- inputs=[editor_project_dropdown, use_custom_subs] + manual_inputs,
- outputs=editor_status
- )
-
-
- with gr.Tab(i18n("Library")):
- gr.Markdown(f"### {i18n('Existing Projects')}")
- with gr.Row():
- project_dropdown = gr.Dropdown(choices=library.get_existing_projects(), label=i18n("Select Project"), value=None)
- refresh_btn = gr.Button(i18n("Refresh List"))
- project_gallery_html = gr.HTML()
- refresh_btn.click(library.refresh_projects, outputs=project_dropdown)
- def on_select_project(proj_name): return library.generate_project_gallery(proj_name)
- project_dropdown.change(on_select_project, project_dropdown, project_gallery_html)
-
- gr.Markdown(f"""
-
-
-
- {i18n('Desenvolvido por Rafael Godoy')}
-
- {i18n('Apoie o projeto, qualquer valor é bem-vindo:')}
- {i18n('Apoiar via PIX')}
-
- {i18n('100% local • open source • no subscription required')}
-
-
- """)
-if __name__ == "__main__":
- import webbrowser
- import threading
- import time
- import argparse
-
- parser = argparse.ArgumentParser()
- parser.add_argument("--colab", action="store_true", help="Run in Google Colab mode")
- args = parser.parse_args()
-
- if args.colab:
- print("Running in Colab mode. Generating public link with Static Mounts...")
- library.set_url_mode("fastapi")
-
- # Broaden allowed paths for Colab
- allowed_dirs = [VIRALS_DIR, WORKING_DIR, os.getcwd(), "."]
-
- # Explicitly set static paths
- try:
- gr.set_static_paths(paths=allowed_dirs)
- print(f"DEBUG: Registered static paths: {allowed_dirs}")
- except AttributeError:
- print("DEBUG: gr.set_static_paths not available")
-
- print(f"DEBUG: Allowed paths for Gradio: {allowed_dirs}")
-
- # Launch with prevent_thread_lock to allow mounting
- app, local_url, share_url = demo.queue().launch(
- share=True,
- allowed_paths=allowed_dirs,
- prevent_thread_lock=True
- )
-
- # Mount the VIRALS directory explicitly
- app.mount("/virals", StaticFiles(directory=VIRALS_DIR), name="virals")
- print(f"Mounted /virals to {VIRALS_DIR}")
-
- demo.block_thread()
- else:
- # Check environment
- is_windows = (os.name == 'nt')
-
- library.set_url_mode("fastapi")
- allowed_dirs = [VIRALS_DIR, WORKING_DIR, os.getcwd(), "."]
- try:
- gr.set_static_paths(paths=allowed_dirs)
- except AttributeError: pass
-
- from fastapi.responses import FileResponse
- from fastapi import BackgroundTasks
-
- # Helper to attach routes to any FastAPI app (whether created by Gradio or us)
- def attach_extra_routes(fastapi_app):
- fastapi_app.mount("/virals", StaticFiles(directory=VIRALS_DIR), name="virals")
-
- @fastapi_app.get("/export_xml_api")
- def export_xml_api(project: str, segment: int, background_tasks: BackgroundTasks, format: str = "premiere"):
- try:
- project_path = os.path.join(VIRALS_DIR, project)
- script_path = os.path.join(WORKING_DIR, "scripts", "export_xml.py")
- cmd = [sys.executable, script_path, "--project", project_path, "--segment", str(segment), "--format", format]
- subprocess.run(cmd, check=True)
- proj_name = os.path.basename(project_path)
- zip_filename = f"export_{proj_name}_seg{segment}.zip"
- file_path = os.path.join(project_path, zip_filename)
- if os.path.exists(file_path):
- return FileResponse(file_path, filename=zip_filename, media_type='application/zip')
- else:
- return {"error": f"File generation failed. Expected: {file_path}"}
- except Exception as e:
- return {"error": str(e)}
-
- print(f"Mounted /virals to {VIRALS_DIR}")
-
- if is_windows:
- print("Running in Windows environment (using Gradio launch for convenience).")
- # Windows: Use demo.launch() for convenience (auto-browser, etc)
- app, local_url, share_url = demo.queue().launch(
- share=False,
- allowed_paths=allowed_dirs,
- inbrowser=True,
- server_name="0.0.0.0",
- server_port=7860,
- prevent_thread_lock=True
- )
- attach_extra_routes(app)
- demo.block_thread()
- else:
- print("Running in Linux/Container environment (using Uvicorn for stability).")
- # Linux/HF: Use Uvicorn for explicit loop control
- app = FastAPI()
- attach_extra_routes(app)
- # Disable SSR to prevent Node proxying issues on HF Spaces
- app = gr.mount_gradio_app(app, demo.queue(), path="/", allowed_paths=allowed_dirs, ssr_mode=False)
- uvicorn.run(app, host="0.0.0.0", port=7860)
+import gradio as gr
+import subprocess
+import os
+import sys
+import json
+import psutil
+import shutil
+import datetime
+import time
+import urllib.parse
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+import uvicorn
+
+
+import re
+import library # Module for Library Logic
+import subtitle_handler as subs # Module for Subtitles
+import subtitle_editor as editor # Module for Editor Logic
+
+# Path to the main script
+MAIN_SCRIPT_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "main_improved.py")
+WORKING_DIR = os.path.dirname(MAIN_SCRIPT_PATH)
+sys.path.append(WORKING_DIR)
+
+from i18n.i18n import I18nAuto
+i18n = I18nAuto()
+
+# --- PRESETS DEFINITIONS ---
+FACE_PRESETS = {
+ "Default (Balanced)": {"thresh": 0.35, "two_face": 0.60, "conf": 0.40, "dead_zone": 150},
+ "Stable (Focus Main)": {"thresh": 0.60, "two_face": 0.80, "conf": 0.60, "dead_zone": 200},
+ "Sensitive (Catch All)": {"thresh": 0.10, "two_face": 0.40, "conf": 0.30, "dead_zone": 100},
+ "High Precision": {"thresh": 0.40, "two_face": 0.65, "conf": 0.75, "dead_zone": 150},
+}
+
+EXPERIMENTAL_PRESETS = {
+ "Default (Off)": {"focus": False, "mar": 0.03, "score": 1.5, "motion": False, "motion_th": 3.0, "motion_sens": 0.05, "decay": 2.0},
+ "Active Speaker (Balanced)": {"focus": True, "mar": 0.03, "score": 1.5, "motion": True, "motion_th": 3.0, "motion_sens": 0.05, "decay": 2.0},
+ "Active Speaker (Sensitive)": {"focus": True, "mar": 0.02, "score": 1.0, "motion": True, "motion_th": 2.0, "motion_sens": 0.10, "decay": 1.0},
+ "Active Speaker (Stable)": {"focus": True, "mar": 0.05, "score": 2.5, "motion": False, "motion_th": 5.0, "motion_sens": 0.02, "decay": 3.0},
+}
+# ---------------------------
+
+VIRALS_DIR = os.path.join(WORKING_DIR, "VIRALS")
+MODELS_DIR = os.path.join(WORKING_DIR, "models")
+
+# Ensure directories exist
+if not os.path.exists(VIRALS_DIR):
+ os.makedirs(VIRALS_DIR, exist_ok=True)
+if not os.path.exists(MODELS_DIR):
+ os.makedirs(MODELS_DIR, exist_ok=True)
+
+# Global variables
+current_process = None
+
+# Helpers
+def convert_color_to_ass(hex_color, alpha="00"):
+ try:
+ with open("debug_colors.log", "a") as f:
+ f.write(f"INPUT: '{hex_color}'\n")
+ except: pass
+
+ if not hex_color:
+ return f"&H{alpha}FFFFFF&"
+
+ hex_clean = hex_color.lstrip('#').strip()
+
+ # Handle rgb/rgba format: rgb(255, 215, 0)
+ if hex_clean.lower().startswith("rgb"):
+ try:
+ # Extract 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
+ r = max(0, min(255, r))
+ g = max(0, min(255, g))
+ b = max(0, min(255, b))
+ # Convert to hex
+ ret = f"&H{alpha}{b:02X}{g:02X}{r:02X}&".upper()
+ try:
+ with open("debug_colors.log", "a") as f:
+ f.write(f"PARSED RGB: {ret}\n")
+ except: pass
+ return ret
+ except Exception as e:
+ try:
+ with open("debug_colors.log", "a") as f:
+ f.write(f"RGB ERROR: {e}\n")
+ except: pass
+
+ # Handle 3-digit hex (e.g. F00 -> FF0000)
+ if len(hex_clean) == 3:
+ hex_clean = "".join([c*2 for c in hex_clean])
+
+ if len(hex_clean) == 6:
+ r = hex_clean[0:2]
+ g = hex_clean[2:4]
+ b = hex_clean[4:6]
+ # Uppercase just in case
+ ret = f"&H{alpha}{b}{g}{r}&".upper()
+ try:
+ with open("debug_colors.log", "a") as f:
+ f.write(f"PARSED HEX: {ret}\n")
+ except: pass
+ return ret
+
+ try:
+ with open("debug_colors.log", "a") as f:
+ f.write(f"INVALID: Defaulting to White\n")
+ except: pass
+ return f"&H{alpha}FFFFFF&"
+
+def kill_process():
+ global current_process
+ if current_process:
+ try:
+ parent = psutil.Process(current_process.pid)
+ for child in parent.children(recursive=True):
+ child.kill()
+ parent.kill()
+ current_process = None
+ return i18n("Process terminated.")
+ except Exception as e:
+ return i18n("Error terminating process: {}").format(e)
+ return i18n("No process running.")
+
+GEMINI_MODELS = [
+ 'gemini-3-pro-preview',
+ 'gemini-2.5-flash',
+ 'gemini-2.5-flash-preview-09-2025',
+ 'gemini-2.5-flash-lite',
+ 'gemini-2.5-flash-lite-preview-09-2025',
+ 'gemini-2.5-pro',
+ 'gemini-2.0-flash',
+ 'gemini-2.0-flash-lite'
+]
+
+G4F_MODELS = [
+ 'gpt-4o',
+ 'gpt-4o-mini',
+ 'gpt-4',
+ 'o1-mini',
+ 'o1',
+ 'deepseek-r1',
+ 'deepseek-v3',
+ 'llama-3.3-70b',
+ 'llama-3.1-405b',
+ 'claude-3.5-sonnet',
+ 'claude-3.7-sonnet',
+ 'gemini-2.0-flash',
+ 'qwen-2.5-72b'
+]
+
+def get_local_models():
+ if not os.path.exists(MODELS_DIR): return []
+ return [f for f in os.listdir(MODELS_DIR) if f.endswith(".gguf")]
+
+
+
+def apply_face_preset(preset_name):
+ if preset_name not in FACE_PRESETS:
+ return [gr.update() for _ in range(4)] # No change
+
+ p = FACE_PRESETS[preset_name]
+ return p["thresh"], p["two_face"], p["conf"], p["dead_zone"]
+
+def apply_experimental_preset(preset_name):
+ if preset_name not in EXPERIMENTAL_PRESETS:
+ return [gr.update() for _ in range(7)] # No change
+
+ p = EXPERIMENTAL_PRESETS[preset_name]
+ return p["focus"], p["mar"], p["score"], p["motion"], p["motion_th"], p["motion_sens"], p["decay"]
+
+# Subtitle logic moved to subtitle_handler.py
+
+
+def run_viral_cutter(input_source, project_name, url, video_file, segments, viral, themes, min_duration, max_duration, model, ai_backend, api_key, ai_model_name, chunk_size, workflow, face_model, face_mode, face_detect_interval, no_face_mode,
+ face_filter_thresh, face_two_thresh, face_conf_thresh, face_dead_zone, focus_active_speaker, active_speaker_mar, active_speaker_score_diff, include_motion, active_speaker_motion_threshold, active_speaker_motion_sensitivity, active_speaker_decay,
+ use_custom_subs, font_name, font_size, font_color, highlight_color, outline_color, outline_thickness, shadow_color, shadow_size, is_bold, is_italic, is_uppercase, vertical_pos, alignment,
+ h_size, w_block, gap, mode, under, strike, border_s, remove_punc, video_quality, use_youtube_subs, translate_target):
+
+ global current_process
+ yield "", gr.update(value=i18n("Running..."), interactive=False), gr.update(visible=True), None
+
+ cmd = [sys.executable, MAIN_SCRIPT_PATH]
+
+ # Input Source Logic
+ if input_source == "Existing Project":
+ if not project_name:
+ yield i18n("Error: No project selected."), gr.update(value=i18n("Start Processing"), interactive=True), gr.update(visible=False), None
+ return
+ full_project_path = os.path.join(VIRALS_DIR, project_name)
+ cmd.extend(["--project-path", full_project_path])
+ elif input_source == "Upload Video":
+ if not video_file:
+ yield i18n("Error: No video file uploaded."), gr.update(value=i18n("Start Processing"), interactive=True), gr.update(visible=False), None
+ return
+
+ # Determine project name from filename
+ original_filename = os.path.basename(video_file)
+ name_no_ext = os.path.splitext(original_filename)[0]
+ # Sanitize: Allow alphanumeric, space, dash, underscore
+ safe_name = "".join([c for c in name_no_ext if c.isalnum() or c in " _-"]).strip()
+ if not safe_name: safe_name = "Untitled_Upload"
+
+ # Always append timestamp as requested
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
+ project_name_upload = f"{safe_name}_{timestamp}"
+ project_path = os.path.join(VIRALS_DIR, project_name_upload)
+
+ os.makedirs(project_path, exist_ok=True)
+
+ target_path = os.path.join(project_path, "input.mp4")
+ shutil.copy(video_file, target_path)
+
+ cmd.extend(["--project-path", project_path])
+ # Skip YouTube subs as it is a local upload
+ cmd.append("--skip-youtube-subs")
+
+ else:
+ if url: cmd.extend(["--url", url])
+ # Pass Video Quality
+ if video_quality: cmd.extend(["--video-quality", video_quality])
+ # Pass Subtitle Option (if False, we skip)
+ if not use_youtube_subs: cmd.append("--skip-youtube-subs")
+
+ # Translation
+ if translate_target and translate_target != "None":
+ cmd.extend(["--translate-target", translate_target])
+
+
+ cmd.extend(["--segments", str(int(segments))])
+ if viral: cmd.append("--viral")
+ if themes: cmd.extend(["--themes", themes])
+ cmd.extend(["--min-duration", str(int(min_duration))])
+ cmd.extend(["--max-duration", str(int(max_duration))])
+ cmd.extend(["--model", model])
+ cmd.extend(["--ai-backend", ai_backend])
+ if api_key: cmd.extend(["--api-key", api_key])
+
+ # New AI Params
+ if ai_model_name: cmd.extend(["--ai-model-name", str(ai_model_name)])
+ if chunk_size: cmd.extend(["--chunk-size", str(int(chunk_size))])
+
+ workflow_map = {"Full": "1", "Cut Only": "2", "Subtitles Only": "3"}
+ cmd.extend(["--workflow", workflow_map.get(workflow, "1")])
+ cmd.extend(["--face-model", face_model])
+ cmd.extend(["--face-mode", face_mode])
+ if face_detect_interval: cmd.extend(["--face-detect-interval", str(face_detect_interval)])
+ if no_face_mode: cmd.extend(["--no-face-mode", no_face_mode])
+
+ # New Face Params
+ if face_filter_thresh is not None: cmd.extend(["--face-filter-threshold", str(face_filter_thresh)])
+ if face_two_thresh is not None: cmd.extend(["--face-two-threshold", str(face_two_thresh)])
+ if face_conf_thresh is not None: cmd.extend(["--face-confidence-threshold", str(face_conf_thresh)])
+ if face_dead_zone is not None: cmd.extend(["--face-dead-zone", str(face_dead_zone)])
+
+
+
+ cmd.append("--skip-prompts")
+
+ if focus_active_speaker:
+ cmd.append("--focus-active-speaker")
+ if active_speaker_mar is not None: cmd.extend(["--active-speaker-mar", str(active_speaker_mar)])
+ if active_speaker_score_diff is not None: cmd.extend(["--active-speaker-score-diff", str(active_speaker_score_diff)])
+ if include_motion: cmd.append("--include-motion")
+ if active_speaker_motion_threshold is not None: cmd.extend(["--active-speaker-motion-threshold", str(active_speaker_motion_threshold)])
+ if active_speaker_motion_sensitivity is not None: cmd.extend(["--active-speaker-motion-sensitivity", str(active_speaker_motion_sensitivity)])
+ if active_speaker_decay is not None: cmd.extend(["--active-speaker-decay", str(active_speaker_decay)])
+
+ cmd.append("--skip-prompts") # Always skip prompts in WebUI to prevent freezing
+
+ if use_custom_subs:
+ subtitle_config = {
+ "font": font_name, "base_size": int(font_size), "base_color": convert_color_to_ass(font_color), "highlight_color": convert_color_to_ass(highlight_color),
+ "outline_color": convert_color_to_ass(outline_color), "outline_thickness": outline_thickness, "shadow_color": convert_color_to_ass(shadow_color),
+ "shadow_size": shadow_size, "vertical_position": vertical_pos, "alignment": alignment, "bold": 1 if is_bold else 0, "italic": 1 if is_italic else 0,
+ "underline": 1 if under else 0, "strikeout": 1 if strike else 0, "border_style": border_s, "words_per_block": int(w_block), "gap_limit": gap,
+ "mode": mode, "highlight_size": int(h_size), "remove_punctuation": remove_punc
+ }
+ # Uppercase is handled in main script or logic?
+ # Actually subtitle_config doesn't seem to natively support "uppercase" in get_subtitle_config default, but app.py was using it.
+ # I should probably add it back if I want to support it, but user said "PROHIBITED to remove existing ones".
+ # I'll re-add 'uppercase': 1 if is_uppercase else 0 to the dict if the backend supports it, otherwise it's just ignored.
+ # But wait, main_improved.py doesn't have 'uppercase' in get_subtitle_config.
+ # I'll keep it in the dict just in case logic uses it elsewhere or if I missed it.
+ # Actually, standard ASS doesn't support uppercase flag directly in Style, it needs to be text transform.
+ # But I'll leave it in the dict.
+ subtitle_config["uppercase"] = 1 if is_uppercase else 0
+
+ subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
+ try:
+ with open(subtitle_config_path, "w", encoding="utf-8") as f:
+ json.dump(subtitle_config, f, indent=4)
+ cmd.extend(["--subtitle-config", subtitle_config_path])
+ except Exception: pass
+
+ env = os.environ.copy()
+ env["PYTHONUNBUFFERED"] = "1"
+ try:
+ current_process = subprocess.Popen(cmd, cwd=WORKING_DIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True, env=env)
+ logs = ""
+ project_folder_path = None
+ if input_source == "Existing Project" and project_name:
+ # If using existing project, we already know the path, but let's see if logs confirm it
+ project_folder_path = os.path.join(VIRALS_DIR, project_name)
+
+ last_update_time = time.time()
+
+ while True:
+ line = current_process.stdout.readline()
+ if not line and current_process.poll() is not None:
+ break
+
+ if line:
+ logs += line
+ if "Project Folder:" in line:
+ parts = line.split("Project Folder:")
+ if len(parts) > 1: project_folder_path = parts[1].strip()
+
+ # Throttle updates to avoid browser freeze (0.2s interval)
+ current_time = time.time()
+ if current_time - last_update_time > 0.2:
+ yield logs, gr.update(visible=True, interactive=False), gr.update(visible=True), None
+ last_update_time = current_time
+
+ # Final yield to ensure all logs are shown
+ yield logs, gr.update(visible=True, interactive=False), gr.update(visible=True), None
+ except Exception as e:
+ logs += f"\nError running process: {str(e)}\n"
+ yield logs, gr.update(visible=True, interactive=False), gr.update(visible=True), None
+ finally:
+ if current_process:
+ if current_process.stdout:
+ try:
+ current_process.stdout.close()
+ except Exception: pass
+ if current_process.poll() is None:
+ # If we are here, it means we finished reading or errored out, but process is still running.
+ # If it was a normal break from loop, process should be done or close to done.
+ # If we are stopping, current_process.terminate() might be needed outside?
+ # But here we just wait.
+ try:
+ current_process.wait()
+ except Exception: pass
+ current_process = None
+
+ # Wait to ensure filesystem flush
+ time.sleep(1.0)
+
+ html_output = ""
+ if project_folder_path and os.path.exists(project_folder_path):
+ html_output = library.generate_project_gallery(project_folder_path, is_full_path=True)
+ else:
+ html_output = f"{i18n('Error: Project folder could not be determined from logs.')}
"
+ yield logs, gr.update(value=i18n("Start Processing"), interactive=True), gr.update(visible=False), html_output
+
+css = """
+/* Global Dark Theme Overrides */
+body, .gradio-container {
+ background-color: #0b0b0b !important;
+ color: #ffffff !important;
+}
+
+/* Force dark background for specific inputs that might be white */
+input[type="password"], textarea, select {
+ background-color: #1f1f1f !important;
+ color: #ffffff !important;
+ border: 1px solid #333 !important;
+}
+
+/* Hide Footer */
+footer {visibility: hidden}
+
+/* Container Width */
+.gradio-container {
+ max-width: 98% !important;
+ width: 98% !important;
+ margin: 0 auto !important;
+}
+"""
+
+import header
+
+with gr.Blocks(title=i18n("ViralCutter WebUI"), theme=gr.themes.Default(primary_hue="orange", neutral_hue="slate"), css=css) as demo:
+ gr.Markdown(header.badges)
+ gr.Markdown(header.description)
+ with gr.Tabs():
+ with gr.Tab(i18n("Create New")):
+ with gr.Row():
+ with gr.Column(scale=1):
+ input_source = gr.Radio([(i18n("YouTube URL (Off HF)"), "YouTube URL"), (i18n("Existing Project"), "Existing Project"), (i18n("Upload Video"), "Upload Video")], label=i18n("Input Source"), value="YouTube URL")
+
+ url_input = gr.Textbox(label=i18n("YouTube URL"), placeholder="https://www.youtube.com/watch?v=...", visible=False)
+ video_upload = gr.File(label=i18n("Upload Video"), file_count="single", file_types=["video"], visible=True)
+
+ with gr.Row():
+ video_quality_input = gr.Dropdown(choices=["best", "1080p", "720p", "480p"], label=i18n("Video Quality"), value="best")
+ translate_input = gr.Dropdown(choices=["None", "pt", "en", "es", "fr", "de", "it", "ru", "ja", "ko", "zh-CN"], label=i18n("Translate Subtitles To"), value="None")
+ use_youtube_subs_input = gr.Checkbox(label=i18n("Use YouTube Subs"), value=True, info=i18n("Download and use official subtitles if available. (Recommended, it speeds up the process)"))
+
+ project_selector = gr.Dropdown(choices=[], label=i18n("Select Project"), visible=False)
+
+ def on_source_change(source):
+ if source == "YouTube URL":
+ return gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(value="Full")
+ elif source == "Upload Video":
+ return gr.update(visible=True), gr.update(visible=False), gr.update(visible=True), gr.update(value="Full")
+ else:
+ # Load projects
+ projs = library.get_existing_projects()
+ return gr.update(visible=False), gr.update(choices=projs, visible=True), gr.update(visible=False), gr.update(value="Subtitles Only")
+
+
+ with gr.Row():
+ segments_input = gr.Number(label=i18n("Segments"), value=3, precision=0)
+ viral_input = gr.Checkbox(label=i18n("Viral Mode"), value=True)
+ themes_input = gr.Textbox(label=i18n("Themes"), placeholder=i18n("funny, sad..."), visible=False)
+ viral_input.change(lambda x: gr.update(visible=not x), viral_input, themes_input)
+ with gr.Row():
+ min_dur_input = gr.Number(label=i18n("Min Duration (s)"), value=15)
+ max_dur_input = gr.Number(label=i18n("Max Duration (s)"), value=90)
+ with gr.Column(scale=1):
+ with gr.Row():
+ ai_backend_input = gr.Dropdown(choices=[(i18n("Gemini"), "gemini"), (i18n("G4F"), "g4f"), (i18n("Local (GGUF)"), "local"), (i18n("Manual"), "manual")], label=i18n("AI Backend"), value="gemini", scale=2)
+ api_key_input = gr.Textbox(label=i18n("Gemini API Key"), type="password", scale=3)
+
+ # New Dynamic Inputs
+ with gr.Row():
+ ai_model_input = gr.Dropdown(choices=GEMINI_MODELS, label=i18n("AI Model"), value=GEMINI_MODELS[1], allow_custom_value=True, visible=True, scale=5)
+ refresh_models_btn = gr.Button("🔄", size="sm", visible=False, scale=0, min_width=50) # Only local
+ chunk_size_input = gr.Number(label=i18n("Chunk Size"), value=70000, precision=0, scale=2)
+
+ # Update listeners with logic to hide/show API key
+ def update_ai_ui(backend):
+ show_api = (backend == "gemini")
+ show_refresh = (backend == "local")
+
+ # Definições padrão para evitar que fiquem vazios
+ new_choices = []
+ new_val = ""
+ new_chunk = 70000
+
+ if backend == "gemini":
+ new_choices = GEMINI_MODELS
+ new_val = GEMINI_MODELS[1]
+ new_chunk = 70000
+ elif backend == "g4f":
+ new_choices = G4F_MODELS
+ new_val = G4F_MODELS[5]
+ new_chunk = 70000
+ elif backend == "local (Off HF)":
+ models = get_local_models()
+ new_choices = models if models else [i18n("No models found")]
+ new_val = new_choices[0]
+ new_chunk = 30000
+ else: # Manual
+ pass
+
+ return (
+ gr.update(visible=show_api), # API Key Visibility (Fixes hole 1)
+ gr.update(choices=new_choices, value=new_val, visible=(backend != "manual")), # Model Dropdown
+ gr.update(visible=show_refresh), # Refresh Button
+ gr.update(value=new_chunk) # Chunk Size
+ )
+
+ def refresh_local_models():
+ models = get_local_models()
+ val = models[0] if models else i18n("No models found")
+ return gr.update(choices=models, value=val)
+
+ refresh_models_btn.click(refresh_local_models, outputs=ai_model_input)
+ ai_backend_input.change(update_ai_ui, inputs=ai_backend_input, outputs=[api_key_input, ai_model_input, refresh_models_btn, chunk_size_input])
+
+ model_input = gr.Dropdown(["tiny", "small", "medium", "large", "large-v1", "large-v2", "large-v3", "turbo", "large-v3-turbo", "distil-large-v2", "distil-medium.en", "distil-small.en", "distil-large-v3"], label=i18n("Whisper Model"), value="small")
+ with gr.Row():
+ workflow_input = gr.Dropdown(choices=[(i18n("Full"), "Full"), (i18n("Cut Only"), "Cut Only"), (i18n("Subtitles Only"), "Subtitles Only")], label=i18n("Workflow"), value="Full")
+ face_model_input = gr.Dropdown(["insightface", "mediapipe"], label=i18n("Face Model"), value="insightface")
+ with gr.Row():
+ face_mode_input = gr.Dropdown(choices=[(i18n("Auto"), "auto"), ("1", "1"), ("2", "2")], label=i18n("Face Mode"), value="auto")
+ face_detect_interval_input = gr.Textbox(label=i18n("Face Det. Interval"), value="0.17,1.0")
+ no_face_mode_input = gr.Dropdown(choices=[(i18n("Padding (9:16)"), "padding"), (i18n("Zoom (Center)"), "zoom")], label=i18n("No Face Fallback"), value="zoom")
+
+
+ # Update listeners now that all components are defined
+ input_source.change(on_source_change, inputs=input_source, outputs=[url_input, project_selector, video_upload, workflow_input])
+
+ with gr.Accordion(i18n("Advanced Face Settings"), open=False):
+ face_preset_input = gr.Dropdown(choices=[(i18n(k), k) for k in FACE_PRESETS.keys()], label=i18n("Configuration Presets"), value="Default (Balanced)", interactive=True)
+ with gr.Row():
+ face_filter_thresh_input = gr.Slider(label=i18n("Ignore Small Faces (0.0 - 1.0)"), minimum=0.0, maximum=1.0, value=0.35, step=0.05, info=i18n("Relative size to ignore background."))
+ face_two_thresh_input = gr.Slider(label=i18n("Threshold for 2 Faces (0.0 - 1.0)"), minimum=0.0, maximum=1.0, value=0.60, step=0.05, info=i18n("Size of 2nd face to activate split mode."))
+ face_conf_thresh_input = gr.Slider(label=i18n("Minimum Confidence (0.0 - 1.0)"), minimum=0.0, maximum=1.0, value=0.40, step=0.05, info=i18n("Ignore detections with low confidence."))
+ face_dead_zone_input = gr.Slider(label=i18n("Dead Zone (Stabilization)"), minimum=0, maximum=200, value=150, step=5, info=i18n("Movement pixels to ignore."))
+
+ face_preset_input.change(apply_face_preset, inputs=face_preset_input, outputs=[face_filter_thresh_input, face_two_thresh_input, face_conf_thresh_input, face_dead_zone_input])
+
+ with gr.Accordion(i18n("Experimental: Active Speaker & Motion"), open=False):
+ experimental_preset_input = gr.Dropdown(choices=[(i18n(k), k) for k in EXPERIMENTAL_PRESETS.keys()], label=i18n("Configuration Presets"), value="Default (Off)", interactive=True)
+ focus_active_speaker_input = gr.Checkbox(label=i18n("Experimental: Focus on Speaker"), value=False, info=i18n("Tries to focus only on the speaking person instead of split screen."))
+ with gr.Row():
+ active_speaker_mar_input = gr.Slider(label=i18n("MAR Threshold (Mouth Open)"), minimum=0.01, maximum=0.20, value=0.03, step=0.005, info=i18n("Mouth open sensitivity."))
+ active_speaker_score_diff_input = gr.Slider(label=i18n("Score Difference"), minimum=0.5, maximum=10.0, value=1.5, step=0.5, info=i18n("Minimum difference to focus on 1 face."))
+
+ with gr.Row():
+ include_motion_input = gr.Checkbox(label=i18n("Consider Motion"), value=False, info=i18n("Increases score with motion (gestures)."))
+
+ with gr.Row():
+ active_speaker_motion_threshold_input = gr.Slider(label=i18n("Motion Dead Zone"), minimum=0.0, maximum=20.0, value=3.0, step=0.5, info=i18n("Pixels ignored."))
+ active_speaker_motion_sensitivity_input = gr.Slider(label=i18n("Motion Sensitivity"), minimum=0.01, maximum=0.5, value=0.05, step=0.01, info=i18n("Points per pixel."))
+ active_speaker_decay_input = gr.Slider(label=i18n("Switch Speed"), minimum=0.5, maximum=5.0, value=2.0, step=0.5, info=i18n("Speed to lose focus."))
+
+ experimental_preset_input.change(apply_experimental_preset, inputs=experimental_preset_input, outputs=[focus_active_speaker_input, active_speaker_mar_input, active_speaker_score_diff_input, include_motion_input, active_speaker_motion_threshold_input, active_speaker_motion_sensitivity_input, active_speaker_decay_input])
+ with gr.Accordion(i18n("Subtitle Settings (alpha)"), open=False):
+ preset_input = gr.Dropdown(choices=[(i18n("Manual"), "Manual")] + [(i18n(k), k) for k in subs.SUBTITLE_PRESETS.keys()], label=i18n("Quick Presets"), value="Hormozi (Classic)")
+ use_custom_subs = gr.Checkbox(label=i18n("Enable Subtitle Customization (Includes Preset)"), value=True)
+
+ # Previews (Always Visible)
+ preview_html = gr.HTML(value=f"{i18n('Select options or preset to preview')}
")
+
+ with gr.Row():
+ preview_vid_btn = gr.Button(i18n("🎬 Render Animated Preview (Slow)"), size="sm")
+ preview_vid = gr.Video(label=i18n("Animated Preview"), height=300, autoplay=True, interactive=False)
+
+ with gr.Accordion(i18n("Advanced Settings"), open=False):
+ gr.Markdown(f"### {i18n('Appearance')}")
+ with gr.Row():
+ font_name_input = gr.Textbox(label=i18n("Font Name"), value="Montserrat-Regular")
+ font_size_input = gr.Slider(label=i18n("Font Size (Base)"), minimum=8, maximum=80, value=12)
+ highlight_size_input = gr.Slider(label=i18n("Highlight Size"), minimum=8, maximum=80, value=14)
+
+ with gr.Row():
+ font_color_input = gr.ColorPicker(label=i18n("Base Color"), value="#FFFFFF")
+ highlight_color_input = gr.ColorPicker(label=i18n("Highlight Color"), value="#00FF00")
+ outline_color_input = gr.ColorPicker(label=i18n("Outline Color"), value="#000000")
+ shadow_color_input = gr.ColorPicker(label=i18n("Shadow Color"), value="#000000")
+
+ gr.Markdown(f"### {i18n('Styling & Effects')}")
+ with gr.Row():
+ outline_thickness_input = gr.Slider(label=i18n("Outline Thickness"), minimum=0, maximum=10, value=1.5)
+ shadow_size_input = gr.Slider(label=i18n("Shadow Size"), minimum=0, maximum=10, value=2)
+ border_style_input = gr.Dropdown(choices=[(i18n("Outline"), 1), (i18n("Opaque Box"), 3)], label=i18n("Border Style"), value=1)
+
+ with gr.Row():
+ bold_input = gr.Checkbox(label=i18n("Bold"))
+ italic_input = gr.Checkbox(label=i18n("Italic"))
+ uppercase_input = gr.Checkbox(label=i18n("Uppercase"))
+ remove_punc_input = gr.Checkbox(label=i18n("Remove Punctuation"), value=True)
+ underline_input = gr.Checkbox(label=i18n("Underline"))
+ strikeout_input = gr.Checkbox(label=i18n("Strikeout"))
+
+ gr.Markdown(f"### {i18n('Positioning & Layout')}")
+ with gr.Row():
+ vertical_pos_input = gr.Slider(label=i18n("V-Pos (Margin V)"), minimum=0, maximum=500, value=210)
+ alignment_input = gr.Dropdown(choices=[(i18n("Left"), 1), (i18n("Center"), 2), (i18n("Right"), 3)], label=i18n("Alignment"), value=2)
+ gap_limit_input = gr.Slider(label=i18n("Gap Limit"), minimum=0.0, maximum=5.0, value=0.5, step=0.1)
+ mode_input = gr.Dropdown(choices=[(i18n("Highlight"), "highlight"), (i18n("Word by Word"), "word_by_word"), (i18n("No Highlight"), "no_highlight")], label=i18n("Mode"), value="highlight")
+ words_per_block_input = gr.Slider(label=i18n("Words per Block"), minimum=1, maximum=20, value=3, step=1)
+
+ manual_inputs = [
+ font_name_input, font_size_input, font_color_input, highlight_color_input,
+ outline_color_input, outline_thickness_input, shadow_color_input, shadow_size_input,
+ bold_input, italic_input, uppercase_input,
+ highlight_size_input, words_per_block_input, gap_limit_input, mode_input,
+ underline_input, strikeout_input, border_style_input,
+ vertical_pos_input, alignment_input,
+ remove_punc_input
+ ]
+
+ # Update manual inputs when preset changes
+ preset_input.change(subs.apply_preset, inputs=[preset_input], outputs=manual_inputs)
+
+ # Auto-update PREVIEW HTML on any change
+ for inp in manual_inputs:
+ inp.change(subs.generate_preview_html, inputs=manual_inputs, outputs=preview_html)
+
+ # Render video button
+ preview_vid_btn.click(
+ subs.render_preview_video,
+ inputs=manual_inputs,
+ outputs=preview_vid
+ )
+
+ # Initial load
+ demo.load(subs.generate_preview_html, inputs=manual_inputs, outputs=preview_html)
+ demo.load(subs.apply_preset, inputs=[preset_input], outputs=manual_inputs) # Apply default preset on load
+
+ with gr.Row():
+ start_btn = gr.Button(i18n("Start Processing"), variant="primary")
+ stop_btn = gr.Button(i18n("Stop"), variant="stop", visible=False)
+ stop_btn.click(kill_process, outputs=[])
+ logs_output = gr.Textbox(label=i18n("Logs"), lines=10, autoscroll=True, elem_id="logs_output")
+
+ # Force scroll to bottom via JS
+ logs_output.change(fn=None, inputs=[], outputs=[], js="""
+ function() {
+ var ta = document.querySelector('#logs_output textarea');
+ if(ta) {
+ // Setup scroll listener once to track user intent
+ if (!ta._scrollerSetup) {
+ ta._isSticky = true; // Default to sticky
+ ta.addEventListener('scroll', function() {
+ var diff = ta.scrollHeight - ta.scrollTop - ta.clientHeight;
+ // If near bottom (<50px), enable sticky. Else disable.
+ if (diff <= 50) {
+ ta._isSticky = true;
+ } else {
+ ta._isSticky = false;
+ }
+ });
+ ta._scrollerSetup = true;
+ }
+
+ // Apply scroll only if sticky
+ if(ta._isSticky === undefined || ta._isSticky === true) {
+ ta.scrollTop = ta.scrollHeight;
+ }
+ }
+ }
+ """)
+ results_html = gr.HTML(label=i18n("Results"))
+
+ # MUST pass all all new inputs to the run function
+ start_btn.click(run_viral_cutter, inputs=[
+ input_source, project_selector, url_input, video_upload, segments_input, viral_input, themes_input, min_dur_input, max_dur_input,
+ model_input, ai_backend_input, api_key_input, ai_model_input, chunk_size_input,
+ workflow_input, face_model_input, face_mode_input, face_detect_interval_input, no_face_mode_input,
+ face_filter_thresh_input, face_two_thresh_input, face_conf_thresh_input, face_dead_zone_input, focus_active_speaker_input,
+ active_speaker_mar_input, active_speaker_score_diff_input, include_motion_input, active_speaker_motion_threshold_input, active_speaker_motion_sensitivity_input, active_speaker_decay_input,
+ use_custom_subs,
+ # Expanded Manual Inputs mapping
+ font_name_input, font_size_input, font_color_input, highlight_color_input,
+ outline_color_input, outline_thickness_input, shadow_color_input, shadow_size_input,
+ bold_input, italic_input, uppercase_input, vertical_pos_input, alignment_input,
+ # New Inputs
+ highlight_size_input, words_per_block_input, gap_limit_input, mode_input,
+ underline_input, strikeout_input, border_style_input, remove_punc_input,
+ video_quality_input, use_youtube_subs_input, translate_input
+ ], outputs=[logs_output, start_btn, stop_btn, results_html])
+
+
+ with gr.Tab(i18n("Subtitle Editor")):
+ gr.Markdown(f"### {i18n('Edit Subtitles (Smart Mode)')}")
+
+ with gr.Group():
+ editor_project_dropdown = gr.Dropdown(choices=library.get_existing_projects(), label=i18n("Select Project"), value=None)
+ editor_refresh_btn = gr.Button(i18n("Refresh"), size="sm")
+
+ with gr.Group():
+ editor_file_dropdown = gr.Dropdown(choices=[], label=i18n("Select Subtitle File"), interactive=True)
+ editor_load_btn = gr.Button(i18n("Load Subtitles"), variant="secondary")
+
+ # Hidden state to store full path of currently loaded JSON
+ current_json_path = gr.State()
+
+ # The Dataframe Editor
+ # Headers: Start, End, Text
+ subtitle_dataframe = gr.Dataframe(
+ headers=["Start", "End", "Text"],
+ datatype=["str", "str", "str"],
+ col_count=(3, "fixed"),
+ interactive=True,
+ label=i18n("Subtitle Segments"),
+ wrap=True
+ )
+
+ with gr.Row():
+ editor_save_btn = gr.Button(i18n("💾 Save Changes"), variant="primary")
+ editor_render_single_btn = gr.Button(i18n("âš¡ Render This Segment (Very-Fast)"), variant="secondary")
+ editor_render_all_btn = gr.Button(i18n("🎬 Render All (Fast)"), variant="stop")
+
+ editor_status = gr.Textbox(label=i18n("Status"), interactive=False)
+
+ # --- Callbacks for Editor ---
+ editor_refresh_btn.click(library.refresh_projects, outputs=editor_project_dropdown)
+
+ def update_file_list(proj_name):
+ if not proj_name: return gr.update(choices=[])
+ proj_path = os.path.join(VIRALS_DIR, proj_name)
+ files = editor.list_editable_files(proj_path)
+ return gr.update(choices=files, value=files[0] if files else None)
+
+ editor_project_dropdown.change(update_file_list, inputs=editor_project_dropdown, outputs=editor_file_dropdown)
+
+ def load_subs(proj_name, file_name):
+ if not proj_name or not file_name:
+ return [], None, i18n("Please select project and file.")
+
+ full_path = os.path.join(VIRALS_DIR, proj_name, 'subs', file_name)
+ data = editor.load_transcription_for_editor(full_path)
+ return data, full_path, i18n("Loaded {} segments.").format(len(data))
+
+ editor_load_btn.click(load_subs, inputs=[editor_project_dropdown, editor_file_dropdown], outputs=[subtitle_dataframe, current_json_path, editor_status])
+
+ def save_subs(json_path, df):
+ if not json_path: return i18n("No file loaded.")
+ data_list = df.values.tolist() if hasattr(df, 'values') else df
+ msg = editor.save_editor_changes(json_path, data_list)
+ return msg
+
+ editor_save_btn.click(save_subs, inputs=[current_json_path, subtitle_dataframe], outputs=editor_status)
+
+ def render_single(json_path, use_custom, font_name, font_size, font_color, highlight_color,
+ outline_color, outline_thickness, shadow_color, shadow_size,
+ is_bold, is_italic, is_uppercase,
+ h_size, w_block, gap, mode, under, strike, border_s,
+ vertical_pos, alignment, remove_punc):
+
+ if not json_path: return i18n("No file loaded.")
+
+ subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
+
+ # Save config if custom subs enabled
+ if use_custom:
+ subtitle_config = {
+ "font": font_name, "base_size": int(font_size),
+ "base_color": convert_color_to_ass(font_color),
+ "highlight_color": convert_color_to_ass(highlight_color),
+ "outline_color": convert_color_to_ass(outline_color),
+ "outline_thickness": outline_thickness,
+ "shadow_color": convert_color_to_ass(shadow_color),
+ "shadow_size": shadow_size, "vertical_position": vertical_pos,
+ "alignment": alignment, "bold": 1 if is_bold else 0,
+ "italic": 1 if is_italic else 0,
+ "underline": 1 if under else 0, "strikeout": 1 if strike else 0,
+ "border_style": border_s, "words_per_block": int(w_block),
+ "gap_limit": gap, "mode": mode, "highlight_size": int(h_size),
+ "uppercase": 1 if is_uppercase else 0,
+ "remove_punctuation": remove_punc
+ }
+ try:
+ with open(subtitle_config_path, "w", encoding="utf-8") as f:
+ json.dump(subtitle_config, f, indent=4)
+ except Exception: pass
+ else:
+ # Remove temp config if it exists to ensure defaults are used
+ try:
+ if os.path.exists(subtitle_config_path):
+ os.remove(subtitle_config_path)
+ except Exception: pass
+
+ # We expect user to SAVE first, but we could auto-save.
+ # For now assume saved.
+ msg = editor.render_specific_video(json_path)
+ return msg
+
+ editor_render_single_btn.click(
+ render_single,
+ inputs=[current_json_path, use_custom_subs] + manual_inputs,
+ outputs=editor_status
+ )
+
+ def render_all(proj_name, use_custom, font_name, font_size, font_color, highlight_color,
+ outline_color, outline_thickness, shadow_color, shadow_size,
+ is_bold, is_italic, is_uppercase,
+ h_size, w_block, gap, mode, under, strike, border_s,
+ vertical_pos, alignment, remove_punc):
+ if not proj_name: return i18n("No project selected.")
+
+ # Save config
+ if use_custom:
+ subtitle_config = {
+ "font": font_name, "base_size": int(font_size),
+ "base_color": convert_color_to_ass(font_color),
+ "highlight_color": convert_color_to_ass(highlight_color),
+ "outline_color": convert_color_to_ass(outline_color),
+ "outline_thickness": outline_thickness,
+ "shadow_color": convert_color_to_ass(shadow_color),
+ "shadow_size": shadow_size, "vertical_position": vertical_pos,
+ "alignment": alignment, "bold": 1 if is_bold else 0,
+ "italic": 1 if is_italic else 0,
+ "underline": 1 if under else 0, "strikeout": 1 if strike else 0,
+ "border_style": border_s, "words_per_block": int(w_block),
+ "gap_limit": gap, "mode": mode, "highlight_size": int(h_size),
+ "uppercase": 1 if is_uppercase else 0,
+ "remove_punctuation": remove_punc
+ }
+ subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
+ try:
+ with open(subtitle_config_path, "w", encoding="utf-8") as f:
+ json.dump(subtitle_config, f, indent=4)
+ except Exception: pass
+
+ proj_path = os.path.join(VIRALS_DIR, proj_name)
+
+ # IMPORTANT: Pass the config file path to the command
+ subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
+ cmd = [sys.executable, MAIN_SCRIPT_PATH, "--project-path", proj_path, "--workflow", "3", "--skip-prompts"]
+
+ if use_custom and os.path.exists(subtitle_config_path):
+ cmd.extend(["--subtitle-config", subtitle_config_path])
+
+ try:
+ subprocess.Popen(cmd, cwd=WORKING_DIR)
+ return i18n("Render All started in background... Check terminal/logs.")
+ except Exception as e:
+ return i18n("Error starting render: {}").format(e)
+
+ editor_render_all_btn.click(
+ render_all,
+ inputs=[editor_project_dropdown, use_custom_subs] + manual_inputs,
+ outputs=editor_status
+ )
+
+
+ with gr.Tab(i18n("Library")):
+ gr.Markdown(f"### {i18n('Existing Projects')}")
+ with gr.Row():
+ project_dropdown = gr.Dropdown(choices=library.get_existing_projects(), label=i18n("Select Project"), value=None)
+ refresh_btn = gr.Button(i18n("Refresh List"))
+ project_gallery_html = gr.HTML()
+ refresh_btn.click(library.refresh_projects, outputs=project_dropdown)
+ def on_select_project(proj_name): return library.generate_project_gallery(proj_name)
+ project_dropdown.change(on_select_project, project_dropdown, project_gallery_html)
+
+ gr.Markdown(f"""
+
+
+
+ {i18n('Desenvolvido por Rafael Godoy')}
+
+ {i18n('Apoie o projeto, qualquer valor é bem-vindo:')}
+ {i18n('Apoiar via PIX')}
+
+ {i18n('100% local • open source • no subscription required')}
+
+
+ """)
+if __name__ == "__main__":
+ import webbrowser
+ import threading
+ import time
+ import argparse
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--colab", action="store_true", help="Run in Google Colab mode")
+ args = parser.parse_args()
+
+ if args.colab:
+ print("Running in Colab mode. Generating public link with Static Mounts...")
+ library.set_url_mode("fastapi")
+
+ # Broaden allowed paths for Colab
+ allowed_dirs = [VIRALS_DIR, WORKING_DIR, os.getcwd(), "."]
+
+ # Explicitly set static paths
+ try:
+ gr.set_static_paths(paths=allowed_dirs)
+ print(f"DEBUG: Registered static paths: {allowed_dirs}")
+ except AttributeError:
+ print("DEBUG: gr.set_static_paths not available")
+
+ print(f"DEBUG: Allowed paths for Gradio: {allowed_dirs}")
+
+ # Launch with prevent_thread_lock to allow mounting
+ app, local_url, share_url = demo.queue().launch(
+ share=True,
+ allowed_paths=allowed_dirs,
+ prevent_thread_lock=True
+ )
+
+ # Mount the VIRALS directory explicitly
+ app.mount("/virals", StaticFiles(directory=VIRALS_DIR), name="virals")
+ print(f"Mounted /virals to {VIRALS_DIR}")
+
+ demo.block_thread()
+ else:
+ # Check environment
+ is_windows = (os.name == 'nt')
+
+ library.set_url_mode("fastapi")
+ allowed_dirs = [VIRALS_DIR, WORKING_DIR, os.getcwd(), "."]
+ try:
+ gr.set_static_paths(paths=allowed_dirs)
+ except AttributeError: pass
+
+ from fastapi.responses import FileResponse
+ from fastapi import BackgroundTasks
+
+ # Helper to attach routes to any FastAPI app (whether created by Gradio or us)
+ def attach_extra_routes(fastapi_app):
+ fastapi_app.mount("/virals", StaticFiles(directory=VIRALS_DIR), name="virals")
+
+ @fastapi_app.get("/export_xml_api")
+ def export_xml_api(project: str, segment: int, background_tasks: BackgroundTasks, format: str = "premiere"):
+ try:
+ project_path = os.path.join(VIRALS_DIR, project)
+ script_path = os.path.join(WORKING_DIR, "scripts", "export_xml.py")
+ cmd = [sys.executable, script_path, "--project", project_path, "--segment", str(segment), "--format", format]
+ subprocess.run(cmd, check=True)
+ proj_name = os.path.basename(project_path)
+ zip_filename = f"export_{proj_name}_seg{segment}.zip"
+ file_path = os.path.join(project_path, zip_filename)
+ if os.path.exists(file_path):
+ return FileResponse(file_path, filename=zip_filename, media_type='application/zip')
+ else:
+ return {"error": f"File generation failed. Expected: {file_path}"}
+ except Exception as e:
+ return {"error": str(e)}
+
+ print(f"Mounted /virals to {VIRALS_DIR}")
+
+ if is_windows:
+ print("Running in Windows environment (using Gradio launch for convenience).")
+ # Windows: Use demo.launch() for convenience (auto-browser, etc)
+ app, local_url, share_url = demo.queue().launch(
+ share=False,
+ allowed_paths=allowed_dirs,
+ inbrowser=True,
+ server_name="0.0.0.0",
+ server_port=7860,
+ prevent_thread_lock=True
+ )
+ attach_extra_routes(app)
+ demo.block_thread()
+ else:
+ print("Running in Linux/Container environment (using Uvicorn for stability).")
+ # Linux/HF: Use Uvicorn for explicit loop control
+ app = FastAPI()
+ attach_extra_routes(app)
+ # Disable SSR to prevent Node proxying issues on HF Spaces
+ app = gr.mount_gradio_app(app, demo.queue(), path="/", allowed_paths=allowed_dirs, ssr_mode=False)
+ uvicorn.run(app, host="0.0.0.0", port=7860)