Opera8 commited on
Commit
cff1609
·
verified ·
1 Parent(s): 6990fc4

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +312 -265
app.py CHANGED
@@ -2,64 +2,57 @@ import os
2
  import shutil
3
  import subprocess
4
  import uuid
5
- import json
6
  from datetime import timedelta
7
- from fastapi import FastAPI, UploadFile, File, Form, Body
8
  from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
9
- from fastapi.staticfiles import StaticFiles
10
  from faster_whisper import WhisperModel
11
  from pydantic import BaseModel
12
  from typing import List
13
 
14
  app = FastAPI()
15
 
16
- # --- تنظیمات ---
17
  TEMP_DIR = "temp"
18
  os.makedirs(TEMP_DIR, exist_ok=True)
19
 
20
- # لود مدل
21
- print("Loading Whisper AI...")
22
  model = WhisperModel("small", device="cpu", compute_type="int8")
23
- print("Model Loaded!")
24
 
25
- # --- مدل‌های داده (برای دریافت اطلاعات از سمت کاربر) ---
26
- class SegmentData(BaseModel):
27
  start: float
28
  end: float
29
  text: str
30
 
31
- class BurnRequest(BaseModel):
32
- file_id: str
33
- style: str
34
  font: str
35
- segments: List[SegmentData]
36
-
37
- # --- تعریف استایل‌ها ---
38
- STYLES_CONFIG = {
39
- "hormozi": {
40
- "name": "هرمزی (زرد)",
41
- "ass_header": "Style: Default,{font_name},60,&H00000000,&H000000FF,&H00000000,&H0000FFFF,1,0,0,0,100,100,0,0,3,2,0,2,10,10,180,1"
42
- },
43
- "cinema": {
44
- "name": "سینمایی (کادر تیره)",
45
- "ass_header": "Style: Default,{font_name},50,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,3,0,0,2,10,10,50,1"
46
- },
47
- "neon": {
48
- "name": "نئون (آبی)",
49
- "ass_header": "Style: Default,{font_name},60,&H00FFFFFF,&H000000FF,&H00FF0000,&H00000000,1,0,0,0,100,100,0,0,1,3,1,2,10,10,150,1"
50
- },
51
- "clean": {
52
- "name": "ساده (سفید با حاشیه)",
53
- "ass_header": "Style: Default,{font_name},55,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,1,2,10,10,100,1"
54
- }
55
- }
56
 
57
- FONTS_CONFIG = {
58
- "vazir": "Vazirmatn",
59
- "lalezar": "Lalezar"
60
- }
61
 
62
- # --- توابع کمکی ---
 
 
 
 
 
 
 
 
 
 
63
 
64
  def format_timestamp(seconds: float):
65
  td = timedelta(seconds=seconds)
@@ -70,10 +63,27 @@ def format_timestamp(seconds: float):
70
  centisecs = int(td.microseconds / 10000)
71
  return f"{hours:01d}:{minutes:02d}:{secs:02d}.{centisecs:02d}"
72
 
