RafaG commited on
Commit
1112294
·
verified ·
1 Parent(s): f7598da

Upload 6 files

Browse files
Files changed (2) hide show
  1. webui/app.py +841 -816
  2. webui/library.py +4 -4
webui/app.py CHANGED
@@ -1,816 +1,841 @@
1
- import gradio as gr
2
- import subprocess
3
- import os
4
- import sys
5
- import json
6
- import psutil
7
- import datetime
8
- import time
9
- import urllib.parse
10
- from fastapi import FastAPI
11
- from fastapi.staticfiles import StaticFiles
12
- import uvicorn
13
-
14
-
15
- import re
16
- import library # Module for Library Logic
17
- import subtitle_handler as subs # Module for Subtitles
18
- import subtitle_editor as editor # Module for Editor Logic
19
-
20
- # Path to the main script
21
- MAIN_SCRIPT_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "main_improved.py")
22
- WORKING_DIR = os.path.dirname(MAIN_SCRIPT_PATH)
23
- sys.path.append(WORKING_DIR)
24
-
25
- from i18n.i18n import I18nAuto
26
- i18n = I18nAuto()
27
-
28
- # --- PRESETS DEFINITIONS ---
29
- FACE_PRESETS = {
30
- "Default (Balanced)": {"thresh": 0.35, "two_face": 0.60, "conf": 0.40, "dead_zone": 150},
31
- "Stable (Focus Main)": {"thresh": 0.60, "two_face": 0.80, "conf": 0.60, "dead_zone": 200},
32
- "Sensitive (Catch All)": {"thresh": 0.10, "two_face": 0.40, "conf": 0.30, "dead_zone": 100},
33
- "High Precision": {"thresh": 0.40, "two_face": 0.65, "conf": 0.75, "dead_zone": 150},
34
- }
35
-
36
- EXPERIMENTAL_PRESETS = {
37
- "Default (Off)": {"focus": False, "mar": 0.03, "score": 1.5, "motion": False, "motion_th": 3.0, "motion_sens": 0.05, "decay": 2.0},
38
- "Active Speaker (Balanced)": {"focus": True, "mar": 0.03, "score": 1.5, "motion": True, "motion_th": 3.0, "motion_sens": 0.05, "decay": 2.0},
39
- "Active Speaker (Sensitive)": {"focus": True, "mar": 0.02, "score": 1.0, "motion": True, "motion_th": 2.0, "motion_sens": 0.10, "decay": 1.0},
40
- "Active Speaker (Stable)": {"focus": True, "mar": 0.05, "score": 2.5, "motion": False, "motion_th": 5.0, "motion_sens": 0.02, "decay": 3.0},
41
- }
42
- # ---------------------------
43
-
44
- VIRALS_DIR = os.path.join(WORKING_DIR, "VIRALS")
45
- MODELS_DIR = os.path.join(WORKING_DIR, "models")
46
-
47
- # Ensure directories exist
48
- if not os.path.exists(VIRALS_DIR):
49
- os.makedirs(VIRALS_DIR, exist_ok=True)
50
- if not os.path.exists(MODELS_DIR):
51
- os.makedirs(MODELS_DIR, exist_ok=True)
52
-
53
- # Global variables
54
- current_process = None
55
-
56
- # Helpers
57
- def convert_color_to_ass(hex_color, alpha="00"):
58
- if not hex_color or not hex_color.startswith("#"):
59
- return f"&H{alpha}FFFFFF&"
60
- hex_clean = hex_color.lstrip('#')
61
- if len(hex_clean) == 6:
62
- r = hex_clean[0:2]
63
- g = hex_clean[2:4]
64
- b = hex_clean[4:6]
65
- return f"&H{alpha}{b}{g}{r}&"
66
- return f"&H{alpha}FFFFFF&"
67
-
68
- def kill_process():
69
- global current_process
70
- if current_process:
71
- try:
72
- parent = psutil.Process(current_process.pid)
73
- for child in parent.children(recursive=True):
74
- child.kill()
75
- parent.kill()
76
- current_process = None
77
- return i18n("Process terminated.")
78
- except Exception as e:
79
- return i18n("Error terminating process: {}").format(e)
80
- return i18n("No process running.")
81
-
82
- GEMINI_MODELS = [
83
- 'gemini-3-pro-preview',
84
- 'gemini-2.5-flash',
85
- 'gemini-2.5-flash-preview-09-2025',
86
- 'gemini-2.5-flash-lite',
87
- 'gemini-2.5-flash-lite-preview-09-2025',
88
- 'gemini-2.5-pro',
89
- 'gemini-2.0-flash',
90
- 'gemini-2.0-flash-lite'
91
- ]
92
-
93
- G4F_MODELS = [
94
- 'gpt-4o',
95
- 'gpt-4o-mini',
96
- 'gpt-4',
97
- 'o1-mini',
98
- 'o1',
99
- 'deepseek-r1',
100
- 'deepseek-v3',
101
- 'llama-3.3-70b',
102
- 'llama-3.1-405b',
103
- 'claude-3.5-sonnet',
104
- 'claude-3.7-sonnet',
105
- 'gemini-2.0-flash',
106
- 'qwen-2.5-72b'
107
- ]
108
-
109
- def get_local_models():
110
- if not os.path.exists(MODELS_DIR): return []
111
- return [f for f in os.listdir(MODELS_DIR) if f.endswith(".gguf")]
112
-
113
-
114
-
115
- def apply_face_preset(preset_name):
116
- if preset_name not in FACE_PRESETS:
117
- return [gr.update() for _ in range(4)] # No change
118
-
119
- p = FACE_PRESETS[preset_name]
120
- return p["thresh"], p["two_face"], p["conf"], p["dead_zone"]
121
-
122
- def apply_experimental_preset(preset_name):
123
- if preset_name not in EXPERIMENTAL_PRESETS:
124
- return [gr.update() for _ in range(7)] # No change
125
-
126
- p = EXPERIMENTAL_PRESETS[preset_name]
127
- return p["focus"], p["mar"], p["score"], p["motion"], p["motion_th"], p["motion_sens"], p["decay"]
128
-
129
- # Subtitle logic moved to subtitle_handler.py
130
-
131
-
132
- def run_viral_cutter(input_source, project_name, url, 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,
133
- 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,
134
- 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,
135
- h_size, w_block, gap, mode, under, strike, border_s, remove_punc, video_quality, use_youtube_subs, translate_target):
136
-
137
- global current_process
138
- yield "", gr.update(value=i18n("Running..."), interactive=False), gr.update(visible=True), None
139
-
140
- cmd = [sys.executable, MAIN_SCRIPT_PATH]
141
-
142
- # Input Source Logic
143
- if input_source == "Existing Project":
144
- if not project_name:
145
- yield i18n("Error: No project selected."), gr.update(value=i18n("Start Processing"), interactive=True), gr.update(visible=False), None
146
- return
147
- full_project_path = os.path.join(VIRALS_DIR, project_name)
148
- cmd.extend(["--project-path", full_project_path])
149
- else:
150
- if url: cmd.extend(["--url", url])
151
- # Pass Video Quality
152
- if video_quality: cmd.extend(["--video-quality", video_quality])
153
- # Pass Subtitle Option (if False, we skip)
154
- if not use_youtube_subs: cmd.append("--skip-youtube-subs")
155
-
156
- # Translation
157
- if translate_target and translate_target != "None":
158
- cmd.extend(["--translate-target", translate_target])
159
-
160
-
161
- cmd.extend(["--segments", str(int(segments))])
162
- if viral: cmd.append("--viral")
163
- if themes: cmd.extend(["--themes", themes])
164
- cmd.extend(["--min-duration", str(int(min_duration))])
165
- cmd.extend(["--max-duration", str(int(max_duration))])
166
- cmd.extend(["--model", model])
167
- cmd.extend(["--ai-backend", ai_backend])
168
- if api_key: cmd.extend(["--api-key", api_key])
169
-
170
- # New AI Params
171
- if ai_model_name: cmd.extend(["--ai-model-name", str(ai_model_name)])
172
- if chunk_size: cmd.extend(["--chunk-size", str(int(chunk_size))])
173
-
174
- workflow_map = {"Full": "1", "Cut Only": "2", "Subtitles Only": "3"}
175
- cmd.extend(["--workflow", workflow_map.get(workflow, "1")])
176
- cmd.extend(["--face-model", face_model])
177
- cmd.extend(["--face-mode", face_mode])
178
- if face_detect_interval: cmd.extend(["--face-detect-interval", str(face_detect_interval)])
179
-
180
- # New Face Params
181
- if face_filter_thresh is not None: cmd.extend(["--face-filter-threshold", str(face_filter_thresh)])
182
- if face_two_thresh is not None: cmd.extend(["--face-two-threshold", str(face_two_thresh)])
183
- if face_conf_thresh is not None: cmd.extend(["--face-confidence-threshold", str(face_conf_thresh)])
184
- if face_dead_zone is not None: cmd.extend(["--face-dead-zone", str(face_dead_zone)])
185
-
186
-
187
-
188
- cmd.append("--skip-prompts")
189
-
190
- if focus_active_speaker:
191
- cmd.append("--focus-active-speaker")
192
- if active_speaker_mar is not None: cmd.extend(["--active-speaker-mar", str(active_speaker_mar)])
193
- if active_speaker_score_diff is not None: cmd.extend(["--active-speaker-score-diff", str(active_speaker_score_diff)])
194
- if include_motion: cmd.append("--include-motion")
195
- if active_speaker_motion_threshold is not None: cmd.extend(["--active-speaker-motion-threshold", str(active_speaker_motion_threshold)])
196
- if active_speaker_motion_sensitivity is not None: cmd.extend(["--active-speaker-motion-sensitivity", str(active_speaker_motion_sensitivity)])
197
- if active_speaker_decay is not None: cmd.extend(["--active-speaker-decay", str(active_speaker_decay)])
198
-
199
- cmd.append("--skip-prompts") # Always skip prompts in WebUI to prevent freezing
200
-
201
- if use_custom_subs:
202
- subtitle_config = {
203
- "font": font_name, "base_size": int(font_size), "base_color": convert_color_to_ass(font_color), "highlight_color": convert_color_to_ass(highlight_color),
204
- "outline_color": convert_color_to_ass(outline_color), "outline_thickness": outline_thickness, "shadow_color": convert_color_to_ass(shadow_color),
205
- "shadow_size": shadow_size, "vertical_position": vertical_pos, "alignment": alignment, "bold": 1 if is_bold else 0, "italic": 1 if is_italic else 0,
206
- "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,
207
- "mode": mode, "highlight_size": int(h_size), "remove_punctuation": remove_punc
208
- }
209
- # Uppercase is handled in main script or logic?
210
- # Actually subtitle_config doesn't seem to natively support "uppercase" in get_subtitle_config default, but app.py was using it.
211
- # I should probably add it back if I want to support it, but user said "PROHIBITED to remove existing ones".
212
- # I'll re-add 'uppercase': 1 if is_uppercase else 0 to the dict if the backend supports it, otherwise it's just ignored.
213
- # But wait, main_improved.py doesn't have 'uppercase' in get_subtitle_config.
214
- # I'll keep it in the dict just in case logic uses it elsewhere or if I missed it.
215
- # Actually, standard ASS doesn't support uppercase flag directly in Style, it needs to be text transform.
216
- # But I'll leave it in the dict.
217
- subtitle_config["uppercase"] = 1 if is_uppercase else 0
218
-
219
- subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
220
- try:
221
- with open(subtitle_config_path, "w", encoding="utf-8") as f:
222
- json.dump(subtitle_config, f, indent=4)
223
- cmd.extend(["--subtitle-config", subtitle_config_path])
224
- except Exception: pass
225
-
226
- env = os.environ.copy()
227
- env["PYTHONUNBUFFERED"] = "1"
228
- try:
229
- current_process = subprocess.Popen(cmd, cwd=WORKING_DIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True, env=env)
230
- logs = ""
231
- project_folder_path = None
232
- if input_source == "Existing Project" and project_name:
233
- # If using existing project, we already know the path, but let's see if logs confirm it
234
- project_folder_path = os.path.join(VIRALS_DIR, project_name)
235
-
236
- while True:
237
- line = current_process.stdout.readline()
238
- if not line and current_process.poll() is not None:
239
- break
240
-
241
- if line:
242
- logs += line
243
- if "Project Folder:" in line:
244
- parts = line.split("Project Folder:")
245
- if len(parts) > 1: project_folder_path = parts[1].strip()
246
- yield logs, gr.update(visible=True, interactive=False), gr.update(visible=True), None
247
- except Exception as e:
248
- logs += f"\nError running process: {str(e)}\n"
249
- yield logs, gr.update(visible=True, interactive=False), gr.update(visible=True), None
250
- finally:
251
- if current_process:
252
- if current_process.stdout:
253
- try:
254
- current_process.stdout.close()
255
- except Exception: pass
256
- if current_process.poll() is None:
257
- # If we are here, it means we finished reading or errored out, but process is still running.
258
- # If it was a normal break from loop, process should be done or close to done.
259
- # If we are stopping, current_process.terminate() might be needed outside?
260
- # But here we just wait.
261
- try:
262
- current_process.wait()
263
- except Exception: pass
264
- current_process = None
265
-
266
- # Wait to ensure filesystem flush
267
- time.sleep(1.0)
268
-
269
- html_output = ""
270
- if project_folder_path and os.path.exists(project_folder_path):
271
- html_output = library.generate_project_gallery(project_folder_path, is_full_path=True)
272
- else:
273
- html_output = f"<h3>{i18n('Error: Project folder could not be determined from logs.')}</h3>"
274
- yield logs, gr.update(value=i18n("Start Processing"), interactive=True), gr.update(visible=False), html_output
275
-
276
- css = """
277
- /* Global Dark Theme Overrides */
278
- body, .gradio-container {
279
- background-color: #0b0b0b !important;
280
- color: #ffffff !important;
281
- }
282
-
283
- /* Force dark background for specific inputs that might be white */
284
- input[type="password"], textarea, select {
285
- background-color: #1f1f1f !important;
286
- color: #ffffff !important;
287
- border: 1px solid #333 !important;
288
- }
289
-
290
- /* Hide Footer */
291
- footer {visibility: hidden}
292
-
293
- /* Container Width */
294
- .gradio-container {
295
- max-width: 98% !important;
296
- width: 98% !important;
297
- margin: 0 auto !important;
298
- }
299
- """
300
-
301
- import header
302
-
303
- with gr.Blocks(title=i18n("ViralCutter WebUI"), theme=gr.themes.Default(primary_hue="orange", neutral_hue="slate"), css=css) as demo:
304
- gr.Markdown(header.badges)
305
- gr.Markdown(header.description)
306
- with gr.Tabs():
307
- with gr.Tab(i18n("Create New")):
308
- with gr.Row():
309
- with gr.Column(scale=1):
310
- input_source = gr.Radio([(i18n("YouTube URL"), "YouTube URL"), (i18n("Existing Project"), "Existing Project")], label=i18n("Input Source"), value="YouTube URL")
311
-
312
- url_input = gr.Textbox(label=i18n("YouTube URL"), placeholder="https://www.youtube.com/watch?v=...", visible=True)
313
-
314
- with gr.Row():
315
- video_quality_input = gr.Dropdown(choices=["best", "1080p", "720p", "480p"], label=i18n("Video Quality"), value="best")
316
- translate_input = gr.Dropdown(choices=["None", "pt", "en", "es", "fr", "de", "it", "ru", "ja", "ko", "zh-CN"], label=i18n("Translate Subtitles To"), value="None")
317
- 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)"))
318
-
319
- project_selector = gr.Dropdown(choices=[], label=i18n("Select Project"), visible=False)
320
-
321
- def on_source_change(source):
322
- if source == "YouTube URL":
323
- return gr.update(visible=True), gr.update(visible=False), gr.update(value="Full") # Reset to Full if URL is picked? Optional.
324
- else:
325
- # Load projects
326
- projs = library.get_existing_projects()
327
- return gr.update(visible=False), gr.update(choices=projs, visible=True), gr.update(value="Subtitles Only") # Auto-switch to Subs Only
328
-
329
-
330
- with gr.Row():
331
- segments_input = gr.Number(label=i18n("Segments"), value=3, precision=0)
332
- viral_input = gr.Checkbox(label=i18n("Viral Mode"), value=True)
333
- themes_input = gr.Textbox(label=i18n("Themes"), placeholder=i18n("funny, sad..."), visible=False)
334
- viral_input.change(lambda x: gr.update(visible=not x), viral_input, themes_input)
335
- with gr.Row():
336
- min_dur_input = gr.Number(label=i18n("Min Duration (s)"), value=15)
337
- max_dur_input = gr.Number(label=i18n("Max Duration (s)"), value=90)
338
- with gr.Column(scale=1):
339
- with gr.Row():
340
- 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)
341
- api_key_input = gr.Textbox(label=i18n("Gemini API Key"), type="password", scale=3)
342
-
343
- # New Dynamic Inputs
344
- with gr.Row():
345
- ai_model_input = gr.Dropdown(choices=GEMINI_MODELS, label=i18n("AI Model"), value=GEMINI_MODELS[1], allow_custom_value=True, visible=True, scale=5)
346
- refresh_models_btn = gr.Button("🔄", size="sm", visible=False, scale=0, min_width=50) # Only local
347
- chunk_size_input = gr.Number(label=i18n("Chunk Size"), value=20000, precision=0, scale=2)
348
-
349
- # Update listeners with logic to hide/show API key
350
- def update_ai_ui(backend):
351
- show_api = (backend == "gemini")
352
- show_refresh = (backend == "local")
353
-
354
- # Definições padrão para evitar que fiquem vazios
355
- new_choices = []
356
- new_val = ""
357
- new_chunk = 20000
358
-
359
- if backend == "gemini":
360
- new_choices = GEMINI_MODELS
361
- new_val = GEMINI_MODELS[1]
362
- new_chunk = 20000
363
- elif backend == "g4f":
364
- new_choices = G4F_MODELS
365
- new_val = G4F_MODELS[0]
366
- new_chunk = 3000
367
- elif backend == "local":
368
- models = get_local_models()
369
- new_choices = models if models else ["No models found"]
370
- new_val = new_choices[0]
371
- new_chunk = 15000
372
- else: # Manual
373
- pass
374
-
375
- return (
376
- gr.update(visible=show_api), # API Key Visibility (Fixes hole 1)
377
- gr.update(choices=new_choices, value=new_val, visible=(backend != "manual")), # Model Dropdown
378
- gr.update(visible=show_refresh), # Refresh Button
379
- gr.update(value=new_chunk) # Chunk Size
380
- )
381
-
382
- def refresh_local_models():
383
- models = get_local_models()
384
- val = models[0] if models else "No models found"
385
- return gr.update(choices=models, value=val)
386
-
387
- refresh_models_btn.click(refresh_local_models, outputs=ai_model_input)
388
- ai_backend_input.change(update_ai_ui, inputs=ai_backend_input, outputs=[api_key_input, ai_model_input, refresh_models_btn, chunk_size_input])
389
-
390
- 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")
391
- with gr.Row():
392
- workflow_input = gr.Dropdown(choices=[(i18n("Full"), "Full"), (i18n("Cut Only"), "Cut Only"), (i18n("Subtitles Only"), "Subtitles Only")], label=i18n("Workflow"), value="Full")
393
- face_model_input = gr.Dropdown(["insightface", "mediapipe"], label=i18n("Face Model"), value="insightface")
394
- with gr.Row():
395
- face_mode_input = gr.Dropdown(choices=[(i18n("Auto"), "auto"), ("1", "1"), ("2", "2")], label=i18n("Face Mode"), value="auto")
396
- face_detect_interval_input = gr.Textbox(label=i18n("Face Det. Interval"), value="0.17,1.0")
397
-
398
-
399
- # Update listeners now that all components are defined
400
- input_source.change(on_source_change, inputs=input_source, outputs=[url_input, project_selector, workflow_input])
401
-
402
- with gr.Accordion(i18n("Advanced Face Settings"), open=False):
403
- face_preset_input = gr.Dropdown(choices=list(FACE_PRESETS.keys()), label=i18n("Configuration Presets"), value="Default (Balanced)", interactive=True)
404
- with gr.Row():
405
- 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."))
406
- 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."))
407
- 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."))
408
- 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."))
409
-
410
- 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])
411
-
412
- with gr.Accordion(i18n("Experimental: Active Speaker & Motion"), open=False):
413
- experimental_preset_input = gr.Dropdown(choices=list(EXPERIMENTAL_PRESETS.keys()), label=i18n("Configuration Presets"), value="Default (Off)", interactive=True)
414
- 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."))
415
- with gr.Row():
416
- 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."))
417
- 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."))
418
-
419
- with gr.Row():
420
- include_motion_input = gr.Checkbox(label=i18n("Consider Motion"), value=False, info=i18n("Increases score with motion (gestures)."))
421
-
422
- with gr.Row():
423
- 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."))
424
- 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."))
425
- 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."))
426
-
427
- 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])
428
- with gr.Accordion(i18n("Subtitle Settings (alpha)"), open=False):
429
- preset_input = gr.Dropdown(choices=[(i18n("Manual"), "Manual")] + [(k, k) for k in subs.SUBTITLE_PRESETS.keys()], label=i18n("Quick Presets"), value="Hormozi (Classic)")
430
- use_custom_subs = gr.Checkbox(label=i18n("Enable Subtitle Customization (Includes Preset)"), value=True)
431
-
432
- # Previews (Always Visible)
433
- preview_html = gr.HTML(value=f"<div style='text-align:center; padding:10px; color:#666;'>{i18n('Select options or preset to preview')}</div>")
434
-
435
- with gr.Row():
436
- preview_vid_btn = gr.Button(i18n("🎬 Render Animated Preview (Slow)"), size="sm")
437
- preview_vid = gr.Video(label=i18n("Animated Preview"), height=300, autoplay=True, interactive=False)
438
-
439
- with gr.Accordion(i18n("Advanced Settings"), open=False):
440
- gr.Markdown(f"### {i18n('Appearance')}")
441
- with gr.Row():
442
- font_name_input = gr.Textbox(label=i18n("Font Name"), value="Montserrat-Regular")
443
- font_size_input = gr.Slider(label=i18n("Font Size (Base)"), minimum=8, maximum=80, value=12)
444
- highlight_size_input = gr.Slider(label=i18n("Highlight Size"), minimum=8, maximum=80, value=14)
445
-
446
- with gr.Row():
447
- font_color_input = gr.ColorPicker(label=i18n("Base Color"), value="#FFFFFF")
448
- highlight_color_input = gr.ColorPicker(label=i18n("Highlight Color"), value="#00FF00")
449
- outline_color_input = gr.ColorPicker(label=i18n("Outline Color"), value="#000000")
450
- shadow_color_input = gr.ColorPicker(label=i18n("Shadow Color"), value="#000000")
451
-
452
- gr.Markdown(f"### {i18n('Styling & Effects')}")
453
- with gr.Row():
454
- outline_thickness_input = gr.Slider(label=i18n("Outline Thickness"), minimum=0, maximum=10, value=1.5)
455
- shadow_size_input = gr.Slider(label=i18n("Shadow Size"), minimum=0, maximum=10, value=2)
456
- border_style_input = gr.Dropdown(choices=[(i18n("Outline"), 1), (i18n("Opaque Box"), 3)], label=i18n("Border Style"), value=1)
457
-
458
- with gr.Row():
459
- bold_input = gr.Checkbox(label=i18n("Bold"))
460
- italic_input = gr.Checkbox(label=i18n("Italic"))
461
- uppercase_input = gr.Checkbox(label=i18n("Uppercase"))
462
- remove_punc_input = gr.Checkbox(label=i18n("Remove Punctuation"), value=True)
463
- underline_input = gr.Checkbox(label=i18n("Underline"))
464
- strikeout_input = gr.Checkbox(label=i18n("Strikeout"))
465
-
466
- gr.Markdown(f"### {i18n('Positioning & Layout')}")
467
- with gr.Row():
468
- vertical_pos_input = gr.Slider(label=i18n("V-Pos (Margin V)"), minimum=0, maximum=500, value=210)
469
- alignment_input = gr.Dropdown(choices=[(i18n("Left"), 1), (i18n("Center"), 2), (i18n("Right"), 3)], label=i18n("Alignment"), value=2)
470
- gap_limit_input = gr.Slider(label=i18n("Gap Limit"), minimum=0.0, maximum=5.0, value=0.5, step=0.1)
471
- 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")
472
- words_per_block_input = gr.Slider(label=i18n("Words per Block"), minimum=1, maximum=20, value=3, step=1)
473
-
474
- manual_inputs = [
475
- font_name_input, font_size_input, font_color_input, highlight_color_input,
476
- outline_color_input, outline_thickness_input, shadow_color_input, shadow_size_input,
477
- bold_input, italic_input, uppercase_input,
478
- highlight_size_input, words_per_block_input, gap_limit_input, mode_input,
479
- underline_input, strikeout_input, border_style_input,
480
- vertical_pos_input, alignment_input,
481
- remove_punc_input
482
- ]
483
-
484
- # Update manual inputs when preset changes
485
- preset_input.change(subs.apply_preset, inputs=[preset_input], outputs=manual_inputs)
486
-
487
- # Auto-update PREVIEW HTML on any change
488
- for inp in manual_inputs:
489
- inp.change(subs.generate_preview_html, inputs=manual_inputs, outputs=preview_html)
490
-
491
- # Render video button
492
- preview_vid_btn.click(
493
- subs.render_preview_video,
494
- inputs=manual_inputs,
495
- outputs=preview_vid
496
- )
497
-
498
- # Initial load
499
- demo.load(subs.generate_preview_html, inputs=manual_inputs, outputs=preview_html)
500
- demo.load(subs.apply_preset, inputs=[preset_input], outputs=manual_inputs) # Apply default preset on load
501
-
502
- with gr.Row():
503
- start_btn = gr.Button(i18n("Start Processing"), variant="primary")
504
- stop_btn = gr.Button(i18n("Stop"), variant="stop", visible=False)
505
- stop_btn.click(kill_process, outputs=[])
506
- logs_output = gr.Textbox(label=i18n("Logs"), lines=10, autoscroll=True, elem_id="logs_output")
507
-
508
- # Force scroll to bottom via JS
509
- logs_output.change(fn=None, inputs=[], outputs=[], js="""
510
- function() {
511
- var ta = document.querySelector('#logs_output textarea');
512
- if(ta) {
513
- ta.scrollTop = ta.scrollHeight;
514
- }
515
- }
516
- """)
517
- results_html = gr.HTML(label=i18n("Results"))
518
-
519
- # MUST pass all all new inputs to the run function
520
- start_btn.click(run_viral_cutter, inputs=[
521
- input_source, project_selector, url_input, segments_input, viral_input, themes_input, min_dur_input, max_dur_input,
522
- model_input, ai_backend_input, api_key_input, ai_model_input, chunk_size_input,
523
- workflow_input, face_model_input, face_mode_input, face_detect_interval_input,
524
- face_filter_thresh_input, face_two_thresh_input, face_conf_thresh_input, face_dead_zone_input, focus_active_speaker_input,
525
- 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,
526
- use_custom_subs,
527
- # Expanded Manual Inputs mapping
528
- font_name_input, font_size_input, font_color_input, highlight_color_input,
529
- outline_color_input, outline_thickness_input, shadow_color_input, shadow_size_input,
530
- bold_input, italic_input, uppercase_input, vertical_pos_input, alignment_input,
531
- # New Inputs
532
- highlight_size_input, words_per_block_input, gap_limit_input, mode_input,
533
- underline_input, strikeout_input, border_style_input, remove_punc_input,
534
- video_quality_input, use_youtube_subs_input, translate_input
535
- ], outputs=[logs_output, start_btn, stop_btn, results_html])
536
-
537
-
538
- with gr.Tab(i18n("Subtitle Editor")):
539
- gr.Markdown(f"### {i18n('Edit Subtitles (Smart Mode)')}")
540
-
541
- with gr.Group():
542
- editor_project_dropdown = gr.Dropdown(choices=library.get_existing_projects(), label=i18n("Select Project"), value=None)
543
- editor_refresh_btn = gr.Button(i18n("Refresh"), size="sm")
544
-
545
- with gr.Group():
546
- editor_file_dropdown = gr.Dropdown(choices=[], label=i18n("Select Subtitle File"), interactive=True)
547
- editor_load_btn = gr.Button(i18n("Load Subtitles"), variant="secondary")
548
-
549
- # Hidden state to store full path of currently loaded JSON
550
- current_json_path = gr.State()
551
-
552
- # The Dataframe Editor
553
- # Headers: Start, End, Text
554
- subtitle_dataframe = gr.Dataframe(
555
- headers=["Start", "End", "Text"],
556
- datatype=["str", "str", "str"],
557
- col_count=(3, "fixed"),
558
- interactive=True,
559
- label=i18n("Subtitle Segments"),
560
- wrap=True
561
- )
562
-
563
- with gr.Row():
564
- editor_save_btn = gr.Button(i18n("💾 Save Changes"), variant="primary")
565
- editor_render_single_btn = gr.Button(i18n("⚡ Render This Segment (Very-Fast)"), variant="secondary")
566
- editor_render_all_btn = gr.Button(i18n("🎬 Render All (Fast)"), variant="stop")
567
-
568
- editor_status = gr.Textbox(label=i18n("Status"), interactive=False)
569
-
570
- # --- Callbacks for Editor ---
571
- editor_refresh_btn.click(library.refresh_projects, outputs=editor_project_dropdown)
572
-
573
- def update_file_list(proj_name):
574
- if not proj_name: return gr.update(choices=[])
575
- proj_path = os.path.join(VIRALS_DIR, proj_name)
576
- files = editor.list_editable_files(proj_path)
577
- return gr.update(choices=files, value=files[0] if files else None)
578
-
579
- editor_project_dropdown.change(update_file_list, inputs=editor_project_dropdown, outputs=editor_file_dropdown)
580
-
581
- def load_subs(proj_name, file_name):
582
- if not proj_name or not file_name:
583
- return [], None, i18n("Please select project and file.")
584
-
585
- full_path = os.path.join(VIRALS_DIR, proj_name, 'subs', file_name)
586
- data = editor.load_transcription_for_editor(full_path)
587
- return data, full_path, i18n("Loaded {} segments.").format(len(data))
588
-
589
- editor_load_btn.click(load_subs, inputs=[editor_project_dropdown, editor_file_dropdown], outputs=[subtitle_dataframe, current_json_path, editor_status])
590
-
591
- def save_subs(json_path, df):
592
- if not json_path: return i18n("No file loaded.")
593
- data_list = df.values.tolist() if hasattr(df, 'values') else df
594
- msg = editor.save_editor_changes(json_path, data_list)
595
- return msg
596
-
597
- editor_save_btn.click(save_subs, inputs=[current_json_path, subtitle_dataframe], outputs=editor_status)
598
-
599
- def render_single(json_path, use_custom, font_name, font_size, font_color, highlight_color,
600
- outline_color, outline_thickness, shadow_color, shadow_size,
601
- is_bold, is_italic, is_uppercase,
602
- h_size, w_block, gap, mode, under, strike, border_s,
603
- vertical_pos, alignment, remove_punc):
604
-
605
- if not json_path: return i18n("No file loaded.")
606
-
607
- subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
608
-
609
- # Save config if custom subs enabled
610
- if use_custom:
611
- subtitle_config = {
612
- "font": font_name, "base_size": int(font_size),
613
- "base_color": convert_color_to_ass(font_color),
614
- "highlight_color": convert_color_to_ass(highlight_color),
615
- "outline_color": convert_color_to_ass(outline_color),
616
- "outline_thickness": outline_thickness,
617
- "shadow_color": convert_color_to_ass(shadow_color),
618
- "shadow_size": shadow_size, "vertical_position": vertical_pos,
619
- "alignment": alignment, "bold": 1 if is_bold else 0,
620
- "italic": 1 if is_italic else 0,
621
- "underline": 1 if under else 0, "strikeout": 1 if strike else 0,
622
- "border_style": border_s, "words_per_block": int(w_block),
623
- "gap_limit": gap, "mode": mode, "highlight_size": int(h_size),
624
- "uppercase": 1 if is_uppercase else 0,
625
- "remove_punctuation": remove_punc
626
- }
627
- try:
628
- with open(subtitle_config_path, "w", encoding="utf-8") as f:
629
- json.dump(subtitle_config, f, indent=4)
630
- except Exception: pass
631
- else:
632
- # Remove temp config if it exists to ensure defaults are used
633
- try:
634
- if os.path.exists(subtitle_config_path):
635
- os.remove(subtitle_config_path)
636
- except Exception: pass
637
-
638
- # We expect user to SAVE first, but we could auto-save.
639
- # For now assume saved.
640
- msg = editor.render_specific_video(json_path)
641
- return msg
642
-
643
- editor_render_single_btn.click(
644
- render_single,
645
- inputs=[current_json_path, use_custom_subs] + manual_inputs,
646
- outputs=editor_status
647
- )
648
-
649
- def render_all(proj_name, use_custom, font_name, font_size, font_color, highlight_color,
650
- outline_color, outline_thickness, shadow_color, shadow_size,
651
- is_bold, is_italic, is_uppercase,
652
- h_size, w_block, gap, mode, under, strike, border_s,
653
- vertical_pos, alignment, remove_punc):
654
- if not proj_name: return i18n("No project selected.")
655
-
656
- # Save config
657
- if use_custom:
658
- subtitle_config = {
659
- "font": font_name, "base_size": int(font_size),
660
- "base_color": convert_color_to_ass(font_color),
661
- "highlight_color": convert_color_to_ass(highlight_color),
662
- "outline_color": convert_color_to_ass(outline_color),
663
- "outline_thickness": outline_thickness,
664
- "shadow_color": convert_color_to_ass(shadow_color),
665
- "shadow_size": shadow_size, "vertical_position": vertical_pos,
666
- "alignment": alignment, "bold": 1 if is_bold else 0,
667
- "italic": 1 if is_italic else 0,
668
- "underline": 1 if under else 0, "strikeout": 1 if strike else 0,
669
- "border_style": border_s, "words_per_block": int(w_block),
670
- "gap_limit": gap, "mode": mode, "highlight_size": int(h_size),
671
- "uppercase": 1 if is_uppercase else 0,
672
- "remove_punctuation": remove_punc
673
- }
674
- subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
675
- try:
676
- with open(subtitle_config_path, "w", encoding="utf-8") as f:
677
- json.dump(subtitle_config, f, indent=4)
678
- except Exception: pass
679
-
680
- proj_path = os.path.join(VIRALS_DIR, proj_name)
681
-
682
- # IMPORTANT: Pass the config file path to the command
683
- subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
684
- cmd = [sys.executable, MAIN_SCRIPT_PATH, "--project-path", proj_path, "--workflow", "3", "--skip-prompts"]
685
-
686
- if use_custom and os.path.exists(subtitle_config_path):
687
- cmd.extend(["--subtitle-config", subtitle_config_path])
688
-
689
- try:
690
- subprocess.Popen(cmd, cwd=WORKING_DIR)
691
- return i18n("Render All started in background... Check terminal/logs.")
692
- except Exception as e:
693
- return f"Error starting render: {e}"
694
-
695
- editor_render_all_btn.click(
696
- render_all,
697
- inputs=[editor_project_dropdown, use_custom_subs] + manual_inputs,
698
- outputs=editor_status
699
- )
700
-
701
-
702
- with gr.Tab(i18n("Library")):
703
- gr.Markdown(f"### {i18n('Existing Projects')}")
704
- with gr.Row():
705
- project_dropdown = gr.Dropdown(choices=library.get_existing_projects(), label=i18n("Select Project"), value=None)
706
- refresh_btn = gr.Button(i18n("Refresh List"))
707
- project_gallery_html = gr.HTML()
708
- refresh_btn.click(library.refresh_projects, outputs=project_dropdown)
709
- def on_select_project(proj_name): return library.generate_project_gallery(proj_name)
710
- project_dropdown.change(on_select_project, project_dropdown, project_gallery_html)
711
-
712
- gr.Markdown(f"""
713
- <hr>
714
- <div style='text-align: center; font-size: 0.9em; color: #777;'>
715
- <p>
716
- <strong>{i18n('Desenvolvido por Rafael Godoy')}</strong>
717
- <br>
718
- {i18n('Apoie o projeto, qualquer valor é bem-vindo:')}
719
- <a href='https://nubank.com.br/pagar/1ls6a4/0QpSSbWBSq' target='_blank'><strong>{i18n('Apoiar via PIX')}</strong></a>
720
- </p>
721
- </div>
722
- """)
723
- if __name__ == "__main__":
724
- import webbrowser
725
- import threading
726
- import time
727
- import argparse
728
-
729
- parser = argparse.ArgumentParser()
730
- parser.add_argument("--colab", action="store_true", help="Run in Google Colab mode")
731
- args = parser.parse_args()
732
-
733
- if args.colab:
734
- print("Running in Colab mode. Generating public link with Static Mounts...")
735
- library.set_url_mode("fastapi")
736
-
737
- # Broaden allowed paths for Colab
738
- allowed_dirs = [VIRALS_DIR, WORKING_DIR, os.getcwd(), "."]
739
-
740
- # Explicitly set static paths
741
- try:
742
- gr.set_static_paths(paths=allowed_dirs)
743
- print(f"DEBUG: Registered static paths: {allowed_dirs}")
744
- except AttributeError:
745
- print("DEBUG: gr.set_static_paths not available")
746
-
747
- print(f"DEBUG: Allowed paths for Gradio: {allowed_dirs}")
748
-
749
- # Launch with prevent_thread_lock to allow mounting
750
- app, local_url, share_url = demo.queue().launch(
751
- share=True,
752
- allowed_paths=allowed_dirs,
753
- prevent_thread_lock=True
754
- )
755
-
756
- # Mount the VIRALS directory explicitly
757
- app.mount("/virals", StaticFiles(directory=VIRALS_DIR), name="virals")
758
- print(f"Mounted /virals to {VIRALS_DIR}")
759
-
760
- demo.block_thread()
761
- else:
762
- print("Running in Local mode (FastAPI + Uvicorn) with Share enabled (Mounting StaticFiles).")
763
- library.set_url_mode("fastapi")
764
-
765
- # Broaden allowed paths (good practice even if using mount)
766
- allowed_dirs = [VIRALS_DIR, WORKING_DIR, os.getcwd(), "."]
767
-
768
- try:
769
- gr.set_static_paths(paths=allowed_dirs)
770
- except AttributeError:
771
- pass
772
-
773
- # Create explicit FastAPI app
774
- app = FastAPI()
775
-
776
- # Mount the VIRALS directory to /virals using standard FastAPI StaticFiles
777
- app.mount("/virals", StaticFiles(directory=VIRALS_DIR), name="virals")
778
-
779
- from fastapi.responses import FileResponse
780
- from fastapi import BackgroundTasks
781
-
782
- @app.get("/export_xml_api")
783
- def export_xml_api(project: str, segment: int, background_tasks: BackgroundTasks, format: str = "premiere"):
784
- """
785
- Trigger XML export for a segment and return the file.
786
- """
787
- try:
788
- project_path = os.path.join(VIRALS_DIR, project)
789
- script_path = os.path.join(WORKING_DIR, "scripts", "export_xml.py")
790
-
791
- # Run script synchronously (threadpool)
792
- cmd = [sys.executable, script_path, "--project", project_path, "--segment", str(segment), "--format", format]
793
- subprocess.run(cmd, check=True)
794
-
795
- # New Logic: Script now creates a ZIP file named export_PROJECT_segX.zip
796
- proj_name = os.path.basename(project_path)
797
- zip_filename = f"export_{proj_name}_seg{segment}.zip"
798
- file_path = os.path.join(project_path, zip_filename)
799
-
800
- if os.path.exists(file_path):
801
- return FileResponse(file_path, filename=zip_filename, media_type='application/zip')
802
- else:
803
- return {"error": f"File generation failed. Expected: {file_path}"}
804
- except Exception as e:
805
- return {"error": str(e)}
806
-
807
- print(f"Mounted /virals to {VIRALS_DIR}")
808
-
809
- # Mount Gradio ON TOP of FastAPI
810
- # path="/" means Gradio handles the root
811
- # Disable SSR to prevent Node proxying issues on HF Spaces (port 7861 errors)
812
- app = gr.mount_gradio_app(app, demo.queue(), path="/", allowed_paths=allowed_dirs, ssr_mode=False)
813
-
814
- # Run with Uvicorn
815
- # This gives Uvicorn control over the main thread and Loop, avoiding blocking/GC issues
816
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import subprocess
3
+ import os
4
+ import sys
5
+ import json
6
+ import psutil
7
+ import shutil
8
+ import datetime
9
+ import time
10
+ import urllib.parse
11
+ from fastapi import FastAPI
12
+ from fastapi.staticfiles import StaticFiles
13
+ import uvicorn
14
+
15
+
16
+ import re
17
+ import library # Module for Library Logic
18
+ import subtitle_handler as subs # Module for Subtitles
19
+ import subtitle_editor as editor # Module for Editor Logic
20
+
21
+ # Path to the main script
22
+ MAIN_SCRIPT_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "main_improved.py")
23
+ WORKING_DIR = os.path.dirname(MAIN_SCRIPT_PATH)
24
+ sys.path.append(WORKING_DIR)
25
+
26
+ from i18n.i18n import I18nAuto
27
+ i18n = I18nAuto()
28
+
29
+ # --- PRESETS DEFINITIONS ---
30
+ FACE_PRESETS = {
31
+ "Default (Balanced)": {"thresh": 0.35, "two_face": 0.60, "conf": 0.40, "dead_zone": 150},
32
+ "Stable (Focus Main)": {"thresh": 0.60, "two_face": 0.80, "conf": 0.60, "dead_zone": 200},
33
+ "Sensitive (Catch All)": {"thresh": 0.10, "two_face": 0.40, "conf": 0.30, "dead_zone": 100},
34
+ "High Precision": {"thresh": 0.40, "two_face": 0.65, "conf": 0.75, "dead_zone": 150},
35
+ }
36
+
37
+ EXPERIMENTAL_PRESETS = {
38
+ "Default (Off)": {"focus": False, "mar": 0.03, "score": 1.5, "motion": False, "motion_th": 3.0, "motion_sens": 0.05, "decay": 2.0},
39
+ "Active Speaker (Balanced)": {"focus": True, "mar": 0.03, "score": 1.5, "motion": True, "motion_th": 3.0, "motion_sens": 0.05, "decay": 2.0},
40
+ "Active Speaker (Sensitive)": {"focus": True, "mar": 0.02, "score": 1.0, "motion": True, "motion_th": 2.0, "motion_sens": 0.10, "decay": 1.0},
41
+ "Active Speaker (Stable)": {"focus": True, "mar": 0.05, "score": 2.5, "motion": False, "motion_th": 5.0, "motion_sens": 0.02, "decay": 3.0},
42
+ }
43
+ # ---------------------------
44
+
45
+ VIRALS_DIR = os.path.join(WORKING_DIR, "VIRALS")
46
+ MODELS_DIR = os.path.join(WORKING_DIR, "models")
47
+
48
+ # Ensure directories exist
49
+ if not os.path.exists(VIRALS_DIR):
50
+ os.makedirs(VIRALS_DIR, exist_ok=True)
51
+ if not os.path.exists(MODELS_DIR):
52
+ os.makedirs(MODELS_DIR, exist_ok=True)
53
+
54
+ # Global variables
55
+ current_process = None
56
+
57
+ # Helpers
58
+ def convert_color_to_ass(hex_color, alpha="00"):
59
+ if not hex_color or not hex_color.startswith("#"):
60
+ return f"&H{alpha}FFFFFF&"
61
+ hex_clean = hex_color.lstrip('#')
62
+ if len(hex_clean) == 6:
63
+ r = hex_clean[0:2]
64
+ g = hex_clean[2:4]
65
+ b = hex_clean[4:6]
66
+ return f"&H{alpha}{b}{g}{r}&"
67
+ return f"&H{alpha}FFFFFF&"
68
+
69
+ def kill_process():
70
+ global current_process
71
+ if current_process:
72
+ try:
73
+ parent = psutil.Process(current_process.pid)
74
+ for child in parent.children(recursive=True):
75
+ child.kill()
76
+ parent.kill()
77
+ current_process = None
78
+ return i18n("Process terminated.")
79
+ except Exception as e:
80
+ return i18n("Error terminating process: {}").format(e)
81
+ return i18n("No process running.")
82
+
83
+ GEMINI_MODELS = [
84
+ 'gemini-3-pro-preview',
85
+ 'gemini-2.5-flash',
86
+ 'gemini-2.5-flash-preview-09-2025',
87
+ 'gemini-2.5-flash-lite',
88
+ 'gemini-2.5-flash-lite-preview-09-2025',
89
+ 'gemini-2.5-pro',
90
+ 'gemini-2.0-flash',
91
+ 'gemini-2.0-flash-lite'
92
+ ]
93
+
94
+ G4F_MODELS = [
95
+ 'gpt-4o',
96
+ 'gpt-4o-mini',
97
+ 'gpt-4',
98
+ 'o1-mini',
99
+ 'o1',
100
+ 'deepseek-r1',
101
+ 'deepseek-v3',
102
+ 'llama-3.3-70b',
103
+ 'llama-3.1-405b',
104
+ 'claude-3.5-sonnet',
105
+ 'claude-3.7-sonnet',
106
+ 'gemini-2.0-flash',
107
+ 'qwen-2.5-72b'
108
+ ]
109
+
110
+ def get_local_models():
111
+ if not os.path.exists(MODELS_DIR): return []
112
+ return [f for f in os.listdir(MODELS_DIR) if f.endswith(".gguf")]
113
+
114
+
115
+
116
+ def apply_face_preset(preset_name):
117
+ if preset_name not in FACE_PRESETS:
118
+ return [gr.update() for _ in range(4)] # No change
119
+
120
+ p = FACE_PRESETS[preset_name]
121
+ return p["thresh"], p["two_face"], p["conf"], p["dead_zone"]
122
+
123
+ def apply_experimental_preset(preset_name):
124
+ if preset_name not in EXPERIMENTAL_PRESETS:
125
+ return [gr.update() for _ in range(7)] # No change
126
+
127
+ p = EXPERIMENTAL_PRESETS[preset_name]
128
+ return p["focus"], p["mar"], p["score"], p["motion"], p["motion_th"], p["motion_sens"], p["decay"]
129
+
130
+ # Subtitle logic moved to subtitle_handler.py
131
+
132
+
133
+ 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,
134
+ 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,
135
+ 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,
136
+ h_size, w_block, gap, mode, under, strike, border_s, remove_punc, video_quality, use_youtube_subs, translate_target):
137
+
138
+ global current_process
139
+ yield "", gr.update(value=i18n("Running..."), interactive=False), gr.update(visible=True), None
140
+
141
+ cmd = [sys.executable, MAIN_SCRIPT_PATH]
142
+
143
+ # Input Source Logic
144
+ if input_source == "Existing Project":
145
+ if not project_name:
146
+ yield i18n("Error: No project selected."), gr.update(value=i18n("Start Processing"), interactive=True), gr.update(visible=False), None
147
+ return
148
+ full_project_path = os.path.join(VIRALS_DIR, project_name)
149
+ cmd.extend(["--project-path", full_project_path])
150
+ elif input_source == "Upload Video":
151
+ if not video_file:
152
+ yield i18n("Error: No video file uploaded."), gr.update(value=i18n("Start Processing"), interactive=True), gr.update(visible=False), None
153
+ return
154
+
155
+ # Create new project for upload
156
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
157
+ project_name_upload = f"Upload_{timestamp}"
158
+ project_path = os.path.join(VIRALS_DIR, project_name_upload)
159
+ os.makedirs(project_path, exist_ok=True)
160
+
161
+ target_path = os.path.join(project_path, "input.mp4")
162
+ shutil.copy(video_file, target_path)
163
+
164
+ cmd.extend(["--project-path", project_path])
165
+ # Skip YouTube subs as it is a local upload
166
+ cmd.append("--skip-youtube-subs")
167
+
168
+ else:
169
+ if url: cmd.extend(["--url", url])
170
+ # Pass Video Quality
171
+ if video_quality: cmd.extend(["--video-quality", video_quality])
172
+ # Pass Subtitle Option (if False, we skip)
173
+ if not use_youtube_subs: cmd.append("--skip-youtube-subs")
174
+
175
+ # Translation
176
+ if translate_target and translate_target != "None":
177
+ cmd.extend(["--translate-target", translate_target])
178
+
179
+
180
+ cmd.extend(["--segments", str(int(segments))])
181
+ if viral: cmd.append("--viral")
182
+ if themes: cmd.extend(["--themes", themes])
183
+ cmd.extend(["--min-duration", str(int(min_duration))])
184
+ cmd.extend(["--max-duration", str(int(max_duration))])
185
+ cmd.extend(["--model", model])
186
+ cmd.extend(["--ai-backend", ai_backend])
187
+ if api_key: cmd.extend(["--api-key", api_key])
188
+
189
+ # New AI Params
190
+ if ai_model_name: cmd.extend(["--ai-model-name", str(ai_model_name)])
191
+ if chunk_size: cmd.extend(["--chunk-size", str(int(chunk_size))])
192
+
193
+ workflow_map = {"Full": "1", "Cut Only": "2", "Subtitles Only": "3"}
194
+ cmd.extend(["--workflow", workflow_map.get(workflow, "1")])
195
+ cmd.extend(["--face-model", face_model])
196
+ cmd.extend(["--face-mode", face_mode])
197
+ if face_detect_interval: cmd.extend(["--face-detect-interval", str(face_detect_interval)])
198
+
199
+ # New Face Params
200
+ if face_filter_thresh is not None: cmd.extend(["--face-filter-threshold", str(face_filter_thresh)])
201
+ if face_two_thresh is not None: cmd.extend(["--face-two-threshold", str(face_two_thresh)])
202
+ if face_conf_thresh is not None: cmd.extend(["--face-confidence-threshold", str(face_conf_thresh)])
203
+ if face_dead_zone is not None: cmd.extend(["--face-dead-zone", str(face_dead_zone)])
204
+
205
+
206
+
207
+ cmd.append("--skip-prompts")
208
+
209
+ if focus_active_speaker:
210
+ cmd.append("--focus-active-speaker")
211
+ if active_speaker_mar is not None: cmd.extend(["--active-speaker-mar", str(active_speaker_mar)])
212
+ if active_speaker_score_diff is not None: cmd.extend(["--active-speaker-score-diff", str(active_speaker_score_diff)])
213
+ if include_motion: cmd.append("--include-motion")
214
+ if active_speaker_motion_threshold is not None: cmd.extend(["--active-speaker-motion-threshold", str(active_speaker_motion_threshold)])
215
+ if active_speaker_motion_sensitivity is not None: cmd.extend(["--active-speaker-motion-sensitivity", str(active_speaker_motion_sensitivity)])
216
+ if active_speaker_decay is not None: cmd.extend(["--active-speaker-decay", str(active_speaker_decay)])
217
+
218
+ cmd.append("--skip-prompts") # Always skip prompts in WebUI to prevent freezing
219
+
220
+ if use_custom_subs:
221
+ subtitle_config = {
222
+ "font": font_name, "base_size": int(font_size), "base_color": convert_color_to_ass(font_color), "highlight_color": convert_color_to_ass(highlight_color),
223
+ "outline_color": convert_color_to_ass(outline_color), "outline_thickness": outline_thickness, "shadow_color": convert_color_to_ass(shadow_color),
224
+ "shadow_size": shadow_size, "vertical_position": vertical_pos, "alignment": alignment, "bold": 1 if is_bold else 0, "italic": 1 if is_italic else 0,
225
+ "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,
226
+ "mode": mode, "highlight_size": int(h_size), "remove_punctuation": remove_punc
227
+ }
228
+ # Uppercase is handled in main script or logic?
229
+ # Actually subtitle_config doesn't seem to natively support "uppercase" in get_subtitle_config default, but app.py was using it.
230
+ # I should probably add it back if I want to support it, but user said "PROHIBITED to remove existing ones".
231
+ # I'll re-add 'uppercase': 1 if is_uppercase else 0 to the dict if the backend supports it, otherwise it's just ignored.
232
+ # But wait, main_improved.py doesn't have 'uppercase' in get_subtitle_config.
233
+ # I'll keep it in the dict just in case logic uses it elsewhere or if I missed it.
234
+ # Actually, standard ASS doesn't support uppercase flag directly in Style, it needs to be text transform.
235
+ # But I'll leave it in the dict.
236
+ subtitle_config["uppercase"] = 1 if is_uppercase else 0
237
+
238
+ subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
239
+ try:
240
+ with open(subtitle_config_path, "w", encoding="utf-8") as f:
241
+ json.dump(subtitle_config, f, indent=4)
242
+ cmd.extend(["--subtitle-config", subtitle_config_path])
243
+ except Exception: pass
244
+
245
+ env = os.environ.copy()
246
+ env["PYTHONUNBUFFERED"] = "1"
247
+ try:
248
+ current_process = subprocess.Popen(cmd, cwd=WORKING_DIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True, env=env)
249
+ logs = ""
250
+ project_folder_path = None
251
+ if input_source == "Existing Project" and project_name:
252
+ # If using existing project, we already know the path, but let's see if logs confirm it
253
+ project_folder_path = os.path.join(VIRALS_DIR, project_name)
254
+
255
+ while True:
256
+ line = current_process.stdout.readline()
257
+ if not line and current_process.poll() is not None:
258
+ break
259
+
260
+ if line:
261
+ logs += line
262
+ if "Project Folder:" in line:
263
+ parts = line.split("Project Folder:")
264
+ if len(parts) > 1: project_folder_path = parts[1].strip()
265
+ yield logs, gr.update(visible=True, interactive=False), gr.update(visible=True), None
266
+ except Exception as e:
267
+ logs += f"\nError running process: {str(e)}\n"
268
+ yield logs, gr.update(visible=True, interactive=False), gr.update(visible=True), None
269
+ finally:
270
+ if current_process:
271
+ if current_process.stdout:
272
+ try:
273
+ current_process.stdout.close()
274
+ except Exception: pass
275
+ if current_process.poll() is None:
276
+ # If we are here, it means we finished reading or errored out, but process is still running.
277
+ # If it was a normal break from loop, process should be done or close to done.
278
+ # If we are stopping, current_process.terminate() might be needed outside?
279
+ # But here we just wait.
280
+ try:
281
+ current_process.wait()
282
+ except Exception: pass
283
+ current_process = None
284
+
285
+ # Wait to ensure filesystem flush
286
+ time.sleep(1.0)
287
+
288
+ html_output = ""
289
+ if project_folder_path and os.path.exists(project_folder_path):
290
+ html_output = library.generate_project_gallery(project_folder_path, is_full_path=True)
291
+ else:
292
+ html_output = f"<h3>{i18n('Error: Project folder could not be determined from logs.')}</h3>"
293
+ yield logs, gr.update(value=i18n("Start Processing"), interactive=True), gr.update(visible=False), html_output
294
+
295
+ css = """
296
+ /* Global Dark Theme Overrides */
297
+ body, .gradio-container {
298
+ background-color: #0b0b0b !important;
299
+ color: #ffffff !important;
300
+ }
301
+
302
+ /* Force dark background for specific inputs that might be white */
303
+ input[type="password"], textarea, select {
304
+ background-color: #1f1f1f !important;
305
+ color: #ffffff !important;
306
+ border: 1px solid #333 !important;
307
+ }
308
+
309
+ /* Hide Footer */
310
+ footer {visibility: hidden}
311
+
312
+ /* Container Width */
313
+ .gradio-container {
314
+ max-width: 98% !important;
315
+ width: 98% !important;
316
+ margin: 0 auto !important;
317
+ }
318
+ """
319
+
320
+ import header
321
+
322
+ with gr.Blocks(title=i18n("ViralCutter WebUI"), theme=gr.themes.Default(primary_hue="orange", neutral_hue="slate"), css=css) as demo:
323
+ gr.Markdown(header.badges)
324
+ gr.Markdown(header.description)
325
+ with gr.Tabs():
326
+ with gr.Tab(i18n("Create New")):
327
+ with gr.Row():
328
+ with gr.Column(scale=1):
329
+ 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")
330
+
331
+ url_input = gr.Textbox(label=i18n("YouTube URL"), placeholder="https://www.youtube.com/watch?v=...", visible=True)
332
+ video_upload = gr.File(label=i18n("Upload Video"), file_count="single", file_types=["video"], visible=False)
333
+
334
+ with gr.Row():
335
+ video_quality_input = gr.Dropdown(choices=["best", "1080p", "720p", "480p"], label=i18n("Video Quality"), value="best")
336
+ translate_input = gr.Dropdown(choices=["None", "pt", "en", "es", "fr", "de", "it", "ru", "ja", "ko", "zh-CN"], label=i18n("Translate Subtitles To"), value="None")
337
+ 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)"))
338
+
339
+ project_selector = gr.Dropdown(choices=[], label=i18n("Select Project"), visible=False)
340
+
341
+ def on_source_change(source):
342
+ if source == "YouTube URL":
343
+ return gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(value="Full")
344
+ elif source == "Upload Video":
345
+ return gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(value="Full")
346
+ else:
347
+ # Load projects
348
+ projs = library.get_existing_projects()
349
+ return gr.update(visible=False), gr.update(choices=projs, visible=True), gr.update(visible=False), gr.update(value="Subtitles Only")
350
+
351
+
352
+ with gr.Row():
353
+ segments_input = gr.Number(label=i18n("Segments"), value=3, precision=0)
354
+ viral_input = gr.Checkbox(label=i18n("Viral Mode"), value=True)
355
+ themes_input = gr.Textbox(label=i18n("Themes"), placeholder=i18n("funny, sad..."), visible=False)
356
+ viral_input.change(lambda x: gr.update(visible=not x), viral_input, themes_input)
357
+ with gr.Row():
358
+ min_dur_input = gr.Number(label=i18n("Min Duration (s)"), value=15)
359
+ max_dur_input = gr.Number(label=i18n("Max Duration (s)"), value=90)
360
+ with gr.Column(scale=1):
361
+ with gr.Row():
362
+ 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)
363
+ api_key_input = gr.Textbox(label=i18n("Gemini API Key"), type="password", scale=3)
364
+
365
+ # New Dynamic Inputs
366
+ with gr.Row():
367
+ ai_model_input = gr.Dropdown(choices=GEMINI_MODELS, label=i18n("AI Model"), value=GEMINI_MODELS[1], allow_custom_value=True, visible=True, scale=5)
368
+ refresh_models_btn = gr.Button("🔄", size="sm", visible=False, scale=0, min_width=50) # Only local
369
+ chunk_size_input = gr.Number(label=i18n("Chunk Size"), value=20000, precision=0, scale=2)
370
+
371
+ # Update listeners with logic to hide/show API key
372
+ def update_ai_ui(backend):
373
+ show_api = (backend == "gemini")
374
+ show_refresh = (backend == "local")
375
+
376
+ # Definições padrão para evitar que fiquem vazios
377
+ new_choices = []
378
+ new_val = ""
379
+ new_chunk = 20000
380
+
381
+ if backend == "gemini":
382
+ new_choices = GEMINI_MODELS
383
+ new_val = GEMINI_MODELS[1]
384
+ new_chunk = 20000
385
+ elif backend == "g4f":
386
+ new_choices = G4F_MODELS
387
+ new_val = G4F_MODELS[0]
388
+ new_chunk = 3000
389
+ elif backend == "local":
390
+ models = get_local_models()
391
+ new_choices = models if models else [i18n("No models found")]
392
+ new_val = new_choices[0]
393
+ new_chunk = 15000
394
+ else: # Manual
395
+ pass
396
+
397
+ return (
398
+ gr.update(visible=show_api), # API Key Visibility (Fixes hole 1)
399
+ gr.update(choices=new_choices, value=new_val, visible=(backend != "manual")), # Model Dropdown
400
+ gr.update(visible=show_refresh), # Refresh Button
401
+ gr.update(value=new_chunk) # Chunk Size
402
+ )
403
+
404
+ def refresh_local_models():
405
+ models = get_local_models()
406
+ val = models[0] if models else i18n("No models found")
407
+ return gr.update(choices=models, value=val)
408
+
409
+ refresh_models_btn.click(refresh_local_models, outputs=ai_model_input)
410
+ ai_backend_input.change(update_ai_ui, inputs=ai_backend_input, outputs=[api_key_input, ai_model_input, refresh_models_btn, chunk_size_input])
411
+
412
+ 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")
413
+ with gr.Row():
414
+ workflow_input = gr.Dropdown(choices=[(i18n("Full"), "Full"), (i18n("Cut Only"), "Cut Only"), (i18n("Subtitles Only"), "Subtitles Only")], label=i18n("Workflow"), value="Full")
415
+ face_model_input = gr.Dropdown(["insightface", "mediapipe"], label=i18n("Face Model"), value="insightface")
416
+ with gr.Row():
417
+ face_mode_input = gr.Dropdown(choices=[(i18n("Auto"), "auto"), ("1", "1"), ("2", "2")], label=i18n("Face Mode"), value="auto")
418
+ face_detect_interval_input = gr.Textbox(label=i18n("Face Det. Interval"), value="0.17,1.0")
419
+
420
+
421
+ # Update listeners now that all components are defined
422
+ input_source.change(on_source_change, inputs=input_source, outputs=[url_input, project_selector, video_upload, workflow_input])
423
+
424
+ with gr.Accordion(i18n("Advanced Face Settings"), open=False):
425
+ face_preset_input = gr.Dropdown(choices=[(i18n(k), k) for k in FACE_PRESETS.keys()], label=i18n("Configuration Presets"), value="Default (Balanced)", interactive=True)
426
+ with gr.Row():
427
+ 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."))
428
+ 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."))
429
+ 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."))
430
+ 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."))
431
+
432
+ 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])
433
+
434
+ with gr.Accordion(i18n("Experimental: Active Speaker & Motion"), open=False):
435
+ experimental_preset_input = gr.Dropdown(choices=[(i18n(k), k) for k in EXPERIMENTAL_PRESETS.keys()], label=i18n("Configuration Presets"), value="Default (Off)", interactive=True)
436
+ 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."))
437
+ with gr.Row():
438
+ 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."))
439
+ 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."))
440
+
441
+ with gr.Row():
442
+ include_motion_input = gr.Checkbox(label=i18n("Consider Motion"), value=False, info=i18n("Increases score with motion (gestures)."))
443
+
444
+ with gr.Row():
445
+ 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."))
446
+ 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."))
447
+ 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."))
448
+
449
+ 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])
450
+ with gr.Accordion(i18n("Subtitle Settings (alpha)"), open=False):
451
+ 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)")
452
+ use_custom_subs = gr.Checkbox(label=i18n("Enable Subtitle Customization (Includes Preset)"), value=True)
453
+
454
+ # Previews (Always Visible)
455
+ preview_html = gr.HTML(value=f"<div style='text-align:center; padding:10px; color:#666;'>{i18n('Select options or preset to preview')}</div>")
456
+
457
+ with gr.Row():
458
+ preview_vid_btn = gr.Button(i18n("🎬 Render Animated Preview (Slow)"), size="sm")
459
+ preview_vid = gr.Video(label=i18n("Animated Preview"), height=300, autoplay=True, interactive=False)
460
+
461
+ with gr.Accordion(i18n("Advanced Settings"), open=False):
462
+ gr.Markdown(f"### {i18n('Appearance')}")
463
+ with gr.Row():
464
+ font_name_input = gr.Textbox(label=i18n("Font Name"), value="Montserrat-Regular")
465
+ font_size_input = gr.Slider(label=i18n("Font Size (Base)"), minimum=8, maximum=80, value=12)
466
+ highlight_size_input = gr.Slider(label=i18n("Highlight Size"), minimum=8, maximum=80, value=14)
467
+
468
+ with gr.Row():
469
+ font_color_input = gr.ColorPicker(label=i18n("Base Color"), value="#FFFFFF")
470
+ highlight_color_input = gr.ColorPicker(label=i18n("Highlight Color"), value="#00FF00")
471
+ outline_color_input = gr.ColorPicker(label=i18n("Outline Color"), value="#000000")
472
+ shadow_color_input = gr.ColorPicker(label=i18n("Shadow Color"), value="#000000")
473
+
474
+ gr.Markdown(f"### {i18n('Styling & Effects')}")
475
+ with gr.Row():
476
+ outline_thickness_input = gr.Slider(label=i18n("Outline Thickness"), minimum=0, maximum=10, value=1.5)
477
+ shadow_size_input = gr.Slider(label=i18n("Shadow Size"), minimum=0, maximum=10, value=2)
478
+ border_style_input = gr.Dropdown(choices=[(i18n("Outline"), 1), (i18n("Opaque Box"), 3)], label=i18n("Border Style"), value=1)
479
+
480
+ with gr.Row():
481
+ bold_input = gr.Checkbox(label=i18n("Bold"))
482
+ italic_input = gr.Checkbox(label=i18n("Italic"))
483
+ uppercase_input = gr.Checkbox(label=i18n("Uppercase"))
484
+ remove_punc_input = gr.Checkbox(label=i18n("Remove Punctuation"), value=True)
485
+ underline_input = gr.Checkbox(label=i18n("Underline"))
486
+ strikeout_input = gr.Checkbox(label=i18n("Strikeout"))
487
+
488
+ gr.Markdown(f"### {i18n('Positioning & Layout')}")
489
+ with gr.Row():
490
+ vertical_pos_input = gr.Slider(label=i18n("V-Pos (Margin V)"), minimum=0, maximum=500, value=210)
491
+ alignment_input = gr.Dropdown(choices=[(i18n("Left"), 1), (i18n("Center"), 2), (i18n("Right"), 3)], label=i18n("Alignment"), value=2)
492
+ gap_limit_input = gr.Slider(label=i18n("Gap Limit"), minimum=0.0, maximum=5.0, value=0.5, step=0.1)
493
+ 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")
494
+ words_per_block_input = gr.Slider(label=i18n("Words per Block"), minimum=1, maximum=20, value=3, step=1)
495
+
496
+ manual_inputs = [
497
+ font_name_input, font_size_input, font_color_input, highlight_color_input,
498
+ outline_color_input, outline_thickness_input, shadow_color_input, shadow_size_input,
499
+ bold_input, italic_input, uppercase_input,
500
+ highlight_size_input, words_per_block_input, gap_limit_input, mode_input,
501
+ underline_input, strikeout_input, border_style_input,
502
+ vertical_pos_input, alignment_input,
503
+ remove_punc_input
504
+ ]
505
+
506
+ # Update manual inputs when preset changes
507
+ preset_input.change(subs.apply_preset, inputs=[preset_input], outputs=manual_inputs)
508
+
509
+ # Auto-update PREVIEW HTML on any change
510
+ for inp in manual_inputs:
511
+ inp.change(subs.generate_preview_html, inputs=manual_inputs, outputs=preview_html)
512
+
513
+ # Render video button
514
+ preview_vid_btn.click(
515
+ subs.render_preview_video,
516
+ inputs=manual_inputs,
517
+ outputs=preview_vid
518
+ )
519
+
520
+ # Initial load
521
+ demo.load(subs.generate_preview_html, inputs=manual_inputs, outputs=preview_html)
522
+ demo.load(subs.apply_preset, inputs=[preset_input], outputs=manual_inputs) # Apply default preset on load
523
+
524
+ with gr.Row():
525
+ start_btn = gr.Button(i18n("Start Processing"), variant="primary")
526
+ stop_btn = gr.Button(i18n("Stop"), variant="stop", visible=False)
527
+ stop_btn.click(kill_process, outputs=[])
528
+ logs_output = gr.Textbox(label=i18n("Logs"), lines=10, autoscroll=True, elem_id="logs_output")
529
+
530
+ # Force scroll to bottom via JS
531
+ logs_output.change(fn=None, inputs=[], outputs=[], js="""
532
+ function() {
533
+ var ta = document.querySelector('#logs_output textarea');
534
+ if(ta) {
535
+ ta.scrollTop = ta.scrollHeight;
536
+ }
537
+ }
538
+ """)
539
+ results_html = gr.HTML(label=i18n("Results"))
540
+
541
+ # MUST pass all all new inputs to the run function
542
+ start_btn.click(run_viral_cutter, inputs=[
543
+ input_source, project_selector, url_input, video_upload, segments_input, viral_input, themes_input, min_dur_input, max_dur_input,
544
+ model_input, ai_backend_input, api_key_input, ai_model_input, chunk_size_input,
545
+ workflow_input, face_model_input, face_mode_input, face_detect_interval_input,
546
+ face_filter_thresh_input, face_two_thresh_input, face_conf_thresh_input, face_dead_zone_input, focus_active_speaker_input,
547
+ 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,
548
+ use_custom_subs,
549
+ # Expanded Manual Inputs mapping
550
+ font_name_input, font_size_input, font_color_input, highlight_color_input,
551
+ outline_color_input, outline_thickness_input, shadow_color_input, shadow_size_input,
552
+ bold_input, italic_input, uppercase_input, vertical_pos_input, alignment_input,
553
+ # New Inputs
554
+ highlight_size_input, words_per_block_input, gap_limit_input, mode_input,
555
+ underline_input, strikeout_input, border_style_input, remove_punc_input,
556
+ video_quality_input, use_youtube_subs_input, translate_input
557
+ ], outputs=[logs_output, start_btn, stop_btn, results_html])
558
+
559
+
560
+ with gr.Tab(i18n("Subtitle Editor")):
561
+ gr.Markdown(f"### {i18n('Edit Subtitles (Smart Mode)')}")
562
+
563
+ with gr.Group():
564
+ editor_project_dropdown = gr.Dropdown(choices=library.get_existing_projects(), label=i18n("Select Project"), value=None)
565
+ editor_refresh_btn = gr.Button(i18n("Refresh"), size="sm")
566
+
567
+ with gr.Group():
568
+ editor_file_dropdown = gr.Dropdown(choices=[], label=i18n("Select Subtitle File"), interactive=True)
569
+ editor_load_btn = gr.Button(i18n("Load Subtitles"), variant="secondary")
570
+
571
+ # Hidden state to store full path of currently loaded JSON
572
+ current_json_path = gr.State()
573
+
574
+ # The Dataframe Editor
575
+ # Headers: Start, End, Text
576
+ subtitle_dataframe = gr.Dataframe(
577
+ headers=["Start", "End", "Text"],
578
+ datatype=["str", "str", "str"],
579
+ col_count=(3, "fixed"),
580
+ interactive=True,
581
+ label=i18n("Subtitle Segments"),
582
+ wrap=True
583
+ )
584
+
585
+ with gr.Row():
586
+ editor_save_btn = gr.Button(i18n("💾 Save Changes"), variant="primary")
587
+ editor_render_single_btn = gr.Button(i18n(" Render This Segment (Very-Fast)"), variant="secondary")
588
+ editor_render_all_btn = gr.Button(i18n("🎬 Render All (Fast)"), variant="stop")
589
+
590
+ editor_status = gr.Textbox(label=i18n("Status"), interactive=False)
591
+
592
+ # --- Callbacks for Editor ---
593
+ editor_refresh_btn.click(library.refresh_projects, outputs=editor_project_dropdown)
594
+
595
+ def update_file_list(proj_name):
596
+ if not proj_name: return gr.update(choices=[])
597
+ proj_path = os.path.join(VIRALS_DIR, proj_name)
598
+ files = editor.list_editable_files(proj_path)
599
+ return gr.update(choices=files, value=files[0] if files else None)
600
+
601
+ editor_project_dropdown.change(update_file_list, inputs=editor_project_dropdown, outputs=editor_file_dropdown)
602
+
603
+ def load_subs(proj_name, file_name):
604
+ if not proj_name or not file_name:
605
+ return [], None, i18n("Please select project and file.")
606
+
607
+ full_path = os.path.join(VIRALS_DIR, proj_name, 'subs', file_name)
608
+ data = editor.load_transcription_for_editor(full_path)
609
+ return data, full_path, i18n("Loaded {} segments.").format(len(data))
610
+
611
+ editor_load_btn.click(load_subs, inputs=[editor_project_dropdown, editor_file_dropdown], outputs=[subtitle_dataframe, current_json_path, editor_status])
612
+
613
+ def save_subs(json_path, df):
614
+ if not json_path: return i18n("No file loaded.")
615
+ data_list = df.values.tolist() if hasattr(df, 'values') else df
616
+ msg = editor.save_editor_changes(json_path, data_list)
617
+ return msg
618
+
619
+ editor_save_btn.click(save_subs, inputs=[current_json_path, subtitle_dataframe], outputs=editor_status)
620
+
621
+ def render_single(json_path, use_custom, font_name, font_size, font_color, highlight_color,
622
+ outline_color, outline_thickness, shadow_color, shadow_size,
623
+ is_bold, is_italic, is_uppercase,
624
+ h_size, w_block, gap, mode, under, strike, border_s,
625
+ vertical_pos, alignment, remove_punc):
626
+
627
+ if not json_path: return i18n("No file loaded.")
628
+
629
+ subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
630
+
631
+ # Save config if custom subs enabled
632
+ if use_custom:
633
+ subtitle_config = {
634
+ "font": font_name, "base_size": int(font_size),
635
+ "base_color": convert_color_to_ass(font_color),
636
+ "highlight_color": convert_color_to_ass(highlight_color),
637
+ "outline_color": convert_color_to_ass(outline_color),
638
+ "outline_thickness": outline_thickness,
639
+ "shadow_color": convert_color_to_ass(shadow_color),
640
+ "shadow_size": shadow_size, "vertical_position": vertical_pos,
641
+ "alignment": alignment, "bold": 1 if is_bold else 0,
642
+ "italic": 1 if is_italic else 0,
643
+ "underline": 1 if under else 0, "strikeout": 1 if strike else 0,
644
+ "border_style": border_s, "words_per_block": int(w_block),
645
+ "gap_limit": gap, "mode": mode, "highlight_size": int(h_size),
646
+ "uppercase": 1 if is_uppercase else 0,
647
+ "remove_punctuation": remove_punc
648
+ }
649
+ try:
650
+ with open(subtitle_config_path, "w", encoding="utf-8") as f:
651
+ json.dump(subtitle_config, f, indent=4)
652
+ except Exception: pass
653
+ else:
654
+ # Remove temp config if it exists to ensure defaults are used
655
+ try:
656
+ if os.path.exists(subtitle_config_path):
657
+ os.remove(subtitle_config_path)
658
+ except Exception: pass
659
+
660
+ # We expect user to SAVE first, but we could auto-save.
661
+ # For now assume saved.
662
+ msg = editor.render_specific_video(json_path)
663
+ return msg
664
+
665
+ editor_render_single_btn.click(
666
+ render_single,
667
+ inputs=[current_json_path, use_custom_subs] + manual_inputs,
668
+ outputs=editor_status
669
+ )
670
+
671
+ def render_all(proj_name, use_custom, font_name, font_size, font_color, highlight_color,
672
+ outline_color, outline_thickness, shadow_color, shadow_size,
673
+ is_bold, is_italic, is_uppercase,
674
+ h_size, w_block, gap, mode, under, strike, border_s,
675
+ vertical_pos, alignment, remove_punc):
676
+ if not proj_name: return i18n("No project selected.")
677
+
678
+ # Save config
679
+ if use_custom:
680
+ subtitle_config = {
681
+ "font": font_name, "base_size": int(font_size),
682
+ "base_color": convert_color_to_ass(font_color),
683
+ "highlight_color": convert_color_to_ass(highlight_color),
684
+ "outline_color": convert_color_to_ass(outline_color),
685
+ "outline_thickness": outline_thickness,
686
+ "shadow_color": convert_color_to_ass(shadow_color),
687
+ "shadow_size": shadow_size, "vertical_position": vertical_pos,
688
+ "alignment": alignment, "bold": 1 if is_bold else 0,
689
+ "italic": 1 if is_italic else 0,
690
+ "underline": 1 if under else 0, "strikeout": 1 if strike else 0,
691
+ "border_style": border_s, "words_per_block": int(w_block),
692
+ "gap_limit": gap, "mode": mode, "highlight_size": int(h_size),
693
+ "uppercase": 1 if is_uppercase else 0,
694
+ "remove_punctuation": remove_punc
695
+ }
696
+ subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
697
+ try:
698
+ with open(subtitle_config_path, "w", encoding="utf-8") as f:
699
+ json.dump(subtitle_config, f, indent=4)
700
+ except Exception: pass
701
+
702
+ proj_path = os.path.join(VIRALS_DIR, proj_name)
703
+
704
+ # IMPORTANT: Pass the config file path to the command
705
+ subtitle_config_path = os.path.join(WORKING_DIR, "temp_subtitle_config.json")
706
+ cmd = [sys.executable, MAIN_SCRIPT_PATH, "--project-path", proj_path, "--workflow", "3", "--skip-prompts"]
707
+
708
+ if use_custom and os.path.exists(subtitle_config_path):
709
+ cmd.extend(["--subtitle-config", subtitle_config_path])
710
+
711
+ try:
712
+ subprocess.Popen(cmd, cwd=WORKING_DIR)
713
+ return i18n("Render All started in background... Check terminal/logs.")
714
+ except Exception as e:
715
+ return i18n("Error starting render: {}").format(e)
716
+
717
+ editor_render_all_btn.click(
718
+ render_all,
719
+ inputs=[editor_project_dropdown, use_custom_subs] + manual_inputs,
720
+ outputs=editor_status
721
+ )
722
+
723
+
724
+ with gr.Tab(i18n("Library")):
725
+ gr.Markdown(f"### {i18n('Existing Projects')}")
726
+ with gr.Row():
727
+ project_dropdown = gr.Dropdown(choices=library.get_existing_projects(), label=i18n("Select Project"), value=None)
728
+ refresh_btn = gr.Button(i18n("Refresh List"))
729
+ project_gallery_html = gr.HTML()
730
+ refresh_btn.click(library.refresh_projects, outputs=project_dropdown)
731
+ def on_select_project(proj_name): return library.generate_project_gallery(proj_name)
732
+ project_dropdown.change(on_select_project, project_dropdown, project_gallery_html)
733
+
734
+ gr.Markdown(f"""
735
+ <hr>
736
+ <div style='text-align: center; font-size: 0.9em; color: #777;'>
737
+ <p>
738
+ <strong>{i18n('Desenvolvido por Rafael Godoy')}</strong>
739
+ <br>
740
+ {i18n('Apoie o projeto, qualquer valor é bem-vindo:')}
741
+ <a href='https://nubank.com.br/pagar/1ls6a4/0QpSSbWBSq' target='_blank'><strong>{i18n('Apoiar via PIX')}</strong></a>
742
+ <br>
743
+ {i18n('100% local open source • sem assinatura')}
744
+ </p>
745
+ </div>
746
+ """)
747
+ if __name__ == "__main__":
748
+ import webbrowser
749
+ import threading
750
+ import time
751
+ import argparse
752
+
753
+ parser = argparse.ArgumentParser()
754
+ parser.add_argument("--colab", action="store_true", help="Run in Google Colab mode")
755
+ args = parser.parse_args()
756
+
757
+ if args.colab:
758
+ print("Running in Colab mode. Generating public link with Static Mounts...")
759
+ library.set_url_mode("fastapi")
760
+
761
+ # Broaden allowed paths for Colab
762
+ allowed_dirs = [VIRALS_DIR, WORKING_DIR, os.getcwd(), "."]
763
+
764
+ # Explicitly set static paths
765
+ try:
766
+ gr.set_static_paths(paths=allowed_dirs)
767
+ print(f"DEBUG: Registered static paths: {allowed_dirs}")
768
+ except AttributeError:
769
+ print("DEBUG: gr.set_static_paths not available")
770
+
771
+ print(f"DEBUG: Allowed paths for Gradio: {allowed_dirs}")
772
+
773
+ # Launch with prevent_thread_lock to allow mounting
774
+ app, local_url, share_url = demo.queue().launch(
775
+ share=True,
776
+ allowed_paths=allowed_dirs,
777
+ prevent_thread_lock=True
778
+ )
779
+
780
+ # Mount the VIRALS directory explicitly
781
+ app.mount("/virals", StaticFiles(directory=VIRALS_DIR), name="virals")
782
+ print(f"Mounted /virals to {VIRALS_DIR}")
783
+
784
+ demo.block_thread()
785
+ else:
786
+ # Check environment
787
+ is_windows = (os.name == 'nt')
788
+
789
+ library.set_url_mode("fastapi")
790
+ allowed_dirs = [VIRALS_DIR, WORKING_DIR, os.getcwd(), "."]
791
+ try:
792
+ gr.set_static_paths(paths=allowed_dirs)
793
+ except AttributeError: pass
794
+
795
+ from fastapi.responses import FileResponse
796
+ from fastapi import BackgroundTasks
797
+
798
+ # Helper to attach routes to any FastAPI app (whether created by Gradio or us)
799
+ def attach_extra_routes(fastapi_app):
800
+ fastapi_app.mount("/virals", StaticFiles(directory=VIRALS_DIR), name="virals")
801
+
802
+ @fastapi_app.get("/export_xml_api")
803
+ def export_xml_api(project: str, segment: int, background_tasks: BackgroundTasks, format: str = "premiere"):
804
+ try:
805
+ project_path = os.path.join(VIRALS_DIR, project)
806
+ script_path = os.path.join(WORKING_DIR, "scripts", "export_xml.py")
807
+ cmd = [sys.executable, script_path, "--project", project_path, "--segment", str(segment), "--format", format]
808
+ subprocess.run(cmd, check=True)
809
+ proj_name = os.path.basename(project_path)
810
+ zip_filename = f"export_{proj_name}_seg{segment}.zip"
811
+ file_path = os.path.join(project_path, zip_filename)
812
+ if os.path.exists(file_path):
813
+ return FileResponse(file_path, filename=zip_filename, media_type='application/zip')
814
+ else:
815
+ return {"error": f"File generation failed. Expected: {file_path}"}
816
+ except Exception as e:
817
+ return {"error": str(e)}
818
+
819
+ print(f"Mounted /virals to {VIRALS_DIR}")
820
+
821
+ if is_windows:
822
+ print("Running in Windows environment (using Gradio launch for convenience).")
823
+ # Windows: Use demo.launch() for convenience (auto-browser, etc)
824
+ app, local_url, share_url = demo.queue().launch(
825
+ share=False,
826
+ allowed_paths=allowed_dirs,
827
+ inbrowser=True,
828
+ server_name="0.0.0.0",
829
+ server_port=7860,
830
+ prevent_thread_lock=True
831
+ )
832
+ attach_extra_routes(app)
833
+ demo.block_thread()
834
+ else:
835
+ print("Running in Linux/Container environment (using Uvicorn for stability).")
836
+ # Linux/HF: Use Uvicorn for explicit loop control
837
+ app = FastAPI()
838
+ attach_extra_routes(app)
839
+ # Disable SSR to prevent Node proxying issues on HF Spaces
840
+ app = gr.mount_gradio_app(app, demo.queue(), path="/", allowed_paths=allowed_dirs, ssr_mode=False)
841
+ uvicorn.run(app, host="0.0.0.0", port=7860)
webui/library.py CHANGED
@@ -201,15 +201,15 @@ def generate_project_gallery(project_path_name, is_full_path=False):
201
  return f'<a href="{src}" target="_blank" style="color: #aaa; display: flex; align-items: center; justify-content: center; padding: 5px; border-radius: 50%; transition: color 0.2s;" title="{label}" onmouseover="this.style.color=\'{color_hover}\'" onmouseout="this.style.color=\'#aaa\'"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{svg_path}</svg></a>'
