Opera8 commited on
Commit
e0266c8
·
verified ·
1 Parent(s): 9fa13c7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +667 -301
app.py CHANGED
@@ -2,53 +2,76 @@ import os
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
- # --- مدل‌های ورودی ---
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
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):
52
  td = timedelta(seconds=seconds)
53
  total_seconds = int(td.total_seconds())
54
  hours = total_seconds // 3600
@@ -57,372 +80,715 @@ def format_timestamp(seconds: float):
57
  centisecs = int(td.microseconds / 10000)
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+
77
  PlayResX: 1080
78
  PlayResY: 1920
79
- WrapStyle: 0
80
 
81
  [V4+ Styles]
82
  Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
83
- 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
84
 
85
  [Events]
86
  Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
87
  """
88
- return header
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
 
100
- # --- API Endpoints ---
 
 
101
 
102
- @app.post("/api/upload")
103
- async def upload_video(file: UploadFile = File(...)):
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:
114
  return JSONResponse(status_code=500, content={"error": str(e)})
115
 
116
- @app.post("/api/burn")
117
- async def burn_video(payload: BurnPayload):
118
  try:
119
- input_path = f"{TEMP_DIR}/{payload.file_id}_raw.mp4"
120
- ass_path = f"{TEMP_DIR}/{payload.file_id}.ass"
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)})
136
 
137
  @app.get("/download/{filename}")
138
- async def download(filename: str):
139
  path = f"{TEMP_DIR}/{filename}"
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():
147
  return """
