RafaG commited on
Commit
9d080a1
·
verified ·
1 Parent(s): 2eba1d3

Upload app.py

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