202
 
203
  # Premiere (Pr)
204
- export_pr = make_export_btn("premiere", "Export Premiere XML", "#d064ff", '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><path d="M9 15h6"></path><path d="M12 12v6"></path>')
205
 
206
  # Resolve (Dv)
207
- export_dv = make_export_btn("resolve", "Export DaVinci Resolve XML", "#ff6464", '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><circle cx="12" cy="14" r="3"></circle>')
208
 
209
  # Final Cut (Fc)
210
- export_fc = make_export_btn("final-cut-pro", "Export FCP XML", "#64d0ff", '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><path d="M10 12l4 2l-4 2z"></path>')
211
 
212
- export_link = f"{export_pr}{export_dv}{export_fc}"
213
 
214
  else:
215
  video_tag = f'<div style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #222; color: #666;"><span>⚠️</span><br>{i18n("External Video")}</div>'
 
201
  return f'<a href="{src}" target="_blank" style="color: #aaa; display: flex; align-items: center; justify-content: center; padding: 5px; border-radius: 50%; transition: color 0.2s;" title="{label}" onmouseover="this.style.color=\'{color_hover}\'" onmouseout="this.style.color=\'#aaa\'"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{svg_path}</svg></a>'
202
 
203
  # Premiere (Pr)
204
+ export_pr = make_export_btn("premiere", "Export Premiere XML (Split Screen – known bug)", "#d064ff", '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><path d="M9 15h6"></path><path d="M12 12v6"></path>')
205
 
206
  # Resolve (Dv)
207
+ # export_dv = make_export_btn("resolve", "Export DaVinci Resolve XML", "#ff6464", '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><circle cx="12" cy="14" r="3"></circle>')
208
 
209
  # Final Cut (Fc)
210
+ # export_fc = make_export_btn("final-cut-pro", "Export FCP XML", "#64d0ff", '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><path d="M10 12l4 2l-4 2z"></path>')
211
 
212
+ export_link = f"{export_pr}" #{export_dv}{export_fc}"
213
 
214
  else:
215
  video_tag = f'<div style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #222; color: #666;"><span>⚠️</span><br>{i18n("External Video")}</div>'