Opera8 commited on
Commit
d5b01e8
·
verified ·
1 Parent(s): b74ea70

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +160 -153
app.py CHANGED
@@ -20,7 +20,7 @@ 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
@@ -29,29 +29,23 @@ class Segment(BaseModel):
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):
@@ -64,26 +58,19 @@ def format_timestamp(seconds: float):
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+
@@ -102,13 +89,11 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
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
 
@@ -119,16 +104,10 @@ 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:
@@ -142,23 +121,15 @@ async def burn_video(payload: BurnPayload):
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)})
@@ -169,7 +140,7 @@ async def download(filename: str):
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():
@@ -178,150 +149,193 @@ async def ui():
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]);
@@ -336,15 +350,15 @@ async def ui():
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
 
@@ -356,20 +370,15 @@ async def ui():
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,
@@ -380,7 +389,7 @@ async def ui():
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
  };
@@ -395,24 +404,22 @@ async def ui():
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>
 
20
  model = WhisperModel("small", device="cpu", compute_type="int8")
21
  print("✅ AI Model Loaded!")
22
 
23
+ # --- مدل‌های ورودی ---
24
  class Segment(BaseModel):
25
  start: float
26
  end: float
 
29
  class StyleOptions(BaseModel):
30
  font: str
31
  font_size: int
32
+ primary_color: str
33
+ outline_color: str
34
+ back_type: str
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
  hex_color = hex_color.lstrip('#')
47
+ if len(hex_color) != 6: return "&H00FFFFFF"
 
 
48
  r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
 
49
  return f"&H{alpha}{b}{g}{r}"
50
 
51
  def format_timestamp(seconds: float):
 
58
  return f"{hours:01d}:{minutes:02d}:{secs:02d}.{centisecs:02d}"
59
 
60
  def generate_ass_header(style: StyleOptions):
 
 
61
  font_name = "Vazirmatn" if style.font == "vazir" else "Lalezar"
 
62
  primary_c = hex_to_ass_color(style.primary_color, "00")
 
63
 
64
+ border_style = "1"
 
65
  back_color = "&H00000000"
66
+ outline_c = hex_to_ass_color(style.outline_color, "00")
67
 
68
+ if style.back_type == "box_transparent":
 
 
 
69
  border_style = "3"
70
+ outline_c = "&H80000000"
71
  elif style.back_type == "box_solid":
72
  border_style = "3"
73
+ outline_c = hex_to_ass_color(style.outline_color, "00")
74
 