73
- def create_ass_file(segments, output_file, style_key, font_key):
74
- font_name = FONTS_CONFIG.get(font_key, "Vazirmatn")
75
- style_template = STYLES_CONFIG.get(style_key, STYLES_CONFIG["hormozi"])["ass_header"]
76
- final_style = style_template.replace("{font_name}", font_name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
  header = f"""[Script Info]
79
  ScriptType: v4.00+
@@ -83,291 +93,328 @@ WrapStyle: 0
83
 
84
  [V4+ Styles]
85
  Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
86
- {final_style}
87
 
88
  [Events]
89
  Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
90
  """
91
- with open(output_file, "w", encoding="utf-8") as f:
92
- f.write(header)
93
- for seg in segments:
 
 
 
 
 
94
  start = format_timestamp(seg.start)
95
  end = format_timestamp(seg.end)
96
- text = seg.text.strip().replace("\n", " ")
97
- f.write(f"Dialogue: 0,{start},{end},Default,,0,0,0,,{text}\n")
 
 
 
98
 
99
- def burn_subtitles(video_path, ass_path, output_path):
 
100
  try:
101
- subprocess.run([
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  "ffmpeg", "-y",
103
- "-i", video_path,
104
  "-vf", f"ass={ass_path}",
105
  "-c:v", "libx264", "-preset", "ultrafast", "-crf", "26",
106
  "-c:a", "copy",
107
  output_path
108
- ], check=True)
109
- return True
 
 
110
  except Exception as e:
111
- print(f"FFmpeg Error: {e}")
112
- return False
113
 
114
- # --- روت‌ها ---
 
 
 
 
 
 
115
 
116
  @app.get("/", response_class=HTMLResponse)
117
- async def home():
118
  return """
119
  <!DOCTYPE html>
120
  <html lang="fa" dir="rtl">
121
  <head>
122
  <meta charset="UTF-8">
123
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
124
- <title>ویرایشگر زیرنویس هوشمند</title>
125
- <link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;700;900&display=swap" rel="stylesheet">
126
  <style>
127
- :root { --primary: #4361ee; --bg: #f8f9fa; --card: #ffffff; --border: #e9ecef; }
128
- body { font-family: 'Vazirmatn', sans-serif; background: var(--bg); margin: 0; padding: 20px; }
 
 
 
 
129
 
130
- .container { max-width: 800px; margin: 0 auto; background: var(--card); padding: 25px; border-radius: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.05); }
131
- h1 { text-align: center; color: #2b2d42; margin-bottom: 30px; }
132
 
133
- /* آپلودر */
134
- .upload-area { border: 2px dashed var(--primary); border-radius: 16px; padding: 40px; text-align: center; cursor: pointer; background: #f0f4ff; transition: 0.3s; }
135
- .upload-area:hover { background: #e0e7ff; }
136
 
137
- /* ادیتور */
138
- .editor-area { display: none; margin-top: 30px; }
139
- .segment-box { background: #fff; border: 1px solid var(--border); padding: 15px; border-radius: 12px; margin-bottom: 10px; display: flex; gap: 10px; align-items: center; box-shadow: 0 2px 5px rgba(0,0,0,0.02); }
140
- .time-badge { background: #e9ecef; padding: 5px 10px; border-radius: 8px; font-size: 0.8rem; color: #495057; white-space: nowrap; direction: ltr; }
141
- .text-input { flex-grow: 1; padding: 10px; border: 1px solid #ced4da; border-radius: 8px; font-family: 'Vazirmatn'; font-size: 1rem; }
142
- .text-input:focus { border-color: var(--primary); outline: none; }
 
 
 
 
 
143
 
144
- /* تنظیمات */
145
- .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px; background: #f8f9fa; padding: 15px; border-radius: 12px; }
146
- select { width: 100%; padding: 10px; border-radius: 8px; border: 1px solid #ced4da; font-family: 'Vazirmatn'; }
147
 
148
- .btn { background: var(--primary); color: white; border: none; width: 100%; padding: 15px; border-radius: 12px; font-size: 1.1rem; font-weight: bold; cursor: pointer; transition: 0.2s; margin-top: 10px; }
149
- .btn:disabled { background: #ccc; cursor: not-allowed; }
150
- .btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(67, 97, 238, 0.3); }
151
 
152
- .loader { display: none; text-align: center; margin: 20px 0; }
153
- .spinner { width: 30px; height: 30px; border: 3px solid #f3f3f3; border-top: 3px solid var(--primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 10px; }
154
- @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
 
155
 
156
- #finalResult { display: none; text-align: center; margin-top: 30px; }
157
- video { width: 100%; max-height: 500px; border-radius: 12px; background: #000; }
158
- .dl-btn { display: inline-block; background: #2ec4b6; color: white; text-decoration: none; padding: 12px 30px; border-radius: 10px; font-weight: bold; margin-top: 10px; }
159
  </style>
160
  </head>
161
  <body>
162
- <div class="container">
163
- <h1>✂️ ویرایشگر زیرنویس هوشمند</h1>
 
 
 
 
 
 
 
 
164
 
165
- <!-- مرحله ۱: آپلود -->
166
- <div class="upload-area" id="uploadArea" onclick="document.getElementById('fileInput').click()">
167
- <div style="font-size: 40px;">📂</div>
168
- <p id="uploadText">برای انتخاب ویدیو کلیک کنید</p>
169
- <input type="file" id="fileInput" accept="video/*" hidden onchange="handleUpload(this)">
 
170
  </div>
171
 
172
- <div id="analyzeLoader" class="loader">
173
- <div class="spinner"></div>
174
- <span>در حال استخراج متن از ویدیو... (لطفا صبر کنید)</span>
175
  </div>
 
176
 
177
- <!-- مرحله ۲: ویرایش -->
178
- <div id="editorArea" class="editor-area">
179
- <div class="settings-grid">
180
- <div>
181
- <label>استایل:</label>
182
- <select id="styleSelect">
183
- <option value="hormozi">هرمزی (زرد)</option>
184
- <option value="cinema">سینمایی (تیره)</option>
185
- <option value="neon">نئون (آبی)</option>
186
- <option value="clean">ساده</option>
187
- </select>
188
- </div>
189
- <div>
190
- <label>فونت:</label>
191
- <select id="fontSelect">
192
- <option value="vazir">وزیر (رسمی)</option>
193
- <option value="lalezar">لاله زار (تیتر)</option>
194
- </select>
195
- </div>
196
  </div>
 
197
 
198
- <h3>📝 متن‌ها را ویرایش کنید:</h3>
199
- <div id="segmentsContainer"></div>
200
-
201
- <button id="burnBtn" class="btn" onclick="burnVideo()">🔥 ساخت ویدیو نهایی</button>
 
 
 
202
  </div>
203
 
204
- <div id="burnLoader" class="loader">
205
- <div class="spinner"></div>
206
- <span>در حال چسباندن زیرنویس‌ها...</span>
 
 
 
 
 
207
  </div>
208
 
209
- <!-- مرحله ۳: دانلود -->
210
- <div id="finalResult">
211
- <h3>✅ ویدیو آماده شد!</h3>
212
- <video id="previewVideo" controls></video>
213
- <br>
214
- <a id="downloadLink" class="dl-btn" download>دانلود ویدیو</a>
215
- <br><br>
216
- <button onclick="location.reload()" style="background:none; border:none; color:#666; cursor:pointer;">شروع دوباره</button>
217
  </div>
218
- </div>
219
 
220
- <script>
221
- let currentFileId = null;
222
- let currentSegments = [];
 
223
 
224
- async function handleUpload(input) {
225
- const file = input.files[0];
226
- if (!file) return;
 
227
 
228
- // UI Update
229
- document.getElementById('uploadArea').style.display = 'none';
230
- document.getElementById('analyzeLoader').style.display = 'block';
 
 
 
 
 
 
 
 
231
 
232
- const formData = new FormData();
233
- formData.append("file", file);
 
234
 
235
- try {
236
- const res = await fetch("/api/analyze", { method: "POST", body: formData });
237
- const data = await res.json();
238
 
239
- if (data.error) throw new Error(data.error);
 
 
240
 
241
- currentFileId = data.file_id;
242
- currentSegments = data.segments;
243
-
244
- renderEditor();
245
- } catch (e) {
246
- alert("خطا: " + e.message);
247
- location.reload();
248
- }
249
- }
250
 
251
- function renderEditor() {
252
- document.getElementById('analyzeLoader').style.display = 'none';
253
- const container = document.getElementById('segmentsContainer');
254
- container.innerHTML = "";
255
-
256
- currentSegments.forEach((seg, index) => {
257
- const div = document.createElement('div');
258
- div.className = 'segment-box';
259
-
260
- const time = document.createElement('div');
261
- time.className = 'time-badge';
262
- // نمایش زمان به فرمت ساده (ثانیه)
263
- time.innerText = `${Math.floor(seg.start)}s - ${Math.floor(seg.end)}s`;
264
-
265
- const input = document.createElement('input');
266
- input.type = 'text';
267
- input.className = 'text-input';
268
- input.value = seg.text.trim();
269
- input.oninput = (e) => { currentSegments[index].text = e.target.value; };
270
-
271
- div.appendChild(time);
272
- div.appendChild(input);
273
- container.appendChild(div);
274
- });
275
 
276
- document.getElementById('editorArea').style.display = 'block';
 
 
277
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
 
279
- async function burnVideo() {
280
- document.getElementById('editorArea').style.display = 'none';
281
- document.getElementById('burnLoader').style.display = 'block';
 
282
 
283
- const payload = {
284
- file_id: currentFileId,
285
- style: document.getElementById('styleSelect').value,
 
 
 
 
286
  font: document.getElementById('fontSelect').value,
287
- segments: currentSegments
288
- };
289
-
290
- try {
291
- const res = await fetch("/api/burn", {
292
- method: "POST",
293
- headers: { "Content-Type": "application/json" },
294
- body: JSON.stringify(payload)
295
- });
296
- const data = await res.json();
297
-
298
- if (data.error) throw new Error(data.error);
299
-
300
- document.getElementById('burnLoader').style.display = 'none';
301
- document.getElementById('finalResult').style.display = 'block';
302
-
303
- const videoEl = document.getElementById('previewVideo');
304
- videoEl.src = data.url;
305
- document.getElementById('downloadLink').href = data.url;
306
-
307
- } catch (e) {
308
- alert("خطا در ساخت ویدیو: " + e.message);
309
- document.getElementById('burnLoader').style.display = 'none';
310
- document.getElementById('editorArea').style.display = 'block';
311
  }
312
- }
313
- </script>
314
- </body>
315
- </html>
316
- """
317
 
318
- @app.post("/api/analyze")
319
- async def analyze_video(file: UploadFile = File(...)):
320
- try:
321
- file_id = str(uuid.uuid4())
322
- file_path = f"{TEMP_DIR}/{file_id}_raw.mp4"
323
-
324
- with open(file_path, "wb") as f:
325
- shutil.copyfileobj(file.file, f)
326
 
327
- # استخراج متن
328
- segments_gen, _ = model.transcribe(file_path, language="fa", beam_size=5)
329
-
330
- # تبدیل به فرمت JSON قابل ارسال
331
- segments_data = []
332
- for seg in segments_gen:
333
- segments_data.append({
334
- "start": seg.start,
335
- "end": seg.end,
336
- "text": seg.text
337
- })
338
 
339
- return {"file_id": file_id, "segments": segments_data}
340
-
341
- except Exception as e:
342
- return JSONResponse(status_code=500, content={"error": str(e)})
343
-
344
- @app.post("/api/burn")
345
- async def burn_video_api(data: BurnRequest):
346
- try:
347
- input_path = f"{TEMP_DIR}/{data.file_id}_raw.mp4"
348
- ass_path = f"{TEMP_DIR}/{data.file_id}.ass"
349
- output_path = f"{TEMP_DIR}/{data.file_id}_final.mp4"
350
-
351
- if not os.path.exists(input_path):
352
- return JSONResponse(status_code=404, content={"error": "فایل یافت نشد (منقضی شده)"})
353
-
354
- # 1. ساخت فایل ASS از روی دیتای ویرایش شده کاربر
355
- create_ass_file(data.segments, ass_path, data.style, data.font)
356
-
357
- # 2. چسباندن
358
- if burn_subtitles(input_path, ass_path, output_path):
359
- # پاک کردن فایل‌های موقت
360
- os.remove(input_path)
361
- os.remove(ass_path)
362
- return {"url": f"/download/{data.file_id}_final.mp4"}
363
- else:
364
- return JSONResponse(status_code=500, content={"error": "خطا در پردازش ویدیو"})
365
 
366
- except Exception as e:
367
- return JSONResponse(status_code=500, content={"error": str(e)})
 
 
 
368
 
369
- @app.get("/download/{filename}")
370
- async def download(filename: str):
371
- path = f"{TEMP_DIR}/{filename}"
372
- if os.path.exists(path): return FileResponse(path)
373
- return JSONResponse(status_code=404, content={"error": "Not Found"})
 
 
 
 
 
 
 
 
2
  import shutil
3
  import subprocess
4
  import uuid
 
5
  from datetime import timedelta
6
+ from fastapi import FastAPI, UploadFile, File
7
  from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
 
8
  from faster_whisper import WhisperModel
9
  from pydantic import BaseModel
10
  from typing import List
11
 
12
  app = FastAPI()
13
 
14
+ # --- تنظیمات سیستم ---
15
  TEMP_DIR = "temp"
16
  os.makedirs(TEMP_DIR, exist_ok=True)
17
 
18
+ # لود مدل هوش مصنوعی
19
+ print("Loading AI Model...")
20
  model = WhisperModel("small", device="cpu", compute_type="int8")
21
+ print("✅ AI Model Loaded!")
22
 
23
+ # --- مدل‌های ورودی (Schema) ---
24
+ class Segment(BaseModel):
25
  start: float
26
  end: float
27
  text: str
28
 
29
+ class StyleOptions(BaseModel):
 
 
30
  font: str
31
+ font_size: int
32
+ primary_color: str # Hex (#RRGGBB)
33
+ outline_color: str # Hex
34
+ back_type: str # 'outline', 'box_transparent', 'box_solid'
35
+ outline_width: float
36
+ margin_v: int # فاصله از پایین
37
+
38
+ class BurnPayload(BaseModel):
39
+ file_id: str
40
+ style: StyleOptions
41
+ segments: List[Segment]
 
 
 
 
 
 
 
 
 
 
42
 
43
+ # --- توابع تبدیل رنگ و زمان ---
 
 
 
44
 
45
+ def hex_to_ass_color(hex_color, alpha="00"):
46
+ """
47
+ تبدیل رنگ وب (#RRGGBB) به فرمت عجیب ASS (&HAlphaBlueGreenRed)
48
+ """
49
+ hex_color = hex_color.lstrip('#')
50
+ if len(hex_color) != 6:
51
+ return "&H00FFFFFF"
52
+
53
+ r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
54
+ # فرمت ASS برعکس است: Blue Green Red
55
+ return f"&H{alpha}{b}{g}{r}"
56
 
57
  def format_timestamp(seconds: float):
58
  td = timedelta(seconds=seconds)
 
63
  centisecs = int(td.microseconds / 10000)
64
  return f"{hours:01d}:{minutes:02d}:{secs:02d}.{centisecs:02d}"
65
 
66
+ def generate_ass_header(style: StyleOptions):
67
+ """تولید هدر فایل زیرنویس بر اساس تنظیمات دقیق کاربر"""
68
+
69
+ font_name = "Vazirmatn" if style.font == "vazir" else "Lalezar"
70
+
71
+ primary_c = hex_to_ass_color(style.primary_color, "00")
72
+ outline_c = hex_to_ass_color(style.outline_color, "00")
73
+
74
+ # تنظیمات پس‌زمینه
75
+ border_style = "1" # 1=Outline, 3=Box
76
+ back_color = "&H00000000"
77
+
78
+ if style.back_type == "outline":
79
+ border_style = "1"
80
+ back_color = "&H00000000" # مهم نیست
81
+ elif style.back_type == "box_transparent":
82
+ border_style = "3"
83
+ outline_c = "&H80000000" # مشکی ۵۰ درصد شفاف
84
+ elif style.back_type == "box_solid":
85
+ border_style = "3"
86
+ outline_c = hex_to_ass_color(style.outline_color, "00") # رنگ کادر کامل
87
 
88
  header = f"""[Script Info]
89
  ScriptType: v4.00+
 
93
 
94
  [V4+ Styles]
95
  Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
96
+ Style: Default,{font_name},{style.font_size},{primary_c},&H000000FF,{outline_c},{back_color},1,0,0,0,100,100,0,0,{border_style},{style.outline_width},0,2,10,10,{style.margin_v},1
97
 
98
  [Events]
99
  Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
100
  """
101
+ return header
102
+
103
+ def create_ass_file(payload: BurnPayload, output_path):
104
+ content = generate_ass_header(payload.style)
105
+
106
+ with open(output_path, "w", encoding="utf-8") as f:
107
+ f.write(content)
108
+ for seg in payload.segments:
109
  start = format_timestamp(seg.start)
110
  end = format_timestamp(seg.end)
111
+ # حذف کاراکترهای مزاحم
112
+ clean_text = seg.text.strip().replace("\n", " ")
113
+ f.write(f"Dialogue: 0,{start},{end},Default,,0,0,0,,{clean_text}\n")
114
+
115
+ # --- API Endpoints ---
116
 
117
+ @app.post("/api/upload")
118
+ async def upload_video(file: UploadFile = File(...)):
119
  try:
120
+ file_id = str(uuid.uuid4())[:8]
121
+ file_path = f"{TEMP_DIR}/{file_id}_raw.mp4"
122
+
123
+ with open(file_path, "wb") as f:
124
+ shutil.copyfileobj(file.file, f)
125
+
126
+ # پردازش هوش مصنوعی
127
+ segments_gen, _ = model.transcribe(file_path, language="fa", beam_size=5)
128
+
129
+ segments = []
130
+ for s in segments_gen:
131
+ segments.append({"start": s.start, "end": s.end, "text": s.text.strip()})
132
+
133
+ return {"file_id": file_id, "segments": segments}
134
+ except Exception as e:
135
+ return JSONResponse(status_code=500, content={"error": str(e)})
136
+
137
+ @app.post("/api/burn")
138
+ async def burn_video(payload: BurnPayload):
139
+ try:
140
+ input_path = f"{TEMP_DIR}/{payload.file_id}_raw.mp4"
141
+ ass_path = f"{TEMP_DIR}/{payload.file_id}.ass"
142
+ output_path = f"{TEMP_DIR}/{payload.file_id}_final.mp4"
143
+
144
+ if not os.path.exists(input_path):
145
+ return JSONResponse(status_code=404, content={"error": "فایل منقضی شده است. لطفا دوباره آپلود کنید."})
146
+
147
+ # 1. ساخت فایل ASS با استایل دقیق
148
+ create_ass_file(payload, ass_path)
149
+
150
+ # 2. رندر نهایی
151
+ # از ultrafast استفاده می‌کنیم تا کاربر معطل نشود
152
+ cmd = [
153
  "ffmpeg", "-y",
154
+ "-i", input_path,
155
  "-vf", f"ass={ass_path}",
156
  "-c:v", "libx264", "-preset", "ultrafast", "-crf", "26",
157
  "-c:a", "copy",
158
  output_path
159
+ ]
160
+ subprocess.run(cmd, check=True)
161
+
162
+ return {"url": f"/download/{payload.file_id}_final.mp4"}
163
  except Exception as e:
164
+ return JSONResponse(status_code=500, content={"error": str(e)})
 
165
 
166
+ @app.get("/download/{filename}")
167
+ async def download(filename: str):
168
+ path = f"{TEMP_DIR}/{filename}"
169
+ if os.path.exists(path): return FileResponse(path)
170
+ return JSONResponse(status_code=404, content={"error": "File not found"})
171
+
172
+ # --- رابط کاربری حرفه‌ای (Frontend) ---
173
 
174
  @app.get("/", response_class=HTMLResponse)
175
+ async def ui():
176
  return """
177
  <!DOCTYPE html>
178
  <html lang="fa" dir="rtl">
179
  <head>
180
  <meta charset="UTF-8">
181
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
182
+ <title>استودیو زیرنویس پرو</title>
183
+ <link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;600;800&display=swap" rel="stylesheet">
184
  <style>
185
+ :root { --primary: #3A86FF; --accent: #FF006E; --bg: #F8F9FA; --card: #fff; --text: #212529; }
186
+ * { box-sizing: border-box; outline: none; }
187
+ body { font-family: 'Vazirmatn', sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 20px; }
188
+
189
+ .main-container { max-width: 1100px; margin: 0 auto; display: grid; grid-template-columns: 1fr; gap: 20px; }
190
+ @media(min-width: 768px) { .main-container { grid-template-columns: 1.2fr 0.8fr; } }
191
 
192
+ .card { background: var(--card); padding: 20px; border-radius: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.05); }
193
+ h2 { margin-top: 0; font-size: 1.2rem; color: var(--primary); border-bottom: 2px solid #f1f3f5; padding-bottom: 10px; }
194
 
195
+ /* Uploader */
196
+ .upload-box { border: 2px dashed #dee2e6; padding: 40px; text-align: center; border-radius: 12px; cursor: pointer; transition: 0.3s; }
197
+ .upload-box:hover { border-color: var(--primary); background: #e7f5ff; }
198
 
199
+ /* Editor List */
200
+ .segment-list { max-height: 500px; overflow-y: auto; padding-left: 5px; }
201
+ .segment-item { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 10px; padding: 10px; margin-bottom: 10px; display: flex; gap: 10px; align-items: center; }
202
+ .time-tag { font-size: 0.75rem; background: #212529; color: #fff; padding: 2px 6px; border-radius: 4px; height: fit-content; }
203
+ .text-edit { width: 100%; border: 1px solid transparent; background: transparent; font-family: inherit; font-size: 1rem; padding: 5px; }
204
+ .text-edit:focus { border-bottom: 1px solid var(--primary); }
205
+
206
+ /* Controls */
207
+ .control-group { margin-bottom: 15px; }
208
+ .control-group label { display: block; font-size: 0.85rem; font-weight: bold; margin-bottom: 5px; color: #495057; }
209
+ .row { display: flex; gap: 10px; }
210
 
211
+ input[type="color"] { width: 100%; height: 40px; border: none; cursor: pointer; padding: 0; border-radius: 8px; }
212
+ input[type="range"] { width: 100%; }
213
+ select { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #ced4da; font-family: inherit; }
214
 
215
+ .action-btn { width: 100%; padding: 15px; background: var(--primary); color: white; border: none; border-radius: 12px; font-weight: 800; font-size: 1.1rem; cursor: pointer; transition: 0.2s; margin-top: 20px; }
216
+ .action-btn:disabled { background: #adb5bd; cursor: wait; }
217
+ .action-btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(58, 134, 255, 0.4); }
218
 
219
+ .hidden { display: none; }
220
+ .loader-overlay { position: fixed; top:0; left:0; width:100%; height:100%; background: rgba(255,255,255,0.9); z-index: 999; display: flex; flex-direction: column; justify-content: center; align-items: center; }
221
+ .spinner { width: 50px; height: 50px; border: 5px solid #f3f3f3; border-top: 5px solid var(--primary); border-radius: 50%; animation: spin 1s linear infinite; }
222
+ @keyframes spin { 100% { transform: rotate(360deg); } }
223
 
224
+ .result-box { text-align: center; }
225
+ .dl-link { display: inline-block; margin-top: 10px; padding: 10px 20px; background: #06d6a0; color: white; text-decoration: none; border-radius: 8px; font-weight: bold; }
 
226
  </style>
227
  </head>
228
  <body>
229
+
230
+ <div id="mainLoader" class="loader-overlay hidden">
231
+ <div class="spinner"></div>
232
+ <h3 id="loaderText" style="margin-top: 15px;">در حال پردازش...</h3>
233
+ </div>
234
+
235
+ <div class="main-container">
236
+ <!-- ستون راست: ویرایشگر متن -->
237
+ <div class="card">
238
+ <h2>📝 ویرایش متن‌ها</h2>
239
 
240
+ <div id="uploadSection">
241
+ <div class="upload-box" onclick="document.getElementById('videoInput').click()">
242
+ <div style="font-size: 3rem;">📤</div>
243
+ <p>کلیک کنید یا ویدیو را اینجا رها کنید</p>
244
+ <input type="file" id="videoInput" hidden accept="video/*" onchange="uploadVideo()">
245
+ </div>
246
  </div>
247
 
248
+ <div id="editorSection" class="hidden">
249
+ <div class="segment-list" id="segmentsContainer"></div>
 
250
  </div>
251
+ </div>
252
 
253
+ <!-- ستون چپ: تنظیمات گرافیکی -->
254
+ <div class="card" id="settingsPanel" style="opacity: 0.5; pointer-events: none;">
255
+ <h2>🎨 تنظیمات ظاهری</h2>
256
+
257
+ <div class="row">
258
+ <div class="control-group" style="flex:1">
259
+ <label>رنگ متن</label>
260
+ <input type="color" id="primaryColor" value="#FFFFFF">
261
+ </div>
262
+ <div class="control-group" style="flex:1">
263
+ <label>رنگ حاشیه/کادر</label>
264
+ <input type="color" id="outlineColor" value="#000000">
 
 
 
 
 
 
 
265
  </div>
266
+ </div>
267
 
268
+ <div class="control-group">
269
+ <label>حالت کادر</label>
270
+ <select id="backType">
271
+ <option value="outline">فقط حاشیه (Outline)</option>
272
+ <option value="box_transparent">کادر مشکی شفاف (سینمایی)</option>
273
+ <option value="box_solid" selected>کادر رنگی پررنگ (هرمزی)</option>
274
+ </select>
275
  </div>
276
 
277
+ <div class="row">
278
+ <div class="control-group" style="flex:1">
279
+ <label>فونت</label>
280
+ <select id="fontSelect">
281
+ <option value="vazir">وزیر (رسمی)</option>
282
+ <option value="lalezar">لاله زار (تیتر)</option>
283
+ </select>
284
+ </div>
285
  </div>
286
 
287
+ <div class="control-group">
288
+ <label>اندازه متن: <span id="fontSizeVal">60</span></label>
289
+ <input type="range" id="fontSize" min="20" max="120" value="60" oninput="updateVal('fontSizeVal', this.value)">
 
 
 
 
 
290
  </div>
 
291
 
292
+ <div class="control-group">
293
+ <label>ضخامت حاشیه: <span id="outlineWidthVal">2</span></label>
294
+ <input type="range" id="outlineWidth" min="0" max="10" step="0.5" value="2" oninput="updateVal('outlineWidthVal', this.value)">
295
+ </div>
296
 
297
+ <div class="control-group">
298
+ <label>فاصله از پایین: <span id="marginVal">150</span></label>
299
+ <input type="range" id="marginV" min="10" max="500" value="150" oninput="updateVal('marginVal', this.value)">
300
+ </div>
301
 
302
+ <button id="burnBtn" class="action-btn" onclick="burnProcess()">🔥 ساخت و دانلود</button>
303
+
304
+ <div id="finalSection" class="result-box hidden">
305
+ <hr>
306
+ <p>✅ ویدیو آماده شد!</p>
307
+ <a id="downloadLink" class="dl-link" href="#" download>دانلود ویدیو</a>
308
+ <br>
309
+ <button onclick="location.reload()" style="margin-top:10px; background:none; border:none; color:#999; cursor:pointer;">شروع مجدد</button>
310
+ </div>
311
+ </div>
312
+ </div>
313
 
314
+ <script>
315
+ let globalFileId = null;
316
+ let globalSegments = [];
317
 
318
+ function updateVal(id, val) { document.getElementById(id).innerText = val; }
 
 
319
 
320
+ async function uploadVideo() {
321
+ const fileInput = document.getElementById('videoInput');
322
+ if (!fileInput.files[0]) return;
323
 
324
+ showLoader("در حال آپلود و هوش مصنوعی... (ممکن است ۱-۲ دقیقه طول بکشد)");
325
+
326
+ const formData = new FormData();
327
+ formData.append("file", fileInput.files[0]);
 
 
 
 
 
328
 
329
+ try {
330
+ const res = await fetch("/api/upload", { method: "POST", body: formData });
331
+ const data = await res.json();
332
+
333
+ if (data.error) throw new Error(data.error);
334
+
335
+ globalFileId = data.file_id;
336
+ globalSegments = data.segments;
337
+
338
+ renderEditor();
339
+ enableSettings();
340
+ hideLoader();
341
+
342
+ document.getElementById('uploadSection').classList.add('hidden');
343
+ document.getElementById('editorSection').classList.remove('hidden');
 
 
 
 
 
 
 
 
 
344
 
345
+ } catch (e) {
346
+ hideLoader();
347
+ alert("خطا: " + e.message);
348
  }
349
+ }
350
+
351
+ function renderEditor() {
352
+ const container = document.getElementById('segmentsContainer');
353
+ container.innerHTML = "";
354
+
355
+ globalSegments.forEach((seg, idx) => {
356
+ const div = document.createElement('div');
357
+ div.className = 'segment-item';
358
+ div.innerHTML = `
359
+ <span class="time-tag">${Math.round(seg.start)}s</span>
360
+ <input class="text-edit" value="${seg.text}" oninput="globalSegments[${idx}].text = this.value">
361
+ `;
362
+ container.appendChild(div);
363
+ });
364
+ }
365
 
366
+ function enableSettings() {
367
+ document.getElementById('settingsPanel').style.opacity = "1";
368
+ document.getElementById('settingsPanel').style.pointerEvents = "all";
369
+ }
370
 
371
+ async function burnProcess() {
372
+ showLoader("در حال چسباندن زیرنویس روی ویدیو...");
373
+
374
+ const payload = {
375
+ file_id: globalFileId,
376
+ segments: globalSegments,
377
+ style: {
378
  font: document.getElementById('fontSelect').value,
379
+ font_size: parseInt(document.getElementById('fontSize').value),
380
+ primary_color: document.getElementById('primaryColor').value,
381
+ outline_color: document.getElementById('outlineColor').value,
382
+ back_type: document.getElementById('backType').value,
383
+ outline_width: parseFloat(document.getElementById('outlineWidth').value),
384
+ margin_v: parseInt(document.getElementById('marginV').value)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  }
386
+ };
 
 
 
 
387
 
388
+ try {
389
+ const res = await fetch("/api/burn", {
390
+ method: "POST",
391
+ headers: { "Content-Type": "application/json" },
392
+ body: JSON.stringify(payload)
393
+ });
394
+ const data = await res.json();
 
395
 
396
+ if (data.error) throw new Error(data.error);
 
 
 
 
 
 
 
 
 
 
397
 
398
+ hideLoader();
399
+ document.getElementById('burnBtn').classList.add('hidden');
400
+ document.getElementById('finalSection').classList.remove('hidden');
401
+ document.getElementById('downloadLink').href = data.url;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
 
403
+ } catch (e) {
404
+ hideLoader();
405
+ alert("خطا در س��خت ویدیو: " + e.message);
406
+ }
407
+ }
408
 
409
+ function showLoader(text) {
410
+ document.getElementById('loaderText').innerText = text;
411
+ document.getElementById('mainLoader').classList.remove('hidden');
412
+ }
413
+ function hideLoader() {
414
+ document.getElementById('mainLoader').classList.add('hidden');
415
+ }
416
+ </script>
417
+
418
+ </body>
419
+ </html>
420
+ """