148
  <!DOCTYPE html>
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: 300px; 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); margin-bottom: 15px; }
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 Box */
205
- .result-container {
206
- margin-top: 20px;
207
- padding-top: 20px;
208
- border-top: 2px dashed #e5e7eb;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  text-align: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  }
211
- video { width: 100%; border-radius: 12px; background: #000; margin-top: 10px; box-shadow: 0 10px 30px -10px rgba(0,0,0,0.2); }
212
- .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; }
 
 
 
 
213
 
214
- .hidden { display: none !important; }
215
- .disabled-panel { opacity: 0.6; pointer-events: none; filter: grayscale(0.5); }
216
  </style>
217
  </head>
218
  <body>
219
 
220
- <div id="mainLoader" class="loader-overlay" style="display: none;">
221
- <div class="spinner"></div>
222
- <h3 id="loaderText">در حال پردازش...</h3>
223
- <p style="color:#666; font-size:0.9rem;">لطفاً صفحه را نبندید</p>
224
- </div>
225
 
226
- <div class="main-container">
227
-
228
- <div class="card" id="cardUpload">
229
- <h2>1. انتخاب ویدیو</h2>
230
- <div class="upload-box" onclick="document.getElementById('videoInput').click()">
231
- <span class="upload-icon">📹</span>
232
- <p id="uploadMsg">ویدیو را انتخاب کنید</p>
233
- <input type="file" id="videoInput" hidden accept="video/*" onchange="uploadVideo()">
234
- </div>
235
  </div>
236
 
237
- <div class="card hidden" id="cardEditor">
238
- <h2>2. ویرایش متن‌ها</h2>
239
- <div class="segment-list" id="segmentsContainer"></div>
240
- </div>
 
241
 
242
- <div class="card disabled-panel" id="cardSettings">
243
- <h2>3. تنظیمات و خروجی</h2>
244
 
245
- <div class="settings-grid">
246
- <div class="control-group">
247
- <label>رنگ متن</label>
248
- <input type="color" id="primaryColor" value="#FFFFFF">
249
- </div>
250
- <div class="control-group">
251
- <label>رنگ کادر</label>
252
- <input type="color" id="outlineColor" value="#000000">
 
 
 
 
253
  </div>
254
  </div>
255
 
256
- <div class="control-group">
257
- <label>استایل کادر</label>
258
- <select id="backType">
259
- <option value="box_solid" selected>کادر پررنگ (هرمزی)</option>
260
- <option value="box_transparent">کادر شفاف (سینمایی)</option>
261
- <option value="outline">فقط حاشیه (Outline)</option>
262
- </select>
263
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
- <div class="settings-grid">
266
- <div class="control-group">
267
- <label>فونت</label>
268
- <select id="fontSelect">
269
- <option value="lalezar">لاله زار (ضخیم)</option>
270
- <option value="vazir">وزیر (رسمی)</option>
271
- </select>
272
- </div>
273
- <div class="control-group">
274
- <label>اندازه فونت: <span id="fontSizeVal">60</span></label>
275
- <input type="range" id="fontSize" min="30" max="100" value="60" oninput="updateVal('fontSizeVal', this.value)">
276
  </div>
277
  </div>
278
 
279
- <div class="control-group">
280
- <label>موقعیت عمودی (از پایین): <span id="marginVal">150</span></label>
281
- <input type="range" id="marginV" min="50" max="600" value="150" oninput="updateVal('marginVal', this.value)">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  </div>
283
 
284
- <button id="burnBtn" class="action-btn" onclick="burnProcess()">ساخت ویدیو نهایی ✨</button>
285
-
286
- <!-- محل نمایش ویدیو خروجی (زیر دکمه) -->
287
- <div id="resultSection" class="result-container hidden">
288
- <h3 style="color:#10b981; margin:0 0 10px 0;">✅ ویدیو آماده شد!</h3>
289
- <video id="previewVideo" controls playsinline></video>
290
- <a id="downloadLink" class="dl-link" href="#" download>دانلود ویدیو</a>
291
- </div>
292
  </div>
293
 
294
- </div>
 
 
 
 
 
 
 
 
 
 
295
 
296
- <script>
297
- let globalFileId = null;
298
- let globalSegments = [];
 
 
299
 
300
- // --- مدیریت حافظه مرورگر ---
301
- const settingIds = ['primaryColor', 'outlineColor', 'backType', 'fontSelect', 'fontSize', 'marginV'];
302
-
303
- document.addEventListener('DOMContentLoaded', () => {
304
- settingIds.forEach(id => {
305
- const savedVal = localStorage.getItem(id);
306
- if (savedVal) {
307
- const el = document.getElementById(id);
308
- el.value = savedVal;
309
- if(id === 'fontSize') updateVal('fontSizeVal', savedVal);
310
- if(id === 'marginV') updateVal('marginVal', savedVal);
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('resultSection').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]);
342
 
343
- try {
344
- const res = await fetch("/api/upload", { method: "POST", body: formData });
345
- const data = await res.json();
346
-
347
- if (data.error) throw new Error(data.error);
348
-
349
- globalFileId = data.file_id;
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
 
365
- function renderEditor() {
366
- const container = document.getElementById('segmentsContainer');
367
- container.innerHTML = "";
368
-
369
- globalSegments.forEach((seg, idx) => {
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,
385
- segments: globalSegments,
386
- style: {
387
- font: document.getElementById('fontSelect').value,
388
- font_size: parseInt(document.getElementById('fontSize').value),
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
- };
396
 
397
- try {
398
- const res = await fetch("/api/burn", {
399
- method: "POST",
400
- headers: { "Content-Type": "application/json" },
401
- body: JSON.stringify(payload)
402
- });
403
- const data = await res.json();
404
-
405
- if (data.error) throw new Error(data.error);
406
-
407
- // نمایش ویدیو
408
- const videoEl = document.getElementById('previewVideo');
409
- // اضافه کردن تایم‌استمپ برای جلوگیری از کش شدن ویدیوی قبلی
410
- videoEl.src = data.url + "?t=" + new Date().getTime();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
 
412
- document.getElementById('downloadLink').href = data.url;
 
 
 
 
 
 
413
 
414
- // باز کردن بخش نتیجه (بدون بستن ادیتور)
415
- document.getElementById('resultSection').classList.remove('hidden');
416
- document.getElementById('resultSection').scrollIntoView({behavior: 'smooth'});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
 
418
- } catch (e) {
419
- alert("خطا در ساخت ویدیو: " + e.message);
420
- } finally {
421
- hideLoader();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  }
423
- }
424
- </script>
425
 
 
426
  </body>
427
  </html>
428
  """
 
2
  import shutil
3
  import subprocess
4
  import uuid
5
+ import json
6
+ import asyncio
7
  from datetime import timedelta
8
+ from typing import List, Optional
9
+ from fastapi import FastAPI, UploadFile, File, Form, Body, HTTPException
10
  from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
11
+ from fastapi.middleware.cors import CORSMiddleware
12
  from faster_whisper import WhisperModel
13
  from pydantic import BaseModel
 
14
 
15
+ # ==========================================
16
+ # 1. CONFIGURATION & AI SETUP
17
+ # ==========================================
18
 
19
+ app = FastAPI(title="AI Subtitle Studio Pro")
20
+
21
+ # CORS Setup
22
+ app.add_middleware(
23
+ CORSMiddleware,
24
+ allow_origins=["*"],
25
+ allow_methods=["*"],
26
+ allow_headers=["*"],
27
+ )
28
+
29
+ # Paths
30
  TEMP_DIR = "temp"
31
  os.makedirs(TEMP_DIR, exist_ok=True)
32
 
33
+ # Load Model (Global)
34
+ print(" [SYSTEM] Initializing AI Neural Network...")
35
  model = WhisperModel("small", device="cpu", compute_type="int8")
36
+ print("✅ [SYSTEM] AI Core Ready.")
37
 
38
+ # ==========================================
39
+ # 2. DATA MODELS
40
+ # ==========================================
41
+
42
+ class SubtitleSegment(BaseModel):
43
+ id: int
44
  start: float
45
  end: float
46
  text: str
47
 
48
+ class StyleConfig(BaseModel):
49
  font: str
50
+ fontSize: int
51
+ primaryColor: str
52
+ outlineColor: str
53
+ backType: str # 'solid', 'transparent', 'outline'
54
+ outlineWidth: float
55
+ marginV: int
56
+ alignment: int # 2=Bottom, 5=Top, 10=Center
57
+
58
+ class ProcessRequest(BaseModel):
59
  file_id: str
60
+ segments: List[SubtitleSegment]
61
+ style: StyleConfig
62
 
63
+ # ==========================================
64
+ # 3. UTILITY FUNCTIONS
65
+ # ==========================================
66
 
67
+ def hex_to_ass(hex_color, alpha="00"):
68
+ """Converts HEX #RRGGBB to ASS &HABGR"""
69
  hex_color = hex_color.lstrip('#')
70
  if len(hex_color) != 6: return "&H00FFFFFF"
71
  r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
72
  return f"&H{alpha}{b}{g}{r}"
73
 
74
+ def format_time_ass(seconds: float):
75
  td = timedelta(seconds=seconds)
76
  total_seconds = int(td.total_seconds())
77
  hours = total_seconds // 3600
 
80
  centisecs = int(td.microseconds / 10000)
81
  return f"{hours:01d}:{minutes:02d}:{secs:02d}.{centisecs:02d}"
82
 
83
+ def generate_ass_file(data: ProcessRequest, output_path: str):
84
+ s = data.style
85
+
86
+ # Mapping fonts
87
+ font_map = {"vazir": "Vazirmatn", "lalezar": "Lalezar"}
88
+ font_name = font_map.get(s.font, "Arial")
89
 
90
+ # Colors
91
+ primary = hex_to_ass(s.primaryColor)
92
+ outline = hex_to_ass(s.outlineColor)
93
+
94
+ # Background Logic
95
+ border_style = 1
96
  back_color = "&H00000000"
 
97
 
98
+ if s.backType == 'solid':
99
+ border_style = 3 # Opaque Box
100
+ outline = hex_to_ass(s.outlineColor, "00") # Fully Opaque
101
+ elif s.backType == 'transparent':
102
+ border_style = 3
103
+ outline = "&H80000000" # 50% Transparent Black
104
+ else: # Outline only
105
+ border_style = 1
106
+
107
  header = f"""[Script Info]
108
  ScriptType: v4.00+
109
  PlayResX: 1080
110
  PlayResY: 1920
111
+ WrapStyle: 1
112
 
113
  [V4+ Styles]
114
  Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
115
+ Style: Default,{font_name},{s.fontSize},{primary},&H000000FF,{outline},{back_color},1,0,0,0,100,100,0,0,{border_style},{s.outlineWidth},0,{s.alignment},10,10,{s.marginV},1
116
 
117
  [Events]
118
  Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
119
  """
 
 
 
 
120
  with open(output_path, "w", encoding="utf-8") as f:
121
+ f.write(header)
122
+ for seg in data.segments:
123
+ start = format_time_ass(seg.start)
124
+ end = format_time_ass(seg.end)
125
+ # Clean text for ASS
126
+ clean_text = seg.text.strip().replace("\n", "\\N")
127
  f.write(f"Dialogue: 0,{start},{end},Default,,0,0,0,,{clean_text}\n")
128
 
129
+ # ==========================================
130
+ # 4. API ENDPOINTS
131
+ # ==========================================
132
 
133
+ @app.post("/api/analyze")
134
+ async def analyze_media(file: UploadFile = File(...)):
135
  try:
136
+ file_id = str(uuid.uuid4())[:12]
137
+ file_ext = file.filename.split('.')[-1]
138
+ input_path = f"{TEMP_DIR}/{file_id}.{file_ext}"
139
+
140
+ with open(input_path, "wb") as f:
141
+ shutil.copyfileobj(file.file, f)
142
+
143
+ # Whisper Transcribe
144
+ segments_gen, _ = model.transcribe(input_path, language="fa", beam_size=5)
145
 
146
+ results = []
147
+ for idx, seg in enumerate(segments_gen):
148
+ results.append({
149
+ "id": idx,
150
+ "start": seg.start,
151
+ "end": seg.end,
152
+ "text": seg.text.strip()
153
+ })
154
 
155
+ return {"status": "success", "file_id": file_id, "ext": file_ext, "segments": results}
156
+
157
  except Exception as e:
158
  return JSONResponse(status_code=500, content={"error": str(e)})
159
 
160
+ @app.post("/api/render")
161
+ async def render_video(data: ProcessRequest):
162
  try:
163
+ # Input paths
164
+ # We need to find the original file extension since we only have ID
165
+ # Simple hack: check common extensions
166
+ exts = ["mp4", "mov", "avi", "mkv", "mp3"]
167
+ input_path = None
168
+ for ext in exts:
169
+ test_path = f"{TEMP_DIR}/{data.file_id}.{ext}"
170
+ if os.path.exists(test_path):
171
+ input_path = test_path
172
+ break
173
 
174
+ if not input_path:
175
+ return JSONResponse(status_code=404, content={"error": "Source file lost"})
176
 
177
+ ass_path = f"{TEMP_DIR}/{data.file_id}.ass"
178
+ output_path = f"{TEMP_DIR}/{data.file_id}_final.mp4"
179
+
180
+ # 1. Generate ASS
181
+ generate_ass_file(data, ass_path)
182
 
183
+ # 2. FFmpeg Burn
184
  cmd = [
185
+ "ffmpeg", "-y",
186
+ "-i", input_path,
187
+ "-vf", f"ass={ass_path}",
188
+ "-c:v", "libx264", "-preset", "ultrafast", "-crf", "26",
189
+ "-c:a", "aac",
190
+ output_path
191
  ]
192
+ subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
193
+
194
+ return {"status": "done", "url": f"/download/{data.file_id}_final.mp4"}
195
+
196
  except Exception as e:
197
  return JSONResponse(status_code=500, content={"error": str(e)})
198
 
199
  @app.get("/download/{filename}")
200
+ async def get_file(filename: str):
201
  path = f"{TEMP_DIR}/{filename}"
202
+ if os.path.exists(path):
203
+ return FileResponse(path)
204
+ return JSONResponse(status_code=404, content={"error": "File missing"})
205
 
206
+ # ==========================================
207
+ # 5. THE MONSTER FRONTEND (SPA)
208
+ # ==========================================
209
 
210
  @app.get("/", response_class=HTMLResponse)
211
+ async def interface():
212
  return """
213
  <!DOCTYPE html>
214
  <html lang="fa" dir="rtl">
215
  <head>
216
  <meta charset="UTF-8">
217
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
218
+ <title>AI Subtitle Monster</title>
219
+ <link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;300;500;700;900&display=swap" rel="stylesheet">
220
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
221
  <style>
222
+ :root {
223
+ --bg-dark: #0f172a;
224
+ --bg-card: #1e293b;
225
+ --primary: #6366f1;
226
+ --primary-glow: rgba(99, 102, 241, 0.5);
227
+ --accent: #ec4899;
228
+ --text-main: #f8fafc;
229
+ --text-muted: #94a3b8;
230
+ --border: #334155;
231
+ --success: #10b981;
232
  }
233
+
234
  * { box-sizing: border-box; outline: none; -webkit-tap-highlight-color: transparent; }
 
 
 
 
 
 
 
 
 
 
 
235
 
236
+ body {
237
+ font-family: 'Vazirmatn', sans-serif;
238
+ background-color: var(--bg-dark);
239
+ color: var(--text-main);
240
+ margin: 0;
241
+ padding: 0;
242
+ overflow-x: hidden;
243
+ height: 100vh;
244
+ display: flex;
245
+ flex-direction: column;
246
+ }
247
+
248
+ /* --- ANIMATED BACKGROUND --- */
249
+ .bg-mesh {
250
+ position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: -1;
251
+ background: radial-gradient(circle at 50% 50%, #1e1b4b 0%, #0f172a 100%);
252
+ overflow: hidden;
253
+ }
254
+ .blob {
255
+ position: absolute;
256
+ filter: blur(80px);
257
+ opacity: 0.4;
258
+ animation: float 10s infinite ease-in-out;
259
+ }
260
+ .blob-1 { top: -10%; left: -10%; width: 50vw; height: 50vw; background: var(--primary); }
261
+ .blob-2 { bottom: -10%; right: -10%; width: 40vw; height: 40vw; background: var(--accent); animation-delay: -5s; }
262
+ @keyframes float { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(30px, 50px); } }
263
+
264
+ /* --- LAYOUT --- */
265
+ .app-header {
266
+ padding: 15px 20px;
267
+ background: rgba(30, 41, 59, 0.8);
268
+ backdrop-filter: blur(10px);
269
+ border-bottom: 1px solid var(--border);
270
+ display: flex;
271
+ justify-content: space-between;
272
+ align-items: center;
273
+ z-index: 100;
274
+ }
275
+ .brand { font-weight: 900; font-size: 1.4rem; background: linear-gradient(to right, var(--primary), var(--accent)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; letter-spacing: -1px; }
276
+
277
+ .app-body {
278
+ flex: 1;
279
+ display: flex;
280
+ flex-direction: column;
281
+ overflow: hidden;
282
+ position: relative;
283
+ }
284
+
285
+ /* --- VIEWS SYSTEM --- */
286
+ .view {
287
+ position: absolute; top: 0; left: 0; width: 100%; height: 100%;
288
+ padding: 20px;
289
+ overflow-y: auto;
290
+ transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s;
291
+ opacity: 0; transform: scale(0.95); pointer-events: none;
292
+ }
293
+ .view.active { opacity: 1; transform: scale(1); pointer-events: all; }
294
+
295
+ /* --- COMPONENTS --- */
296
+ .card {
297
+ background: var(--bg-card);
298
+ border: 1px solid var(--border);
299
+ border-radius: 20px;
300
+ padding: 25px;
301
+ margin-bottom: 20px;
302
+ box-shadow: 0 10px 30px -10px rgba(0,0,0,0.3);
303
+ }
304
+
305
+ .btn {
306
+ width: 100%;
307
+ padding: 16px;
308
+ border-radius: 14px;
309
+ border: none;
310
+ font-weight: 800;
311
+ font-size: 1rem;
312
+ cursor: pointer;
313
+ transition: 0.2s;
314
+ display: flex;
315
+ align-items: center;
316
+ justify-content: center;
317
+ gap: 10px;
318
+ }
319
+ .btn-primary {
320
+ background: linear-gradient(135deg, var(--primary), #4338ca);
321
+ color: white;
322
+ box-shadow: 0 0 20px var(--primary-glow);
323
+ }
324
+ .btn-primary:active { transform: scale(0.98); }
325
+
326
+ /* --- UPLOADER --- */
327
+ .upload-zone {
328
+ border: 2px dashed var(--border);
329
+ border-radius: 20px;
330
+ height: 300px;
331
+ display: flex;
332
+ flex-direction: column;
333
+ justify-content: center;
334
+ align-items: center;
335
+ cursor: pointer;
336
+ transition: 0.3s;
337
+ background: rgba(255,255,255,0.02);
338
+ }
339
+ .upload-zone:hover { border-color: var(--primary); background: rgba(99, 102, 241, 0.05); }
340
+ .upload-icon { font-size: 4rem; margin-bottom: 20px; color: var(--text-muted); transition: 0.3s; }
341
+ .upload-zone:hover .upload-icon { transform: scale(1.1) rotate(-10deg); color: var(--primary); }
342
+
343
+ /* --- EDITOR --- */
344
+ .editor-layout {
345
+ display: grid;
346
+ grid-template-columns: 1fr;
347
+ gap: 20px;
348
+ height: 100%;
349
+ }
350
+ @media(min-width: 1024px) {
351
+ .editor-layout { grid-template-columns: 350px 1fr; }
352
+ }
353
+
354
+ .segments-container {
355
+ flex: 1;
356
+ overflow-y: auto;
357
+ padding-right: 5px;
358
+ }
359
+
360
+ .segment-row {
361
+ background: rgba(255,255,255,0.03);
362
+ border-radius: 12px;
363
+ padding: 15px;
364
+ margin-bottom: 12px;
365
+ border-right: 3px solid transparent;
366
+ transition: 0.2s;
367
+ }
368
+ .segment-row:focus-within { border-right-color: var(--accent); background: rgba(255,255,255,0.06); }
369
 
370
+ .seg-time { font-size: 0.75rem; color: var(--text-muted); margin-bottom: 5px; font-family: monospace; }
371
+ .seg-input {
372
+ width: 100%;
373
+ background: transparent;
374
+ border: none;
375
+ color: var(--text-main);
376
+ font-size: 1.1rem;
377
+ font-family: inherit;
378
+ resize: none;
379
+ overflow: hidden;
380
+ }
381
+
382
+ /* --- SETTINGS PANEL --- */
383
+ .settings-panel {
384
+ background: var(--bg-card);
385
+ border-radius: 16px;
386
+ padding: 20px;
387
+ height: fit-content;
388
+ }
389
+ .control-group { margin-bottom: 18px; }
390
+ .control-label { display: flex; justify-content: space-between; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 8px; }
391
 
392
+ /* Modern Inputs */
393
+ input[type="range"] {
394
+ width: 100%;
395
+ height: 6px;
396
+ background: var(--border);
397
+ border-radius: 5px;
398
+ appearance: none;
399
+ }
400
+ input[type="range"]::-webkit-slider-thumb {
401
+ appearance: none;
402
+ width: 18px; height: 18px;
403
+ background: var(--primary);
404
+ border-radius: 50%;
405
+ cursor: pointer;
406
+ box-shadow: 0 0 10px var(--primary-glow);
407
+ }
408
 
409
+ input[type="color"] {
410
+ width: 100%; height: 40px;
411
+ border: none; border-radius: 8px;
412
+ cursor: pointer;
413
+ background: transparent;
414
+ }
415
+
416
+ .style-chips { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 5px; }
417
+ .chip {
418
+ padding: 8px 16px;
419
+ background: var(--border);
420
+ border-radius: 20px;
421
+ font-size: 0.85rem;
422
+ cursor: pointer;
423
+ white-space: nowrap;
424
+ transition: 0.2s;
425
+ border: 1px solid transparent;
426
+ }
427
+ .chip.active {
428
+ background: rgba(99, 102, 241, 0.2);
429
+ color: var(--primary);
430
+ border-color: var(--primary);
431
+ }
432
+
433
+ /* --- PREVIEW BOX (Simulated) --- */
434
+ .preview-box {
435
+ position: relative;
436
+ width: 100%;
437
+ aspect-ratio: 16/9;
438
+ background: #000;
439
+ border-radius: 12px;
440
+ margin-bottom: 20px;
441
+ display: flex;
442
+ align-items: center;
443
+ justify-content: center;
444
+ overflow: hidden;
445
+ background-image: url('https://images.unsplash.com/photo-1492691527719-9d1e07e534b4?ixlib=rb-1.2.1&auto=format&fit=crop&w=1000&q=80');
446
+ background-size: cover;
447
+ background-position: center;
448
+ }
449
+ .preview-text {
450
+ position: absolute;
451
  text-align: center;
452
+ pointer-events: none;
453
+ transition: 0.1s;
454
+ line-height: 1.4;
455
+ max-width: 80%;
456
+ }
457
+
458
+ /* --- LOADER OVERLAY --- */
459
+ .loader-screen {
460
+ position: fixed; top:0; left:0; width:100%; height:100%;
461
+ background: rgba(15, 23, 42, 0.95);
462
+ z-index: 1000;
463
+ display: none;
464
+ flex-direction: column;
465
+ justify-content: center;
466
+ align-items: center;
467
+ }
468
+ .loader-screen.flex { display: flex; }
469
+ .dna-loader {
470
+ display: flex; gap: 5px; margin-bottom: 20px;
471
+ }
472
+ .dna-dot {
473
+ width: 10px; height: 10px; background: var(--primary);
474
+ border-radius: 50%;
475
+ animation: bounce 1s infinite ease-in-out;
476
  }
477
+ .dna-dot:nth-child(2) { animation-delay: 0.1s; background: var(--accent); }
478
+ .dna-dot:nth-child(3) { animation-delay: 0.2s; }
479
+ @keyframes bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-15px); } }
480
+
481
+ /* --- RESULT PAGE --- */
482
+ .result-video { width: 100%; border-radius: 12px; box-shadow: 0 0 30px rgba(0,0,0,0.5); }
483
 
 
 
484
  </style>
485
  </head>
486
  <body>
487
 
488
+ <!-- BACKGROUND -->
489
+ <div class="bg-mesh"><div class="blob blob-1"></div><div class="blob blob-2"></div></div>
 
 
 
490
 
491
+ <!-- LOADER -->
492
+ <div id="loader" class="loader-screen">
493
+ <div class="dna-loader"><div class="dna-dot"></div><div class="dna-dot"></div><div class="dna-dot"></div></div>
494
+ <h3 id="loaderMsg">در حال پردازش هوش مصنوعی...</h3>
495
+ <p style="color: var(--text-muted); font-size: 0.9rem;">لطفاً صبر کنید</p>
 
 
 
 
496
  </div>
497
 
498
+ <!-- HEADER -->
499
+ <header class="app-header">
500
+ <div class="brand"><i class="fa-solid fa-wand-magic-sparkles"></i> SubMagic</div>
501
+ <div style="font-size: 0.8rem; color: var(--text-muted);">v2.0 Pro</div>
502
+ </header>
503
 
504
+ <!-- BODY -->
505
+ <div class="app-body">
506
 
507
+ <!-- VIEW 1: UPLOAD -->
508
+ <div id="view-upload" class="view active">
509
+ <div style="max-width: 600px; margin: 40px auto;">
510
+ <div class="card">
511
+ <h2 style="text-align: center;">شروع پروژه جدید</h2>
512
+ <div class="upload-zone" onclick="document.getElementById('fileIn').click()">
513
+ <i class="fa-solid fa-cloud-arrow-up upload-icon"></i>
514
+ <h3>ویدیو را انتخاب کنید</h3>
515
+ <p style="color: var(--text-muted);">پشتیبانی از MP4, MOV, AVI</p>
516
+ <input type="file" id="fileIn" hidden accept="video/*" onchange="handleUpload()">
517
+ </div>
518
+ </div>
519
  </div>
520
  </div>
521
 
522
+ <!-- VIEW 2: EDITOR -->
523
+ <div id="view-editor" class="view">
524
+ <div class="editor-layout">
525
+
526
+ <!-- SIDEBAR SETTINGS -->
527
+ <div class="settings-panel">
528
+ <h2>🎨 تنظیمات گرافیکی</h2>
529
+
530
+ <!-- PREVIEW -->
531
+ <div class="preview-box">
532
+ <div id="livePreview" class="preview-text">این یک متن تست است</div>
533
+ </div>
534
+
535
+ <div class="control-group">
536
+ <div class="control-label"><span>رنگ متن</span></div>
537
+ <input type="color" id="colorMain" value="#FFFFFF" oninput="updatePreview()">
538
+ </div>
539
+
540
+ <div class="control-group">
541
+ <div class="control-label"><span>رنگ کادر/حاشیه</span></div>
542
+ <input type="color" id="colorOutline" value="#000000" oninput="updatePreview()">
543
+ </div>
544
+
545
+ <div class="control-group">
546
+ <div class="control-label"><span>نوع پس‌زمینه</span></div>
547
+ <div class="style-chips">
548
+ <div class="chip active" onclick="setStyle('solid', this)">هرمزی</div>
549
+ <div class="chip" onclick="setStyle('transparent', this)">سینمایی</div>
550
+ <div class="chip" onclick="setStyle('outline', this)">ساده</div>
551
+ </div>
552
+ </div>
553
+
554
+ <div class="control-group">
555
+ <div class="control-label"><span>فونت</span></div>
556
+ <div class="style-chips">
557
+ <div class="chip active" onclick="setFont('lalezar', this)">لاله زار</div>
558
+ <div class="chip" onclick="setFont('vazir', this)">وزیر</div>
559
+ </div>
560
+ </div>
561
+
562
+ <div class="control-group">
563
+ <div class="control-label"><span>اندازه متن</span> <span id="lblSize">60</span></div>
564
+ <input type="range" id="rngSize" min="30" max="120" value="60" oninput="updatePreview()">
565
+ </div>
566
+
567
+ <div class="control-group">
568
+ <div class="control-label"><span>موقعیت عمودی</span></div>
569
+ <input type="range" id="rngPos" min="10" max="500" value="100" oninput="updatePreview()">
570
+ </div>
571
+
572
+ <button class="btn btn-primary" onclick="startRender()">
573
+ <i class="fa-solid fa-rocket"></i> ساخت خروجی نهایی
574
+ </button>
575
+ </div>
576
+
577
+ <!-- SEGMENTS LIST -->
578
+ <div class="card" style="display: flex; flex-direction: column; height: 100%;">
579
+ <h2>📝 ویرایش متن‌ها</h2>
580
+ <div id="segmentsList" class="segments-container">
581
+ <!-- Dynamic Content -->
582
+ </div>
583
+ </div>
584
 
 
 
 
 
 
 
 
 
 
 
 
585
  </div>
586
  </div>
587
 
588
+ <!-- VIEW 3: RESULT -->
589
+ <div id="view-result" class="view">
590
+ <div style="max-width: 800px; margin: 20px auto;">
591
+ <div class="card" style="text-align: center;">
592
+ <h2 style="color: var(--success);">🎉 ویدیو آماده شد!</h2>
593
+ <video id="finalPlayer" controls class="result-video"></video>
594
+
595
+ <div style="display: flex; gap: 10px; margin-top: 20px;">
596
+ <a id="dlBtn" href="#" download class="btn btn-primary" style="background: var(--success);">
597
+ <i class="fa-solid fa-download"></i> دانلود ویدیو
598
+ </a>
599
+ <button class="btn" style="background: var(--border);" onclick="location.reload()">
600
+ <i class="fa-solid fa-rotate-right"></i> پروژه جدید
601
+ </button>
602
+ </div>
603
+ </div>
604
+ </div>
605
  </div>
606
 
 
 
 
 
 
 
 
 
607
  </div>
608
 
609
+ <!-- LOGIC -->
610
+ <script>
611
+ // STATE
612
+ let appState = {
613
+ fileId: null,
614
+ segments: [],
615
+ style: {
616
+ backType: 'solid',
617
+ font: 'lalezar'
618
+ }
619
+ };
620
 
621
+ // --- NAVIGATION ---
622
+ function switchView(id) {
623
+ document.querySelectorAll('.view').forEach(el => el.classList.remove('active'));
624
+ document.getElementById(id).classList.add('active');
625
+ }
626
 
627
+ function showLoader(msg) {
628
+ document.getElementById('loaderMsg').innerText = msg;
629
+ document.getElementById('loader').classList.add('flex');
630
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
 
632
+ function hideLoader() {
633
+ document.getElementById('loader').classList.remove('flex');
634
+ }
635
+
636
+ // --- UPLOAD ---
637
+ async function handleUpload() {
638
+ const file = document.getElementById('fileIn').files[0];
639
+ if(!file) return;
640
+
641
+ showLoader("در حال آپلود و استخراج متن با هوش مصنوعی...");
642
 
643
+ const formData = new FormData();
644
+ formData.append("file", file);
645
+
646
+ try {
647
+ const res = await fetch("/api/analyze", { method: "POST", body: formData });
648
+ const data = await res.json();
649
+
650
+ if(data.error) throw new Error(data.error);
651
+
652
+ appState.fileId = data.file_id;
653
+ appState.segments = data.segments;
654
+
655
+ renderSegments();
656
+ switchView('view-editor');
657
+ updatePreview();
658
+
659
+ } catch(e) {
660
+ alert("Error: " + e.message);
661
+ } finally {
662
+ hideLoader();
663
+ }
664
+ }
665
+
666
+ // --- EDITOR LOGIC ---
667
+ function renderSegments() {
668
+ const container = document.getElementById('segmentsList');
669
+ container.innerHTML = "";
670
 
671
+ appState.segments.forEach((seg, idx) => {
672
+ const div = document.createElement('div');
673
+ div.className = 'segment-row';
674
+ div.innerHTML = `
675
+ <div class="seg-time">${formatTime(seg.start)} -> ${formatTime(seg.end)}</div>
676
+ <textarea class="seg-input" rows="1" oninput="updateSegment(${idx}, this)">${seg.text}</textarea>
677
+ `;
678
+ container.appendChild(div);
679
+ });
680
 
681
+ // Auto resize textareas
682
+ document.querySelectorAll('.seg-input').forEach(tx => {
683
+ tx.style.height = 'auto';
684
+ tx.style.height = (tx.scrollHeight) + 'px';
685
+ });
686
  }
 
687
 
688
+ function updateSegment(idx, el) {
689
+ appState.segments[idx].text = el.value;
690
+ document.getElementById('livePreview').innerText = el.value; // Live typing effect
691
+ el.style.height = 'auto';
692
+ el.style.height = (el.scrollHeight) + 'px';
693
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
694
 
695
+ function formatTime(s) {
696
+ const m = Math.floor(s / 60);
697
+ const sec = Math.floor(s % 60);
698
+ return `${m}:${sec.toString().padStart(2, '0')}`;
699
+ }
700
+
701
+ // --- SETTINGS & PREVIEW ---
702
+ function setStyle(type, el) {
703
+ appState.style.backType = type;
704
+ el.parentElement.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
705
+ el.classList.add('active');
706
+ updatePreview();
707
+ }
708
+
709
+ function setFont(font, el) {
710
+ appState.style.font = font;
711
+ el.parentElement.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
712
+ el.classList.add('active');
713
+ updatePreview();
714
+ }
715
+
716
+ function updatePreview() {
717
+ const txt = document.getElementById('livePreview');
718
+ const size = document.getElementById('rngSize').value;
719
+ const pos = document.getElementById('rngPos').value;
720
+ const color = document.getElementById('colorMain').value;
721
+ const outline = document.getElementById('colorOutline').value;
722
+ const font = appState.style.font === 'lalezar' ? 'Lalezar' : 'Vazirmatn';
723
 
724
+ document.getElementById('lblSize').innerText = size;
725
+
726
+ // CSS Simulation of ASS Style
727
+ txt.style.fontFamily = font;
728
+ txt.style.fontSize = (size / 2) + 'px'; // Scale down for preview box
729
+ txt.style.color = color;
730
+ txt.style.bottom = (pos / 5) + 'px'; // Scale down pos
731
 
732
+ if(appState.style.backType === 'solid') {
733
+ txt.style.backgroundColor = outline;
734
+ txt.style.textShadow = 'none';
735
+ txt.style.padding = '5px 10px';
736
+ txt.style.borderRadius = '4px';
737
+ } else if (appState.style.backType === 'transparent') {
738
+ txt.style.backgroundColor = 'rgba(0,0,0,0.6)';
739
+ txt.style.textShadow = 'none';
740
+ txt.style.padding = '5px 10px';
741
+ txt.style.borderRadius = '4px';
742
+ } else { // Outline
743
+ txt.style.backgroundColor = 'transparent';
744
+ txt.style.webkitTextStroke = `1px ${outline}`;
745
+ txt.style.textShadow = `0 0 2px ${outline}`;
746
+ txt.style.padding = '0';
747
+ }
748
+ }
749
 
750
+ // --- RENDER ---
751
+ async function startRender() {
752
+ showLoader("در حال رندر نهایی ویدیو... (ممکن است کمی طول بکشد)");
753
+
754
+ const payload = {
755
+ file_id: appState.fileId,
756
+ segments: appState.segments,
757
+ style: {
758
+ font: appState.style.font,
759
+ fontSize: parseInt(document.getElementById('rngSize').value),
760
+ primaryColor: document.getElementById('colorMain').value,
761
+ outlineColor: document.getElementById('colorOutline').value,
762
+ backType: appState.style.backType,
763
+ outlineWidth: 2.0,
764
+ marginV: parseInt(document.getElementById('rngPos').value),
765
+ alignment: 2
766
+ }
767
+ };
768
+
769
+ try {
770
+ const res = await fetch("/api/render", {
771
+ method: "POST",
772
+ headers: { "Content-Type": "application/json" },
773
+ body: JSON.stringify(payload)
774
+ });
775
+ const data = await res.json();
776
+
777
+ if(data.error) throw new Error(data.error);
778
+
779
+ document.getElementById('finalPlayer').src = data.url + "?t=" + Date.now();
780
+ document.getElementById('dlBtn').href = data.url;
781
+
782
+ switchView('view-result');
783
+
784
+ } catch(e) {
785
+ alert("Render Error: " + e.message);
786
+ } finally {
787
+ hideLoader();
788
+ }
789
  }
 
 
790
 
791
+ </script>
792
  </body>
793
  </html>
794
  """