75
  header = f"""[Script Info]
76
  ScriptType: v4.00+
 
89
 
90
  def create_ass_file(payload: BurnPayload, output_path):
91
  content = generate_ass_header(payload.style)
 
92
  with open(output_path, "w", encoding="utf-8") as f:
93
  f.write(content)
94
  for seg in payload.segments:
95
  start = format_timestamp(seg.start)
96
  end = format_timestamp(seg.end)
 
97
  clean_text = seg.text.strip().replace("\n", " ")
98
  f.write(f"Dialogue: 0,{start},{end},Default,,0,0,0,,{clean_text}\n")
99
 
 
104
  try:
105
  file_id = str(uuid.uuid4())[:8]
106
  file_path = f"{TEMP_DIR}/{file_id}_raw.mp4"
107
+ with open(file_path, "wb") as f: shutil.copyfileobj(file.file, f)
108
 
 
 
 
 
109
  segments_gen, _ = model.transcribe(file_path, language="fa", beam_size=5)
110
+ segments = [{"start": s.start, "end": s.end, "text": s.text.strip()} for s in segments_gen]
 
 
 
111
 
112
  return {"file_id": file_id, "segments": segments}
113
  except Exception as e:
 
121
  output_path = f"{TEMP_DIR}/{payload.file_id}_final.mp4"
122
 
123
  if not os.path.exists(input_path):
124
+ return JSONResponse(status_code=404, content={"error": "فایل منقضی شده است."})
125
 
 
126
  create_ass_file(payload, ass_path)
127
 
 
 
128
  cmd = [
129
+ "ffmpeg", "-y", "-i", input_path, "-vf", f"ass={ass_path}",
130
+ "-c:v", "libx264", "-preset", "ultrafast", "-crf", "26", "-c:a", "copy", output_path
 
 
 
 
131
  ]
132
  subprocess.run(cmd, check=True)
 
133
  return {"url": f"/download/{payload.file_id}_final.mp4"}
134
  except Exception as e:
135
  return JSONResponse(status_code=500, content={"error": str(e)})
 
140
  if os.path.exists(path): return FileResponse(path)
141
  return JSONResponse(status_code=404, content={"error": "File not found"})
142
 
143
+ # --- UI ---
144
 
145
  @app.get("/", response_class=HTMLResponse)
146
  async def ui():
 
149
  <html lang="fa" dir="rtl">
150
  <head>
151
  <meta charset="UTF-8">
152
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
153
+ <title>زیرنویس‌ساز موبایل</title>
154
  <link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;600;800&display=swap" rel="stylesheet">
155
  <style>
156
+ :root {
157
+ --primary: #4f46e5;
158
+ --bg: #f3f4f6;
159
+ --card: #ffffff;
160
+ --text: #1f2937;
161
+ --border: #e5e7eb;
162
+ }
163
+ * { box-sizing: border-box; outline: none; -webkit-tap-highlight-color: transparent; }
164
+ body {
165
+ font-family: 'Vazirmatn', sans-serif;
166
+ background: var(--bg);
167
+ color: var(--text);
168
+ margin: 0;
169
+ padding: 15px;
170
+ font-size: 14px;
171
+ }
172
+ .main-container { max-width: 600px; margin: 0 auto; display: flex; flex-direction: column; gap: 15px; }
173
+ .card { background: var(--card); padding: 20px; border-radius: 16px; box-shadow: 0 2px 10px rgba(0,0,0,0.03); }
174
+ h2 { margin-top: 0; font-size: 1.1rem; color: var(--primary); border-bottom: 1px solid var(--border); padding-bottom: 10px; margin-bottom: 15px; }
175
 
176
  /* Uploader */
177
+ .upload-box { border: 2px dashed #c7d2fe; padding: 30px 10px; text-align: center; border-radius: 12px; background: #eef2ff; transition: 0.2s; }
178
+ .upload-box:active { background: #e0e7ff; border-color: var(--primary); }
179
+ .upload-icon { font-size: 2.5rem; margin-bottom: 10px; display: block; }
180
 
181
+ /* Editor */
182
+ .segment-list { max-height: 350px; overflow-y: auto; padding: 5px; background: #f9fafb; border-radius: 8px; border: 1px solid var(--border); }
183
+ .segment-item { background: #fff; border: 1px solid var(--border); border-radius: 10px; padding: 12px; margin-bottom: 10px; }
184
+ .time-tag { font-size: 0.7rem; background: #e5e7eb; color: #374151; padding: 2px 6px; border-radius: 4px; display: inline-block; margin-bottom: 5px; }
185
+ .text-edit { width: 100%; border: none; background: transparent; font-family: inherit; font-size: 1rem; padding: 0; resize: none; border-bottom: 1px dashed #ccc; }
186
  .text-edit:focus { border-bottom: 1px solid var(--primary); }
187
 
188
+ /* Settings */
189
+ .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
190
  .control-group { margin-bottom: 15px; }
191
+ .control-group label { display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 6px; color: #4b5563; }
192
+ input[type="color"] { width: 100%; height: 45px; border: none; border-radius: 10px; padding: 2px; background: #fff; border: 1px solid var(--border); }
193
+ input[type="range"] { width: 100%; accent-color: var(--primary); }
194
+ select { width: 100%; padding: 12px; border-radius: 10px; border: 1px solid var(--border); font-family: inherit; background: #fff; font-size: 0.9rem; }
 
 
195
 
196
+ .action-btn { width: 100%; padding: 16px; background: linear-gradient(135deg, #4f46e5, #4338ca); color: white; border: none; border-radius: 14px; font-weight: 800; font-size: 1.1rem; cursor: pointer; box-shadow: 0 4px 15px rgba(79, 70, 229, 0.3); }
197
+ .action-btn:active { transform: scale(0.98); }
 
198
 
199
+ /* Loader */
200
+ .loader-overlay { position: fixed; top:0; left:0; width:100%; height:100%; background: rgba(255,255,255,0.95); z-index: 999; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 20px; }
201
+ .spinner { width: 50px; height: 50px; border: 5px solid #e5e7eb; border-top: 5px solid var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 20px; }
202
  @keyframes spin { 100% { transform: rotate(360deg); } }
203
+
204
+ /* Result */
205
  .result-box { text-align: center; }
206
+ video { width: 100%; border-radius: 12px; background: #000; margin-top: 10px; }
207
+ .dl-link { display: block; margin-top: 15px; padding: 15px; background: #10b981; color: white; text-decoration: none; border-radius: 12px; font-weight: bold; font-size: 1.1rem; }
208
+
209
+ .hidden { display: none !important; }
210
+ .disabled-panel { opacity: 0.6; pointer-events: none; filter: grayscale(0.5); }
211
  </style>
212
  </head>
213
  <body>
214
 
215
+ <div id="mainLoader" class="loader-overlay" style="display: none;">
216
  <div class="spinner"></div>
217
+ <h3 id="loaderText">در حال پردازش...</h3>
218
+ <p style="color:#666; font-size:0.9rem;">لطفاً صفحه را نبندید</p>
219
  </div>
220
 
221
  <div class="main-container">
222
+
223
+ <div class="card" id="cardUpload">
224
+ <h2>1. انتخاب ویدیو</h2>
225
+ <div class="upload-box" onclick="document.getElementById('videoInput').click()">
226
+ <span class="upload-icon">📹</span>
227
+ <p id="uploadMsg">ویدیو را از گالری انتخاب کنید</p>
228
+ <input type="file" id="videoInput" hidden accept="video/*" onchange="uploadVideo()">
 
 
 
229
  </div>
230
+ </div>
231
 
232
+ <div class="card hidden" id="cardEditor">
233
+ <h2>2. ویرایش متن‌ها</h2>
234
+ <div class="segment-list" id="segmentsContainer"></div>
235
  </div>
236
 
237
+ <div class="card disabled-panel" id="cardSettings">
238
+ <h2>3. تنظیمات ظاهری</h2>
 
239
 
240
+ <div class="settings-grid">
241
+ <div class="control-group">
242
  <label>رنگ متن</label>
243
  <input type="color" id="primaryColor" value="#FFFFFF">
244
  </div>
245
+ <div class="control-group">
246
+ <label>رنگ کادر</label>
247
  <input type="color" id="outlineColor" value="#000000">
248
  </div>
249
  </div>
250
 
251
  <div class="control-group">
252
+ <label>استایل کادر</label>
253
  <select id="backType">
254
+ <option value="box_solid">کادر پررنگ (هرمزی)</option>
255
+ <option value="box_transparent">کادر شفاف (سینمایی)</option>
256
  <option value="outline">فقط حاشیه (Outline)</option>
 
 
257
  </select>
258
  </div>
259
 
260
+ <div class="settings-grid">
261
+ <div class="control-group">
262
  <label>فونت</label>
263
  <select id="fontSelect">
264
+ <option value="lalezar">لاله زار (ضخیم)</option>
265
  <option value="vazir">وزیر (رسمی)</option>
 
266
  </select>
267
  </div>
268
+ <div class="control-group">
269
+ <label>اندازه فونت: <span id="fontSizeVal">60</span></label>
270
+ <input type="range" id="fontSize" min="30" max="100" value="60" oninput="updateVal('fontSizeVal', this.value)">
271
+ </div>
272
  </div>
273
 
274
  <div class="control-group">
275
+ <label>موقعیت عمودی (از پایین): <span id="marginVal">150</span></label>
276
+ <input type="range" id="marginV" min="50" max="600" value="150" oninput="updateVal('marginVal', this.value)">
 
 
 
 
 
277
  </div>
278
 
279
+ <button id="burnBtn" class="action-btn" onclick="burnProcess()">ساخت ویدیو نهایی ✨</button>
280
+ </div>
 
 
281
 
282
+ <div class="card hidden" id="cardResult">
283
+ <h2>🎉 ویدیو آماده شد!</h2>
284
+ <div class="result-box">
285
+ <video id="previewVideo" controls playsinline></video>
 
286
  <a id="downloadLink" class="dl-link" href="#" download>دانلود ویدیو</a>
287
+ <button onclick="location.reload()" style="margin-top:20px; background:none; border:none; color:#6b7280; padding:10px;">شروع دوباره</button>
 
288
  </div>
289
  </div>
290
+
291
  </div>
292
 
293
  <script>
294
  let globalFileId = null;
295
  let globalSegments = [];
296
 
297
+ // --- مدیریت حافظه مرورگر (LocalStorage) ---
298
+ const settingIds = ['primaryColor', 'outlineColor', 'backType', 'fontSelect', 'fontSize', 'marginV'];
299
+
300
+ // لود کردن تنظیمات هنگام باز شدن صفحه
301
+ document.addEventListener('DOMContentLoaded', () => {
302
+ settingIds.forEach(id => {
303
+ const savedVal = localStorage.getItem(id);
304
+ if (savedVal) {
305
+ const el = document.getElementById(id);
306
+ el.value = savedVal;
307
+ // بروزرسانی عدد اسلایدرها
308
+ if(id === 'fontSize') updateVal('fontSizeVal', savedVal);
309
+ if(id === 'marginV') updateVal('marginVal', savedVal);
310
+ }
311
+ });
312
+ });
313
+
314
+ // ذخیره تنظیمات هنگام تغییر
315
+ settingIds.forEach(id => {
316
+ document.getElementById(id).addEventListener('input', (e) => {
317
+ localStorage.setItem(id, e.target.value);
318
+ });
319
+ });
320
+
321
  function updateVal(id, val) { document.getElementById(id).innerText = val; }
322
 
323
+ function showLoader(text) {
324
+ document.getElementById('loaderText').innerText = text;
325
+ document.getElementById('mainLoader').style.display = 'flex';
326
+ }
327
+ function hideLoader() {
328
+ document.getElementById('mainLoader').style.display = 'none';
329
+ }
330
+
331
  async function uploadVideo() {
332
  const fileInput = document.getElementById('videoInput');
333
  if (!fileInput.files[0]) return;
334
 
335
+ document.getElementById('cardResult').classList.add('hidden');
336
+ document.getElementById('uploadMsg').innerText = fileInput.files[0].name;
337
+
338
+ showLoader("در حال آپلود و آنالیز هوشمند...");
339
 
340
  const formData = new FormData();
341
  formData.append("file", fileInput.files[0]);
 
350
  globalSegments = data.segments;
351
 
352
  renderEditor();
 
 
353
 
354
+ document.getElementById('cardEditor').classList.remove('hidden');
355
+ document.getElementById('cardSettings').classList.remove('disabled-panel');
356
+ document.getElementById('cardEditor').scrollIntoView({behavior: 'smooth'});
357
 
358
  } catch (e) {
 
359
  alert("خطا: " + e.message);
360
+ } finally {
361
+ hideLoader();
362
  }
363
  }
364
 
 
370
  const div = document.createElement('div');
371
  div.className = 'segment-item';
372
  div.innerHTML = `
373
+ <div class="time-tag">${Math.floor(seg.start)} - ${Math.floor(seg.end)}s</div>
374
+ <textarea class="text-edit" rows="1" oninput="globalSegments[${idx}].text = this.value; this.style.height='auto'; this.style.height=this.scrollHeight+'px';">${seg.text}</textarea>
375
  `;
376
  container.appendChild(div);
377
  });
378
  }
379
 
 
 
 
 
 
380
  async function burnProcess() {
381
+ showLoader("در حال ساخت زیرنویس...");
382
 
383
  const payload = {
384
  file_id: globalFileId,
 
389
  primary_color: document.getElementById('primaryColor').value,
390
  outline_color: document.getElementById('outlineColor').value,
391
  back_type: document.getElementById('backType').value,
392
+ outline_width: 2.0,
393
  margin_v: parseInt(document.getElementById('marginV').value)
394
  }
395
  };
 
404
 
405
  if (data.error) throw new Error(data.error);
406
 
407
+ const videoEl = document.getElementById('previewVideo');
408
+ videoEl.src = data.url;
 
409
  document.getElementById('downloadLink').href = data.url;
410
 
411
+ document.getElementById('cardResult').classList.remove('hidden');
412
+ document.getElementById('cardResult').scrollIntoView({behavior: 'smooth'});
413
+ document.getElementById('cardUpload').classList.add('hidden');
414
+ document.getElementById('cardEditor').classList.add('hidden');
415
+ document.getElementById('cardSettings').classList.add('hidden');
416
+
417
  } catch (e) {
 
418
  alert("خطا در ساخت ویدیو: " + e.message);
419
+ } finally {
420
+ hideLoader();
421
  }
422
  }
 
 
 
 
 
 
 
 
423
  </script>
424
 
425
  </body>