Transfer Bot commited on
Commit
5ff2fd9
·
0 Parent(s):

Moved to Hugging Face automatically

Browse files
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ # نصب FFmpeg، Git و کتابخانه‌های حیاتی سیستم
4
+ RUN apt-get update && \
5
+ apt-get install -y ffmpeg git fontconfig \
6
+ libraqm0 \
7
+ libfribidi0 \
8
+ libharfbuzz0b \
9
+ libsndfile1 \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ WORKDIR /app
13
+ # ایجاد پوشه‌های لازم
14
+ RUN mkdir -p /app/static/css /app/static/js /app/temp
15
+
16
+ COPY requirements.txt .
17
+ RUN pip install --no-cache-dir -r requirements.txt
18
+
19
+ # کپی کردن تمام فایل‌ها
20
+ COPY . .
21
+ RUN chmod -R 777 /app
22
+
23
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Zirnavis
3
+ emoji: 😻
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py ADDED
@@ -0,0 +1,1185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ import uuid
5
+ import json
6
+ import time
7
+ import asyncio
8
+ import random
9
+ import re
10
+ import importlib.util
11
+ import inspect
12
+ import requests
13
+ from datetime import datetime
14
+ from typing import List, Optional, Union, Dict
15
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException, BackgroundTasks
16
+ from fastapi.responses import FileResponse, JSONResponse
17
+ from fastapi.staticfiles import StaticFiles
18
+ from fastapi.middleware.cors import CORSMiddleware
19
+ import google.generativeai as genai
20
+ from pydantic import BaseModel
21
+ from PIL import Image, ImageDraw, ImageFont
22
+ from pydub import AudioSegment
23
+ from pydub.silence import detect_silence
24
+ from groq import Groq
25
+
26
+ # --- سازگاری با محیط‌هایی که Pillow بدون libraqm نصب شده است ---
27
+ # در برخی سرورها Pillow بدون کتابخانه libraqm کامپایل می‌شود و پارامترهای
28
+ # direction/language/features باعث خطای "not supported without libraqm" می‌شوند.
29
+ # این پچ باعث می‌شود در چنین شرایطی به‌جای کرش کردن، متن بدون این پارامترها
30
+ # (با جهت پیش‌فرض چپ‌به‌راست کتابخانه) رسم شود و ساخت ویدیو ادامه پیدا کند.
31
+ _orig_draw_text = ImageDraw.ImageDraw.text
32
+ _orig_draw_textlength = ImageDraw.ImageDraw.textlength
33
+ _orig_draw_textbbox = ImageDraw.ImageDraw.textbbox
34
+
35
+ def _strip_raqm_kwargs(kwargs):
36
+ kwargs.pop('direction', None)
37
+ kwargs.pop('language', None)
38
+ kwargs.pop('features', None)
39
+ return kwargs
40
+
41
+ def _safe_draw_text(self, xy, text, *args, **kwargs):
42
+ try:
43
+ return _orig_draw_text(self, xy, text, *args, **kwargs)
44
+ except ValueError as e:
45
+ if 'raqm' in str(e).lower():
46
+ return _orig_draw_text(self, xy, text, *args, **_strip_raqm_kwargs(kwargs))
47
+ raise
48
+
49
+ def _safe_draw_textlength(self, text, *args, **kwargs):
50
+ try:
51
+ return _orig_draw_textlength(self, text, *args, **kwargs)
52
+ except ValueError as e:
53
+ if 'raqm' in str(e).lower():
54
+ return _orig_draw_textlength(self, text, *args, **_strip_raqm_kwargs(kwargs))
55
+ raise
56
+
57
+ def _safe_draw_textbbox(self, xy, text, *args, **kwargs):
58
+ try:
59
+ return _orig_draw_textbbox(self, xy, text, *args, **kwargs)
60
+ except ValueError as e:
61
+ if 'raqm' in str(e).lower():
62
+ return _orig_draw_textbbox(self, xy, text, *args, **_strip_raqm_kwargs(kwargs))
63
+ raise
64
+
65
+ ImageDraw.ImageDraw.text = _safe_draw_text
66
+ ImageDraw.ImageDraw.textlength = _safe_draw_textlength
67
+ ImageDraw.ImageDraw.textbbox = _safe_draw_textbbox
68
+
69
+ app = FastAPI()
70
+
71
+ app.add_middleware(
72
+ CORSMiddleware,
73
+ allow_origins=["*"],
74
+ allow_methods=["*"],
75
+ allow_headers=["*"],
76
+ )
77
+
78
+ TEMP_DIR = "temp"
79
+ STATIC_DIR = "static"
80
+ STYLES_DIR = "styles"
81
+ os.makedirs(TEMP_DIR, exist_ok=True)
82
+ os.makedirs(STATIC_DIR, exist_ok=True)
83
+ os.makedirs(STYLES_DIR, exist_ok=True)
84
+
85
+ app.mount("/temp", StaticFiles(directory="temp"), name="temp")
86
+ app.mount("/static", StaticFiles(directory="static"), name="static")
87
+
88
+ MODEL_NAME = "gemini-2.5-flash"
89
+
90
+ FONT_DIR = "static/fonts"
91
+ FONT_DATASET_REPO_ID = "Opera8/font" # دیتاست عمومی هاگینگ‌فیس حاوی تمام فایل‌های فونت
92
+ FONT_FILES_MAP = {
93
+ "vazir": "Vazirmatn.ttf",
94
+ "lalezar": "Lalezar.ttf",
95
+ "amiri": "Amiri-Bold.ttf",
96
+ "sarbaz": "Sarbaz.ttf",
97
+ "nastaliq": "IranNastaliq.ttf",
98
+ "vazir-thin": "Vazirmatn-Thin.ttf",
99
+ "mada-thin": "Mada-ExtraLight.ttf",
100
+ "aref-bold": "ArefRuqaa-Bold.ttf",
101
+ "dastnevis": "Dastnevis.ttf",
102
+ "entazar": "Entazar.ttf",
103
+ "kamran": "Kamran.ttf",
104
+ "gharib": "Gharib.ttf",
105
+ "pinar": "Pinar-Bold.ttf",
106
+ "hasti": "Hasti.ttf",
107
+ }
108
+
109
+ def ensure_fonts_downloaded():
110
+ """
111
+ دیگر هیچ فایل فونتی داخل خود پروژه/اسپیس قرار نمی‌گیرد.
112
+ این تابع در لحظه بالا آمدن سرور، تمام فایل‌های فونت را از دیتاست عمومی
113
+ هاگینگ‌فیس (Opera8/font) دریافت کرده و در مسیر static/fonts ذخیره می‌کند.
114
+ """
115
+ os.makedirs(FONT_DIR, exist_ok=True)
116
+ downloaded_names = []
117
+ failed_names = []
118
+ try:
119
+ from huggingface_hub import list_repo_files, hf_hub_download
120
+ remote_files = list_repo_files(repo_id=FONT_DATASET_REPO_ID, repo_type="dataset")
121
+ font_remote_files = [f for f in remote_files if f.lower().endswith((".ttf", ".otf"))]
122
+
123
+ for remote_name in font_remote_files:
124
+ local_name = os.path.basename(remote_name)
125
+ local_path = os.path.join(FONT_DIR, local_name)
126
+ try:
127
+ cached_file_path = hf_hub_download(repo_id=FONT_DATASET_REPO_ID, filename=remote_name, repo_type="dataset")
128
+ shutil.copyfile(cached_file_path, local_path)
129
+ downloaded_names.append(local_name)
130
+ except Exception as e:
131
+ failed_names.append(f"{local_name} ({e})")
132
+ except Exception as e:
133
+ print(f"❌ اتصال به دیتاست فونت‌ها «{FONT_DATASET_REPO_ID}» ناموفق بود: {e}")
134
+ return
135
+
136
+ if downloaded_names:
137
+ print(f"✅ تعداد {len(downloaded_names)} فونت از دیتاست «{FONT_DATASET_REPO_ID}» با موفقیت دریافت و ذخیره شد: {', '.join(downloaded_names)}")
138
+ if failed_names:
139
+ print(f"❌ دریافت {len(failed_names)} فونت از دیتاست با خطا مواجه شد:")
140
+ for name in failed_names:
141
+ print(f" - {name}")
142
+
143
+ # --- تنظیمات چندزبانه (Multi-language support) ---
144
+ # هر زبان: کد ویسپر گراک، جهت نوشتار، متن راهنمای رونویسی، و فونت پیش‌فرض امن برای آن زبان
145
+ SUPPORTED_LANGUAGES = {
146
+ "fa": {
147
+ "label": "فارسی", "flag": "🇮🇷", "rtl": True, "whisper_code": "fa",
148
+ "default_font": "pinar",
149
+ "prompt": "سلام سلام، چطوری؟ کلمات دقیقاً همان‌طور که تلفظ می‌شوند، با رعایت حروف اضافه، ساختار عامیانه، محاوره‌ای و شکسته نوشته شوند. حتماً تمامی کلمات تکراری و تکرار پشت سر هم کلمات (مثل سلام سلام، خوب خوب) دقیقاً و دونه به دونه ثبت شوند و به هیچ عنوان حذف یا خلاصه‌سازی نشوند."
150
+ },
151
+ "en": {
152
+ "label": "English", "flag": "🇺🇸", "rtl": False, "whisper_code": "en",
153
+ "default_font": "vazir",
154
+ "prompt": "Hi hi, how are you? Transcribe the words exactly as spoken, keeping casual and colloquial speech intact. Repeated words said back to back (like hi hi, okay okay) must be written exactly and individually, never removed, merged or summarized."
155
+ },
156
+ "ar": {
157
+ "label": "العربية", "flag": "🇸🇦", "rtl": True, "whisper_code": "ar",
158
+ "default_font": "amiri",
159
+ "prompt": "مرحباً مرحباً، كيف حالك؟ يجب كتابة الكلمات تماماً كما تُنطق، بما في ذلك اللهجة العامية. يجب تدوين الكلمات المكررة المتتالية (مثل مرحباً مرحباً) بدقة ودون حذف أو تلخيص."
160
+ },
161
+ "tr": {
162
+ "label": "Türkçe", "flag": "🇹🇷", "rtl": False, "whisper_code": "tr",
163
+ "default_font": "vazir",
164
+ "prompt": "Merhaba merhaba, nasılsın? Kelimeleri tam olarak söylendiği gibi, günlük konuşma diliyle yazın. Art arda tekrar eden kelimeler (merhaba merhaba gibi) tek tek ve eksiksiz yazılmalı, asla silinmemeli veya özetlenmemelidir."
165
+ },
166
+ "es": {
167
+ "label": "Español", "flag": "🇪🇸", "rtl": False, "whisper_code": "es",
168
+ "default_font": "vazir",
169
+ "prompt": "Hola hola, ¿cómo estás? Transcribe las palabras exactamente como se pronuncian, manteniendo el habla coloquial. Las palabras repetidas consecutivamente (como hola hola) deben escribirse exactamente, sin eliminarlas ni resumirlas."
170
+ },
171
+ "fr": {
172
+ "label": "Français", "flag": "🇫🇷", "rtl": False, "whisper_code": "fr",
173
+ "default_font": "vazir",
174
+ "prompt": "Salut salut, comment ça va ? Transcrivez les mots exactement comme ils sont prononcés, en conservant le langage familier. Les mots répétés consécutivement (comme salut salut) doivent être écrits exactement, sans suppression ni résumé."
175
+ },
176
+ "de": {
177
+ "label": "Deutsch", "flag": "🇩🇪", "rtl": False, "whisper_code": "de",
178
+ "default_font": "vazir",
179
+ "prompt": "Hallo hallo, wie geht's? Transkribiere die Wörter genau so, wie sie gesprochen werden, einschließlich Umgangssprache. Aufeinanderfolgende Wiederholungen (wie hallo hallo) müssen exakt und einzeln erfasst werden, niemals entfernt oder zusammengefasst."
180
+ },
181
+ "it": {
182
+ "label": "Italiano", "flag": "🇮🇹", "rtl": False, "whisper_code": "it",
183
+ "default_font": "vazir",
184
+ "prompt": "Ciao ciao, come stai? Trascrivi le parole esattamente come vengono pronunciate, mantenendo il linguaggio colloquiale. Le parole ripetute consecutivamente (come ciao ciao) devono essere scritte esattamente, senza rimuoverle o riassumerle."
185
+ },
186
+ "pt": {
187
+ "label": "Português", "flag": "🇵🇹", "rtl": False, "whisper_code": "pt",
188
+ "default_font": "vazir",
189
+ "prompt": "Oi oi, como vai? Transcreva as palavras exatamente como são faladas, mantendo a linguagem coloquial. Palavras repetidas consecutivamente (como oi oi) devem ser escritas exatamente, sem remover ou resumir."
190
+ },
191
+ "ur": {
192
+ "label": "اردو", "flag": "🇵🇰", "rtl": True, "whisper_code": "ur",
193
+ "default_font": "nastaliq",
194
+ "prompt": "ہیلو ہیلو، کیا حال ہے؟ الفاظ کو بالکل اسی طرح لکھیں جیسے بولے گئے ہیں، بول چال کی زبان کو برقرار رکھیں۔ لگاتار دہرائے گئے الفاظ (جیسے ہیلو ہیلو) کو بالکل اور الگ الگ لکھا جائے، کبھی حذف یا خلاصہ نہ کیا جائے۔"
195
+ },
196
+ }
197
+ DEFAULT_LANGUAGE = "fa"
198
+ RTL_LANGS = {code for code, cfg in SUPPORTED_LANGUAGES.items() if cfg["rtl"]}
199
+
200
+ def normalize_language(lang: Optional[str]) -> str:
201
+ if lang and lang in SUPPORTED_LANGUAGES:
202
+ return lang
203
+ return DEFAULT_LANGUAGE
204
+
205
+ # دریافت توکن Groq و Gemini از تنظیمات سکرت اسپیس
206
+ GROQ_API_KEYS_ENV = os.getenv("GROQ_API_KEYS", "")
207
+ GROQ_API_KEYS = [k.strip() for k in GROQ_API_KEYS_ENV.split(",") if k.strip()]
208
+ groq_key_index = 0
209
+
210
+ API_KEYS = [os.getenv("GEMINI_API_KEY")] if os.getenv("GEMINI_API_KEY") else []
211
+
212
+ # --- سیستم صف و پایش موقت آپلود غیرهمگام ---
213
+ class UploadJobStatus:
214
+ QUEUED = "queued"
215
+ PROCESSING = "processing"
216
+ COMPLETED = "completed"
217
+ FAILED = "failed"
218
+
219
+ class UploadJob:
220
+ def __init__(self, task_id: str, file_name: str, language: str = "fa"):
221
+ self.id = task_id
222
+ self.file_name = file_name
223
+ self.language = language
224
+ self.status = UploadJobStatus.QUEUED
225
+ self.result = None
226
+ self.error_message = None
227
+ self.created_at = datetime.now()
228
+
229
+ upload_jobs: Dict[str, UploadJob] = {}
230
+
231
+ # --- Dynamic Style Loading ---
232
+ loaded_styles = {}
233
+ style_configs = {}
234
+ style_templates = {}
235
+
236
+ def load_all_styles():
237
+ print("--- Loading Styles from /styles ---")
238
+ for filename in os.listdir(STYLES_DIR):
239
+ if filename.endswith(".py") and filename != "__init__.py":
240
+ module_name = filename[:-3]
241
+ file_path = os.path.join(STYLES_DIR, filename)
242
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
243
+ if spec and spec.loader:
244
+ mod = importlib.util.module_from_spec(spec)
245
+ spec.loader.exec_module(mod)
246
+
247
+ if hasattr(mod, 'config'):
248
+ ids = mod.config.get("ids", [])
249
+ for style_id in ids:
250
+ loaded_styles[style_id] = mod
251
+ style_configs[style_id] = mod.config
252
+ if hasattr(mod, 'frontend_template'):
253
+ style_templates[style_id] = mod.frontend_template.strip()
254
+ print(f"Loaded Style: {style_id}")
255
+
256
+ load_all_styles()
257
+
258
+ # تنظیم همزمانی پردازش
259
+ CONCURRENCY_LIMIT = 20
260
+ GEMINI_SEMAPHORE = asyncio.Semaphore(CONCURRENCY_LIMIT)
261
+
262
+ # --- Data Models ---
263
+ class WordInfo(BaseModel):
264
+ word: str; start: float; end: float
265
+ highlight: Optional[bool] = False
266
+ color: Optional[str] = None
267
+
268
+ class SubtitleSegment(BaseModel):
269
+ id: Optional[Union[str, int]] = None
270
+ start: float
271
+ end: float
272
+ text: str
273
+ words: Optional[List[WordInfo]] = []
274
+
275
+ class StyleConfig(BaseModel):
276
+ font: str; fontSize: int; primaryColor: str; outlineColor: str
277
+ backType: str; marginV: int
278
+ x: Optional[int] = 0
279
+ name: Optional[str] = "classic"
280
+ radius: Optional[int] = 16
281
+ paddingX: Optional[int] = 20
282
+ paddingY: Optional[int] = 10
283
+ total_video_duration: Optional[float] = None
284
+ current_render_time: Optional[float] = None
285
+ entry_anim_progress: Optional[float] = 1.0
286
+ styleBgColors: Dict[str, str] = {}
287
+ styleColors: Dict[str, str] = {}
288
+ styleActiveColors: Dict[str, str] = {}
289
+ useActiveColor: Optional[bool] = True
290
+ fadeUnread: Optional[bool] = True
291
+ fadeSurrounding: Optional[bool] = False
292
+ typewriter: Optional[bool] = False
293
+ language: Optional[str] = "fa"
294
+ text_direction: Optional[str] = "rtl"
295
+ lang_code: Optional[str] = "fa"
296
+ ai_design: Optional[Dict] = None
297
+
298
+ class ProcessRequest(BaseModel):
299
+ file_id: str; segments: List[SubtitleSegment]
300
+ video_width: int; video_height: int; style: StyleConfig
301
+
302
+ class StylePrompt(BaseModel):
303
+ description: str
304
+
305
+ class JobStatus:
306
+ QUEUED = "queued"; PROCESSING = "processing"
307
+ COMPLETED = "completed"; FAILED = "failed"
308
+
309
+ class Job:
310
+ def __init__(self, job_id: str, request_data: ProcessRequest):
311
+ self.id = job_id; self.data = request_data; self.status = JobStatus.QUEUED
312
+ self.created_at = datetime.now(); self.result_url = None; self.error_message = None
313
+
314
+ render_queue = asyncio.Queue()
315
+ jobs_db: Dict[str, Job] = {}
316
+
317
+ async def queue_worker():
318
+ print("--- Queue Worker Started ---")
319
+ while True:
320
+ job_id = await render_queue.get()
321
+ job = jobs_db.get(job_id)
322
+ if job:
323
+ try:
324
+ print(f"Processing job: {job_id}")
325
+ job.status = JobStatus.PROCESSING
326
+ output_url = process_render_logic(job.data)
327
+ job.result_url = output_url
328
+ job.status = JobStatus.COMPLETED # اصلاح باگ عدم تعریف نام متغیر COMPLETED
329
+ print(f"Job {job_id} completed.")
330
+ except Exception as e:
331
+ print(f"Job {job_id} failed: {e}")
332
+ job.status = JobStatus.FAILED
333
+ job.error_message = str(e)
334
+ render_queue.task_done()
335
+
336
+ async def cleanup_old_files_loop():
337
+ print("--- File Cleanup Worker Started ---")
338
+ while True:
339
+ await asyncio.sleep(600) # بررسی وضعیت هر ۱۰ دقیقه یک‌بار
340
+ try:
341
+ now = time.time()
342
+ for filename in os.listdir(TEMP_DIR):
343
+ file_path = os.path.join(TEMP_DIR, filename)
344
+ if filename.endswith(".mp4") and "_final_" in filename:
345
+ try:
346
+ mtime = os.path.getmtime(file_path)
347
+ if now - mtime > 7200:
348
+ os.remove(file_path)
349
+ print(f"Cleanup: Removed expired final video: {filename}")
350
+ except Exception as e:
351
+ print(f"Cleanup error for {filename}: {e}")
352
+ except Exception as e:
353
+ print(f"Global cleanup loop error: {e}")
354
+
355
+ @app.on_event("startup")
356
+ async def startup_event():
357
+ print(f"تعداد {len(API_KEYS)} کلید جیمینای و تعداد {len(GROQ_API_KEYS)} کلید گراک شناسایی شد.")
358
+ ensure_fonts_downloaded()
359
+ check_fonts_integrity()
360
+ asyncio.create_task(queue_worker())
361
+ asyncio.create_task(cleanup_old_files_loop())
362
+
363
+ def check_fonts_integrity():
364
+ """بررسی سلامت تمامی فایل‌های فونت هنگام بالا آمدن سرور و گزارش آن به فارسی در لاگ"""
365
+ healthy = []
366
+ broken = []
367
+ for key, filename in FONT_FILES_MAP.items():
368
+ fpath = os.path.join(FONT_DIR, filename)
369
+ if not os.path.exists(fpath):
370
+ broken.append(f"{filename} (فایل موجود نیست)")
371
+ continue
372
+ try:
373
+ ImageFont.truetype(fpath, 40)
374
+ healthy.append(filename)
375
+ except Exception as e:
376
+ broken.append(f"{filename} ({e})")
377
+
378
+ if healthy:
379
+ print(f"✅ تعداد {len(healthy)} فونت با این نام‌ها شناسایی و با موفقیت بارگذاری شد: {', '.join(healthy)}")
380
+ if broken:
381
+ print(f"❌ تعداد {len(broken)} فونت خراب/ناقص یا در دسترس نبود:")
382
+ for b in broken:
383
+ print(f" - {b}")
384
+
385
+ # --- Helper Functions ---
386
+ def clean_json_response(text: str):
387
+ text = text.strip()
388
+ if text.startswith('{') and text.endswith('}'): return text
389
+ match = re.search(r'```json\s*(\{.*?\})\s*```', text, re.DOTALL)
390
+ if match: return match.group(1)
391
+ match = re.search(r'\{.*\}', text, re.DOTALL)
392
+ if match: return match.group(0)
393
+ return text
394
+
395
+ def get_video_info(path):
396
+ try:
397
+ cmd = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,duration", "-of", "json", path]
398
+ res = subprocess.run(cmd, capture_output=True, text=True)
399
+ data = json.loads(res.stdout)
400
+ stream = data['streams'][0]
401
+ w = int(stream.get('width', 1080)); h = int(stream.get('height', 1920)); dur = stream.get('duration')
402
+ if not dur:
403
+ cmd_dur = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", path]
404
+ res_dur = subprocess.run(cmd_dur, capture_output=True, text=True)
405
+ data_dur = json.loads(res_dur.stdout)
406
+ dur = data_dur['format'].get('duration', 60)
407
+ return w, h, float(dur)
408
+ except: return 1080, 1920, 60.0
409
+
410
+ _FONT_OBJ_CACHE = {}
411
+
412
+ def get_font_object(style_font_name, size):
413
+ """
414
+ بار کردن فونت درخواستی. اگر فایل فونت موردنظر روی دیسک نبود، مانند قبل
415
+ به فونت پیش‌فرض Vazirmatn سوییچ می‌کند؛ اما اگر فایل فونت وجود داشت ولی خراب/ناقص
416
+ بود (مثلاً مشکل دانلود ناقص Git LFS)، به‌جای رد شدن بی‌صدا، خطای واضح و مشخص
417
+ می‌دهد تا خودتان متوجه شوید و فایل فونت صحیح را در static/fonts قرار دهید.
418
+ """
419
+ cache_key = (style_font_name, size)
420
+ if cache_key in _FONT_OBJ_CACHE:
421
+ return _FONT_OBJ_CACHE[cache_key]
422
+
423
+ target_filename = FONT_FILES_MAP.get(style_font_name, "Vazirmatn.ttf")
424
+ target_path = os.path.join(FONT_DIR, target_filename)
425
+ if not os.path.exists(target_path):
426
+ target_path = os.path.join(FONT_DIR, "Vazirmatn.ttf")
427
+
428
+ try:
429
+ font_obj = ImageFont.truetype(target_path, size)
430
+ except Exception as e:
431
+ raise RuntimeError(
432
+ f"❌ فایل فونت «{os.path.basename(target_path)}» خراب یا ناقص است و بارگذاری نشد ({e}). "
433
+ f"لطفاً یک فایل فونت (.ttf) سالم را در مسیر static/fonts/{os.path.basename(target_path)} قرار دهید."
434
+ )
435
+
436
+ _FONT_OBJ_CACHE[cache_key] = font_obj
437
+ return font_obj
438
+
439
+ def get_color_tuple(color_str: str, default=(255, 255, 255, 255)):
440
+ if not color_str or not isinstance(color_str, str): return default
441
+ color_str = color_str.strip().lower()
442
+ if color_str.startswith('#'):
443
+ try:
444
+ hex_val = color_str.lstrip('#')
445
+ if len(hex_val) == 6: return tuple(int(hex_val[i:i+2], 16) for i in (0, 2, 4)) + (255,)
446
+ elif len(hex_val) == 8: return tuple(int(hex_val[i:i+2], 16) for i in (0, 2, 4, 6))
447
+ except: pass
448
+ elif color_str.startswith('rgba'):
449
+ try:
450
+ content = color_str[color_str.find('(')+1 : color_str.rfind(')')]
451
+ parts = [x.strip() for x in content.split(',')]
452
+ if len(parts) >= 4:
453
+ r, g, b = int(parts[0]), int(parts[1]), int(parts[2])
454
+ a = int(float(parts[3]) * 255)
455
+ return (r, g, b, a)
456
+ except: pass
457
+ elif color_str.startswith('rgb'):
458
+ try:
459
+ content = color_str[color_str.find('(')+1 : color_str.rfind(')')]
460
+ parts = [x.strip() for x in content.split(',')]
461
+ if len(parts) >= 3: return (int(parts[0]), int(parts[1]), int(parts[2]), 255)
462
+ except: pass
463
+ return default
464
+
465
+ def create_subtitle_image(text_parts: list, active_idx: int, width: int, height: int, style: StyleConfig, word_infos: Optional[List[WordInfo]] = None):
466
+ img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
467
+ draw = ImageDraw.Draw(img)
468
+
469
+ # --- تعیین جهت نوشتار و کد زبان بر اساس زبان انتخابی پروژه (چندزبانه) ---
470
+ lang_code = normalize_language(getattr(style, 'language', None))
471
+ is_rtl = lang_code in RTL_LANGS
472
+ text_direction = 'rtl' if is_rtl else 'ltr'
473
+ setattr(style, 'text_direction', text_direction)
474
+ setattr(style, 'lang_code', lang_code)
475
+
476
+ text_offset = 0
477
+ fs = style.fontSize
478
+ if style.font == 'dastnevis':
479
+ text_offset = int(fs * 0.15) if style.name == "music_player" else int(fs * 0.25)
480
+ elif style.font == 'entazar':
481
+ text_offset = int(fs * 0.03) if style.name == "music_player" else int(fs * 0.2)
482
+ elif style.font == 'kamran':
483
+ text_offset = int(fs * 0.03) if style.name == "music_player" else int(fs * 0.2)
484
+ elif style.font == 'pinar':
485
+ text_offset = 0 if style.name == "music_player" else int(fs * -0.2)
486
+
487
+ stroke_val = 0
488
+ if style.font == 'entazar' and style.name != "music_player":
489
+ stroke_val = 0.1
490
+
491
+ if text_offset != 0 or stroke_val > 0:
492
+ original_text_method = draw.text
493
+ def custom_draw_text(xy, text, **kwargs):
494
+ if stroke_val > 0:
495
+ current_fill = kwargs.get('fill', (255, 255, 255, 255))
496
+ kwargs['stroke_width'] = stroke_val
497
+ kwargs['stroke_fill'] = current_fill
498
+
499
+ # برای استایل‌های کادردار، انحراف دستی را صفر می‌کنیم تا تراز بصری کادر به هم نخورد
500
+ actual_offset = text_offset
501
+ if style.name in ["instagram_box", "alpha_gradient", "karaoke_static", "auto_director", "falling_karaoke"]:
502
+ actual_offset = 0
503
+
504
+ original_text_method((xy[0], xy[1] + actual_offset), text, **kwargs)
505
+ draw.text = custom_draw_text
506
+
507
+ font = get_font_object(style.font, style.fontSize)
508
+ lines = []
509
+ if style.name == "music_player":
510
+ lines.append(text_parts)
511
+ else:
512
+ MAX_WORDS_PER_LINE = 5
513
+ current_line = []
514
+ for i, word in enumerate(text_parts):
515
+ current_line.append(word)
516
+ if len(current_line) == MAX_WORDS_PER_LINE:
517
+ lines.append(current_line)
518
+ current_line = []
519
+ if current_line:
520
+ lines.append(current_line)
521
+
522
+ line_metrics = []
523
+ max_line_width = 0
524
+ for line_words in lines:
525
+ w_widths = []
526
+ l_width = 0
527
+ full_line_text = " ".join(line_words)
528
+ try: l_width = draw.textlength(full_line_text, font=font, direction=text_direction, language=lang_code)
529
+ except: l_width = font.getlength(full_line_text)
530
+ if l_width > max_line_width: max_line_width = l_width
531
+ for w in line_words:
532
+ try: wl = draw.textlength(w, font=font, direction=text_direction, language=lang_code)
533
+ except: wl = font.getlength(w)
534
+ w_widths.append(wl)
535
+ line_metrics.append({"width": l_width, "words": line_words, "word_widths": w_widths})
536
+
537
+ safe_word_infos = word_infos
538
+ if word_infos:
539
+ safe_word_infos = []
540
+ self_handled = ["instagram_box", "alpha_gradient", "music_player", "falling_words", "falling_karaoke", "blue_cloud", "spring_popup", "falling_plain", "red_outline_box"]
541
+ should_fade_unread = getattr(style, 'fadeUnread', True) and (style.name not in self_handled)
542
+ should_fade_surr = getattr(style, 'fadeSurrounding', False) and (style.name not in self_handled)
543
+ curr_t = getattr(style, 'current_render_time', None)
544
+
545
+ for i, w in enumerate(word_infos):
546
+ w_copy = w.copy() if hasattr(w, 'copy') else w
547
+
548
+ if (i == active_idx) and not getattr(style, 'useActiveColor', True):
549
+ if style.name in ["auto_director", "karaoke_static", "falling_karaoke", "alpha_gradient", "falling_plain"]:
550
+ # اعمال رنگ متن انتخابی کاربر برای استایل‌های داینامیک و نئون آلفا
551
+ w_copy.color = style.styleColors.get(style.name, "#FFFFFF")
552
+ elif style.name in ["instagram_box"]:
553
+ # حفظ رفتار قبلی برای باکس متحرک بدون تغییر
554
+ w_copy.color = "#FFFFFF"
555
+
556
+ is_future = (curr_t is not None and curr_t < w.start) or (curr_t is None and active_idx != -1 and i > active_idx)
557
+ is_past = (curr_t is not None and curr_t >= w.end) or (curr_t is None and active_idx != -1 and i < active_idx)
558
+
559
+ if (is_future and (should_fade_unread or should_fade_surr)) or (is_past and should_fade_surr):
560
+ if style.name in ["classic", "progressive_write"]:
561
+ default_txt_color = style.primaryColor
562
+ else:
563
+ # تعریف جدول رنگ‌های پیش‌فرض استایل‌های اختصاصی جهت جلوگیری از لود رنگ سفید اشتباه
564
+ default_style_colors = {
565
+ "blue_outline": "#0000FF",
566
+ "blue_cloud": "#00b4ff",
567
+ "dark_edges": "#ffffff",
568
+ "falling_words": "#ffffff",
569
+ "falling_karaoke": "#ffffff"
570
+ }
571
+ default_txt_color = style.styleColors.get(style.name, default_style_colors.get(style.name, "#FFFFFF"))
572
+
573
+ tr, tg, tb, _ = get_color_tuple(w_copy.color if w_copy.color else default_txt_color, (255, 255, 255, 255))
574
+
575
+ # قرار دادن هر دو استایل جدید در لیست بدون کادرها جهت اعمال مستقیم کانال آلفا
576
+ no_box_styles = ["karaoke_static", "auto_director", "plain_white", "white_outline", "dark_edges", "blue_outline", "spring_popup", "falling_plain"]
577
+ if style.name in no_box_styles:
578
+ w_copy.color = f"rgba({tr},{tg},{tb},0.35)"
579
+ else:
580
+ bg_key = 'simple_bar_main_box' if style.name == "simple_bar" else style.name
581
+ bgr, bgg, bgb, _ = get_color_tuple(style.styleBgColors.get(bg_key, style.outlineColor), (0, 0, 0, 255))
582
+ fr, fg, fb = int((tr * 0.35) + (bgr * 0.65)), int((tg * 0.35) + (bgg * 0.65)), int((tb * 0.35) + (bgb * 0.65))
583
+ w_copy.color = f"rgba({fr},{fg},{fb},255)"
584
+
585
+ safe_word_infos.append(w_copy)
586
+
587
+ style_module = loaded_styles.get(style.name)
588
+ if style_module and hasattr(style_module, 'draw_frame'):
589
+ style_module.draw_frame(
590
+ draw=draw,
591
+ img=img,
592
+ width=width,
593
+ height=height,
594
+ style_config=style,
595
+ lines=lines,
596
+ line_metrics=line_metrics,
597
+ active_idx=active_idx,
598
+ font=font,
599
+ color_parser=get_color_tuple,
600
+ word_infos=safe_word_infos
601
+ )
602
+ else:
603
+ y = height - style.marginV
604
+ draw.text((width/2, y), "Style Error", font=font, fill="red")
605
+ return img
606
+
607
+ def generate_subtitle_video(data: ProcessRequest, temp_dir: str):
608
+ target_styles = [
609
+ "instagram_box",
610
+ "dark_edges",
611
+ "falling_words",
612
+ "classic",
613
+ "progressive_write"
614
+ ]
615
+
616
+ if data.style.name in target_styles:
617
+ data.style.paddingX = (data.style.paddingX or 0) + 15
618
+
619
+ list_file = os.path.join(temp_dir, f"{data.file_id}_list.txt")
620
+ empty_img_path = os.path.join(temp_dir, "empty.png")
621
+ if not os.path.exists(empty_img_path): Image.new('RGBA', (data.video_width, data.video_height), (0, 0, 0, 0)).save(empty_img_path)
622
+
623
+ sorted_segments = sorted(data.segments, key=lambda x: x.start)
624
+ if sorted_segments:
625
+ setattr(data.style, 'total_video_duration', sorted_segments[-1].end)
626
+ else:
627
+ setattr(data.style, 'total_video_duration', 1.0)
628
+
629
+ with open(list_file, "w") as f:
630
+ current_timeline = 0.0
631
+ last_generated_image = "empty.png"
632
+
633
+ for idx, seg in enumerate(sorted_segments):
634
+ start_time = round(max(seg.start, current_timeline), 3)
635
+ end_time = round(max(seg.end, start_time + 0.1), 3)
636
+ if end_time - start_time < 0.04: continue
637
+
638
+ gap = round(start_time - current_timeline, 3)
639
+ if gap > 0.005:
640
+ # حذف کلمات چسبیده قبلی در ��مان سکوت و نمایش ندادن هیچ متنی (نمایش فریم خالی)
641
+ f.write(f"file 'empty.png'\nduration {gap:.3f}\n")
642
+ current_timeline = start_time
643
+ last_generated_image = "empty.png"
644
+
645
+ current_timeline = start_time
646
+ available_duration = round(end_time - current_timeline, 3)
647
+ words = [w.word for w in seg.words] if seg.words else seg.text.split()
648
+
649
+ if seg.words and len(words) > 0:
650
+ seg.words.sort(key=lambda x: x.start)
651
+ words = [w.word for w in seg.words]
652
+
653
+ SUB_FRAME_DURATION = 0.025 if data.style.name in ["falling_words", "falling_karaoke", "spring_popup"] else 0.05
654
+ time_cursor = start_time
655
+ ANIMATION_DURATION = 0.4
656
+ while time_cursor < end_time:
657
+ active_word_index = -1
658
+ for i, w_info in enumerate(seg.words):
659
+ if time_cursor >= w_info.start and time_cursor < w_info.end:
660
+ active_word_index = i
661
+ break
662
+ setattr(data.style, 'current_render_time', time_cursor)
663
+ time_into_segment = time_cursor - start_time
664
+ anim_progress = min(1.0, time_into_segment / ANIMATION_DURATION)
665
+ setattr(data.style, 'entry_anim_progress', anim_progress)
666
+ name = f"sub_{data.file_id}_{idx}_{int(time_cursor*1000)}.png"
667
+ img = create_subtitle_image(words, active_word_index, data.video_width, data.video_height, data.style, word_infos=seg.words)
668
+ img.save(os.path.join(temp_dir, name))
669
+ last_generated_image = name
670
+ f.write(f"file '{name}'\nduration {SUB_FRAME_DURATION:.3f}\n")
671
+ time_cursor += SUB_FRAME_DURATION
672
+ current_timeline = end_time
673
+ else:
674
+ name = f"sub_{data.file_id}_{idx}_full.png"
675
+ img = create_subtitle_image(words, -1, data.video_width, data.video_height, data.style, word_infos=seg.words)
676
+ img.save(os.path.join(temp_dir, name))
677
+ f.write(f"file '{name}'\nduration {available_duration:.3f}\n")
678
+ last_generated_image = name
679
+ current_timeline += available_duration
680
+
681
+ f.write(f"file 'empty.png'\nduration 30.0\n")
682
+ return list_file
683
+
684
+ def process_render_logic(req: ProcessRequest) -> str:
685
+ # اعمال زمان‌های مرزی دقیق برای هر سگمنت
686
+ for s in req.segments:
687
+ if s.words:
688
+ s.words.sort(key=lambda x: x.start)
689
+ s.start = s.words[0].start
690
+ s.end = s.words[-1].end
691
+
692
+ req.segments = [s for s in req.segments if s.end > s.start]
693
+ req.segments.sort(key=lambda x: x.start)
694
+
695
+ lst = generate_subtitle_video(req, TEMP_DIR)
696
+ inp = f"{TEMP_DIR}/{req.file_id}.mp4"
697
+ if not os.path.exists(inp): raise Exception("Input video not found")
698
+
699
+ out = f"{TEMP_DIR}/{req.file_id}_final_{int(time.time())}.mp4"
700
+
701
+ # ترکیب مستقیم روی ویدیو با حفظ زمان‌بندی متغیر (VFR) بدون دستکاری فریم‌ها
702
+ cmd_merge = [
703
+ "ffmpeg", "-y",
704
+ "-i", inp,
705
+ "-f", "concat", "-safe", "0", "-i", lst,
706
+ "-filter_complex", "[0:v][1:v]overlay=0:0:eof_action=pass[outv]",
707
+ "-map", "[outv]", "-map", "0:a",
708
+ "-c:v", "libx264", "-r", "30", "-preset", "ultrafast", "-c:a", "aac", out
709
+ ]
710
+
711
+ res = subprocess.run(cmd_merge, capture_output=True, text=True)
712
+ if res.returncode != 0: raise Exception(f"Merge failed: {res.stderr}")
713
+ return f"/temp/{os.path.basename(out)}"
714
+
715
+ @app.get("/")
716
+ async def index(): return FileResponse("index.html")
717
+
718
+ @app.get("/api/styles")
719
+ def get_style_definitions():
720
+ return {
721
+ "styles": style_configs,
722
+ "templates": style_templates
723
+ }
724
+
725
+ @app.get("/api/languages")
726
+ def get_supported_languages():
727
+ return {
728
+ "default": DEFAULT_LANGUAGE,
729
+ "languages": [
730
+ {"code": code, "label": cfg["label"], "flag": cfg["flag"], "rtl": cfg["rtl"]}
731
+ for code, cfg in SUPPORTED_LANGUAGES.items()
732
+ ]
733
+ }
734
+
735
+ def _hex_or_default(value, default):
736
+ """اعتبارسنجی ساده کد رنگ هگز؛ اگر نامعتبر بود مقدار پیش‌فرض امن برمی‌گردد."""
737
+ if isinstance(value, str) and re.fullmatch(r"#[0-9A-Fa-f]{6}", value.strip()):
738
+ return value.strip()
739
+ return default
740
+
741
+ def _clamp_ai_design(raw: dict) -> dict:
742
+ """
743
+ هرچه هوش مصنوعی برگرداند، اینجا کاملاً کنترل و به مقادیر امن محدود (clamp) می‌شود
744
+ تا موتور رندر هیچ‌وقت با یک مقدار عجیب/خراب کرش نکند.
745
+ توجه: این استایل دیگر هیچ‌وقت کادر/باکس/پس‌زمینه دور کلمه نمی‌سازد؛ فقط
746
+ حرکت ورود/خروج و رنگ متن (و در صورت لزوم، درخشش نور) طراحی می‌شود.
747
+ برای فیلدهای نامعتبر/جاافتاده، به‌جای یک مقدار خنثی و همیشه‌یکسان، یک مقدار
748
+ تصادفی و چشمگیر انتخاب می‌شود تا حتی در بدترین حالت (خطای هوش مصنوعی) هم
749
+ خروجی «ساده و کسل‌کننده» به نظر نرسد.
750
+ """
751
+ if not isinstance(raw, dict):
752
+ raw = {}
753
+
754
+ entry_options = ["fall", "rise", "pop", "slide_left", "slide_right"]
755
+ exit_options = ["fade", "shrink", "fall", "rise"]
756
+ timing_options = ["elastic", "punch", "smooth"]
757
+ glow_options = ["soft", "strong", "none"]
758
+ valid_fonts = list(FONT_FILES_MAP.keys())
759
+ vivid_color_pairs = [
760
+ ("#FFFFFF", "#FFD700", "#7C4DFF"),
761
+ ("#FFFFFF", "#00E5FF", "#FF2D95"),
762
+ ("#F5F5F5", "#39FF14", "#8A2BE2"),
763
+ ("#FFFFFF", "#FF6EC7", "#00B3FF"),
764
+ ("#EAEAEA", "#FFEE58", "#FF3B30"),
765
+ ("#FFFFFF", "#00FFC6", "#FF5E00"),
766
+ ]
767
+
768
+ entry_options_full = {"fall", "rise", "pop", "slide_left", "slide_right", "fade"}
769
+ exit_options_full = {"fade", "shrink", "fall", "rise", "none"}
770
+ timing_options_full = {"elastic", "smooth", "punch", "linear"}
771
+ glow_options_full = {"none", "soft", "strong"}
772
+
773
+ entry = raw.get("entry") if raw.get("entry") in entry_options_full else random.choice(entry_options)
774
+ exit_type = raw.get("exit") if raw.get("exit") in exit_options_full else random.choice(exit_options)
775
+ timing = raw.get("timing") if raw.get("timing") in timing_options_full else random.choice(timing_options)
776
+ glow = raw.get("glow") if raw.get("glow") in glow_options_full else random.choice(glow_options)
777
+ font = raw.get("font") if raw.get("font") in valid_fonts else random.choice(valid_fonts)
778
+
779
+ try:
780
+ stagger = float(raw.get("stagger", 0.06))
781
+ except (TypeError, ValueError):
782
+ stagger = 0.06
783
+ stagger = max(0.0, min(0.12, stagger))
784
+ if stagger == 0.0 and "stagger" not in raw:
785
+ stagger = round(random.uniform(0.04, 0.09), 2)
786
+
787
+ try:
788
+ font_size = int(raw.get("fontSize", 60))
789
+ except (TypeError, ValueError):
790
+ font_size = 60
791
+ font_size = max(30, min(90, font_size))
792
+
793
+ fallback_primary, fallback_active, fallback_glow = random.choice(vivid_color_pairs)
794
+
795
+ return {
796
+ "entry": entry,
797
+ "exit": exit_type,
798
+ "timing": timing,
799
+ "stagger": stagger,
800
+ "glow": glow,
801
+ "primaryColor": _hex_or_default(raw.get("primaryColor"), fallback_primary),
802
+ "activeColor": _hex_or_default(raw.get("activeColor"), fallback_active),
803
+ "glowColor": _hex_or_default(raw.get("glowColor") or raw.get("shapeColor"), fallback_glow),
804
+ "font": font,
805
+ "fontSize": font_size,
806
+ }
807
+
808
+ @app.post("/api/generate-style")
809
+ def generate_style_api(req: StylePrompt):
810
+ if not API_KEYS: raise HTTPException(500, "API Keys Missing")
811
+ prompt = f"""شما یک طراح موشن‌گرافیک زیرنویس هستی. کار تو فقط طراحی «حرکت ورود کلمات» و «رنگ‌ها»ست — هیچ‌وقت دور متن یا دور کلمات کادر/باکس/حباب نمی‌سازی؛ فقط خود متن با یک انیمیشن و رنگ مشخص روی صحنه ظاهر می‌شود.
812
+
813
+ توضیح کاربر: "{req.description}"
814
+
815
+ قوانین جدی که باید دقیقاً رعایت کنی:
816
+ 1) مهم‌ترین قانون: هرچه کاربر برای «جهت حرکت» گفته را دقیقاً همان اجرا کن. مثلاً:
817
+ - «از بالا بیاد» یا «بریزه» → entry="fall"
818
+ - «از پایین بیاد» یا «بلند شه» → entry="rise"
819
+ - «از چپ بیاد» → entry="slide_left"
820
+ - «از راست بیاد» → entry="slide_right"
821
+ - «بپره تو» یا «پاپ‌آپ» → entry="pop"
822
+ اگر کاربر جهتی نگفت، بر اساس حس‌وحال کلی یک جهت مناسب انتخاب کن.
823
+ 2) رنگ: هرچه کاربر برای رنگ گفته را دقیقاً همان اجرا کن (مثلاً «قرمز» → یک قرمز واقعی مثل #FF3B30، «آبی» → یک آبی واقعی، «طلایی» → یک طلایی واقعی). اگر کاربر گفت «رنگ کل متن قرمز باشه» یعنی primaryColor باید قرمز باشد. اگر گفت «کلمه‌ای که گفته می‌شه فلان رنگ بشه» یعنی activeColor باید همان رنگ باشد. هرگز رنگ درخواستی کاربر را با یک رنگ «قشنگ‌تر» از نظر خودت جایگزین نکن.
824
+ 3) glow (درخشش نور اطراف متن) را فقط وقتی روشن کن که کاربر واقعاً به نور/نئون/درخشش اشاره کرده باشد یا حس‌وحال آن (نئون، درخشان، فانتزی) از توضیحش برمی‌آید؛ در غیر این صورت glow="none".
825
+ 4) اگر کلمه‌ای مثل «آبشاری»، «ریزان»، «موج» گفته شد: stagger را بالا (حدود 0.08 تا 0.12) بگذار.
826
+ 5) اگر کلمه‌ای مثل «فنری»، «پرشی»، «باحال» گفته شد: timing را "elastic" یا "punch" بگذار؛ برای حس آرام و ساده timing="smooth" یا "linear" بگذار.
827
+ 6) اگر کاربر توضیح ساده/مینیمالی داد (مثلاً «فقط متن سفید که آروم بیاد»)، رنگ‌ها را ساده نگه‌دار و glow="none" بگذار — زینت اضافه بدون درخواست کاربر اشتباه است.
828
+ 7) هیچ‌وقت shape/کادر/باکس/پس‌زمینه دور کلمه طراحی نکن؛ فقط حرکت و رنگ خود متن مهم است.
829
+
830
+ فقط یک JSON با دقیقاً همین کلیدها برگردان (بدون هیچ توضیح اضافه، بدون Markdown):
831
+ - entry: یکی از ["fall", "rise", "pop", "slide_left", "slide_right", "fade"] (نحوه ورود هر کلمه به صحنه)
832
+ - exit: یکی از ["fade", "shrink", "fall", "rise", "none"] (نحوه خروج/فروکش هر کلمه بعد از گفته‌شدن)
833
+ - timing: یکی از ["elastic", "smooth", "punch", "linear"] (منحنی حرکت)
834
+ - stagger: عددی بین 0 تا 0.12 (تاخیر آبشاری بین کلمات پشت‌سرهم؛ برای افکت آبشاری عدد بالاتر بده)
835
+ - glow: یکی از ["none", "soft", "strong"] (درخشش نور اطراف متن؛ فقط اگر کاربر خواسته)
836
+ - primaryColor: کد رنگ هگز برای رنگ اصلی متن (دقیقاً مطابق درخواست کاربر)
837
+ - activeColor: کد رنگ هگز برای کلمه‌ای که همین الان گفته می‌شود (دقیقاً مطابق درخواست کاربر)
838
+ - glowColor: کد رنگ هگز برای نور اطراف متن (اگر glow فعال باشد)
839
+ - font: یکی از {list(FONT_FILES_MAP.keys())}
840
+ - fontSize: عددی بین 30 تا 90"""
841
+
842
+ last_error = None
843
+ for attempt in range(3):
844
+ try:
845
+ genai.configure(api_key=random.choice(API_KEYS))
846
+ model = genai.GenerativeModel(MODEL_NAME)
847
+ res = model.generate_content(
848
+ prompt,
849
+ generation_config={
850
+ "response_mime_type": "application/json",
851
+ "temperature": 1.35,
852
+ "top_p": 0.97,
853
+ }
854
+ )
855
+ data = json.loads(clean_json_response(res.text))
856
+ design = _clamp_ai_design(data)
857
+ return {"success": True, "design": design}
858
+ except Exception as e:
859
+ last_error = str(e)
860
+ print(f"⚠️ تلاش {attempt + 1} برای ساخت استایل هوشمند ناموفق بود: {last_error}")
861
+ continue
862
+ # در صورت شکست کامل هوش مصنوعی، یک طراحی امن و پیش‌فرض برگردانده می‌شود، ولی صادقانه اعلام می‌شود که موفق نبوده
863
+ print(f"❌ ساخت استایل هوشمند بعد از ۳ تلاش ناموفق ماند. آخرین خطا: {last_error}")
864
+ return {"success": False, "design": _clamp_ai_design({}), "error": last_error}
865
+
866
+ # --- تابع ارسال فایل صوتی به Groq با پرامپت تقویت‌شده تکرارها (چندزبانه) ---
867
+ async def transcribe_audio_via_groq(audio_path: str, language: str = "fa"):
868
+ global groq_key_index
869
+ if not GROQ_API_KEYS:
870
+ raise Exception("متغیر محیطی GROQ_API_KEYS در قسمت Secrets تنظیم نشده است.")
871
+
872
+ lang_cfg = SUPPORTED_LANGUAGES.get(language, SUPPORTED_LANGUAGES[DEFAULT_LANGUAGE])
873
+
874
+ # انتخاب چرخشی کلید
875
+ current_key = GROQ_API_KEYS[groq_key_index % len(GROQ_API_KEYS)]
876
+ groq_key_index += 1
877
+
878
+ client = Groq(api_key=current_key)
879
+
880
+ def call_groq():
881
+ with open(audio_path, "rb") as file:
882
+ return client.audio.transcriptions.create(
883
+ file=(os.path.basename(audio_path), file.read()),
884
+ model="whisper-large-v3",
885
+ response_format="verbose_json",
886
+ timestamp_granularities=["word", "segment"],
887
+ temperature=0.0,
888
+ language=lang_cfg["whisper_code"],
889
+ prompt=lang_cfg["prompt"]
890
+ )
891
+
892
+ transcription = await asyncio.to_thread(call_groq)
893
+ return transcription
894
+
895
+ # --- تابع بازگشتی هوشمند برای برش فایل‌های صوتی سنگین از نقاط سکوت (چندزبانه) ---
896
+ async def transcribe_audio_recursive(audio_path: str, start_time_offset: float = 0.0, language: str = "fa") -> list:
897
+ file_size = os.path.getsize(audio_path)
898
+ MAX_SIZE = 24 * 1024 * 1024 # 24 MB محدودیت امن
899
+
900
+ if file_size <= MAX_SIZE:
901
+ transcription = await transcribe_audio_via_groq(audio_path, language)
902
+
903
+ # ��ستخراج ساختار داده خروجی Groq
904
+ if hasattr(transcription, "model_dump"):
905
+ data = transcription.model_dump()
906
+ elif hasattr(transcription, "dict"):
907
+ data = transcription.dict()
908
+ elif isinstance(transcription, dict):
909
+ data = transcription
910
+ else:
911
+ try: data = dict(transcription)
912
+ except: data = {}
913
+
914
+ raw_word_items = []
915
+ segments = data.get("segments", [])
916
+ words_list = data.get("words", [])
917
+
918
+ if words_list:
919
+ raw_word_items = words_list
920
+ elif segments:
921
+ for seg in segments:
922
+ text_val = seg.get("text", "").strip()
923
+ if not text_val: continue
924
+ if "words" in seg and seg["words"]:
925
+ raw_word_items.extend(seg["words"])
926
+ else:
927
+ seg_start = float(seg.get("start", 0))
928
+ seg_end = float(seg.get("end", 0))
929
+ w_list = text_val.split()
930
+ if w_list:
931
+ duration = seg_end - seg_start
932
+ word_duration = duration / max(1, len(w_list))
933
+ for i, w_txt in enumerate(w_list):
934
+ w_start_time = seg_start + (i * word_duration)
935
+ raw_word_items.append({
936
+ "word": w_txt,
937
+ "start": round(w_start_time, 3),
938
+ "end": round(w_start_time + word_duration, 3)
939
+ })
940
+
941
+ # اعمال آفست زمانی برای برش‌های بعدی که در جای درست قرار بگیرند
942
+ adjusted_words = []
943
+ for w in raw_word_items:
944
+ if not isinstance(w, dict):
945
+ word_text = getattr(w, "word", getattr(w, "text", ""))
946
+ word_start = float(getattr(w, "start", getattr(w, "start_time", 0)))
947
+ word_end = float(getattr(w, "end", getattr(w, "end_time", 0)))
948
+ else:
949
+ word_text = w.get("word", w.get("text", ""))
950
+ word_start = float(w.get("start", w.get("start_time", 0)))
951
+ word_end = float(w.get("end", w.get("end_time", 0)))
952
+
953
+ adjusted_words.append({
954
+ "word": word_text,
955
+ "start": round(word_start + start_time_offset, 3),
956
+ "end": round(word_end + start_time_offset, 3)
957
+ })
958
+ return adjusted_words
959
+
960
+ else:
961
+ # فایل سنگین‌تر از حد مجاز است، باید برش بخورد
962
+ print(f"File size {file_size} > 24MB. Splitting audio...")
963
+ audio = await asyncio.to_thread(AudioSegment.from_mp3, audio_path)
964
+ mid_point_ms = len(audio) // 2
965
+
966
+ # جستجو برای پیدا کردن نقطه سکوت در محدوده 40% تا 60% فایل
967
+ search_start = int(len(audio) * 0.4)
968
+ search_end = int(len(audio) * 0.6)
969
+ search_chunk = audio[search_start:search_end]
970
+
971
+ def find_silences():
972
+ # تشخیص سکوت (سکوت بالاتر از 500 میلی‌ثانیه با آستانه -40dB)
973
+ return detect_silence(search_chunk, min_silence_len=500, silence_thresh=-40)
974
+
975
+ silences = await asyncio.to_thread(find_silences)
976
+
977
+ if silences:
978
+ # انتخاب سکوتی که نزدیک‌ترین فاصله را به وسط بخش مورد جستجو دارد
979
+ best_silence = silences[len(silences)//2]
980
+ split_point_ms = search_start + (best_silence[0] + best_silence[1]) // 2
981
+ else:
982
+ # اگر هیچ سکوتی پیدا نشد، دقیقاً از وسط برش می‌زنیم
983
+ split_point_ms = mid_point_ms
984
+
985
+ part1_path = audio_path.replace(".mp3", f"_part1_{int(time.time()*1000)}_{random.randint(100,999)}.mp3")
986
+ part2_path = audio_path.replace(".mp3", f"_part2_{int(time.time()*1000)}_{random.randint(100,999)}.mp3")
987
+
988
+ def export_parts():
989
+ audio[:split_point_ms].export(part1_path, format="mp3", bitrate="64k")
990
+ audio[split_point_ms:].export(part2_path, format="mp3", bitrate="64k")
991
+
992
+ await asyncio.to_thread(export_parts)
993
+
994
+ # فراخوانی بازگشتی هوش مصنوعی برای هر دو قسمت
995
+ words1 = await transcribe_audio_recursive(part1_path, start_time_offset, language)
996
+ words2 = await transcribe_audio_recursive(part2_path, start_time_offset + (split_point_ms / 1000.0), language)
997
+
998
+ # پاکسازی فایل‌های تکه‌تکه‌شده
999
+ try:
1000
+ os.remove(part1_path)
1001
+ os.remove(part2_path)
1002
+ except Exception as e:
1003
+ print(f"Cleanup error for split files: {e}")
1004
+
1005
+ # تجمیع نتایج
1006
+ return words1 + words2
1007
+
1008
+ # --- پردازش مو��ت در صف پس‌زمینه (نسخه اصلاح شده بدون فیلتر متنی، چندزبانه و با پشتیبانی از فایل‌های سنگین) ---
1009
+ async def bg_process_upload(task_id: str, raw_path: str, fixed_path: str, audio_path: str, language: str = "fa"):
1010
+ job = upload_jobs.get(task_id)
1011
+ if not job:
1012
+ return
1013
+
1014
+ job.status = UploadJobStatus.PROCESSING
1015
+ try:
1016
+ # ۱. تبدیل ویدیوی خام به فرمت استاندارد 30fps h264
1017
+ proc1 = await asyncio.create_subprocess_exec(
1018
+ "ffmpeg", "-y", "-i", raw_path, "-r", "30", "-c:v", "libx264",
1019
+ "-preset", "ultrafast", "-c:a", "copy", fixed_path,
1020
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
1021
+ )
1022
+ await proc1.communicate()
1023
+
1024
+ w, h, total_duration = get_video_info(fixed_path)
1025
+
1026
+ # ۲. استخراج مستقیم صدا به صورت MP3 با نرخ بیت 64k
1027
+ proc2 = await asyncio.create_subprocess_exec(
1028
+ "ffmpeg", "-y", "-i", fixed_path, "-vn", "-acodec", "libmp3lame",
1029
+ "-ar", "16000", "-ac", "1", "-b:a", "64k", audio_path,
1030
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
1031
+ )
1032
+ await proc2.communicate()
1033
+
1034
+ print(f"--- [BG Task {task_id}] Transcribing audio with Recursive Groq Logic (lang={language}) ---")
1035
+
1036
+ # ۳. ارسال فایل صوتی به تابع هوشمند که فایل‌های سنگین را برش می‌دهد
1037
+ raw_word_items = await transcribe_audio_recursive(audio_path, 0.0, language)
1038
+
1039
+ # پاکسازی فایل‌های موقت پایه
1040
+ if os.path.exists(audio_path):
1041
+ try: os.remove(audio_path)
1042
+ except: pass
1043
+ if os.path.exists(raw_path):
1044
+ try: os.remove(raw_path)
1045
+ except: pass
1046
+
1047
+ if not raw_word_items:
1048
+ job.status = UploadJobStatus.FAILED
1049
+ job.error_message = "تبدیل گفتار به متن ناموفق بود. هیچ متنی در فایل شناسایی نشد."
1050
+ return
1051
+
1052
+ # ۶. گروه‌بندی کلمات به صورت دسته‌های ۵ تایی استاندارد
1053
+ final_segs = []
1054
+ chunk_size = 5
1055
+ for k in range(0, len(raw_word_items), chunk_size):
1056
+ sub = raw_word_items[k:k+chunk_size]
1057
+ if not sub: continue
1058
+ sc_words = []
1059
+ for w in sub:
1060
+ sc_words.append({
1061
+ "word": w["word"],
1062
+ "start": round(w["start"], 3),
1063
+ "end": round(w["end"], 3),
1064
+ "highlight": False
1065
+ })
1066
+
1067
+ if sc_words:
1068
+ final_segs.append({
1069
+ "start": sc_words[0]["start"],
1070
+ "end": sc_words[-1]["end"],
1071
+ "text": " ".join([x["word"] for x in sc_words]),
1072
+ "words": sc_words
1073
+ })
1074
+
1075
+ lang_cfg = SUPPORTED_LANGUAGES.get(language, SUPPORTED_LANGUAGES[DEFAULT_LANGUAGE])
1076
+ suggested_style = {"primaryColor": "#FFFFFF", "outlineColor": "#000000", "font": lang_cfg["default_font"], "fontSize": 60, "backType": "solid"}
1077
+
1078
+ job.result = {
1079
+ "file_id": task_id,
1080
+ "url": f"/temp/{task_id}.mp4",
1081
+ "width": w,
1082
+ "height": h,
1083
+ "segments": final_segs,
1084
+ "suggested_style": suggested_style,
1085
+ "language": language
1086
+ }
1087
+ job.status = UploadJobStatus.COMPLETED
1088
+
1089
+ except Exception as e:
1090
+ if os.path.exists(audio_path):
1091
+ try: os.remove(audio_path)
1092
+ except: pass
1093
+ if os.path.exists(raw_path):
1094
+ try: os.remove(raw_path)
1095
+ except: pass
1096
+ job.status = UploadJobStatus.FAILED
1097
+ job.error_message = f"Processing Error: {str(e)}"
1098
+
1099
+ @app.post("/api/upload")
1100
+ async def upload(background_tasks: BackgroundTasks, file: UploadFile = File(...), language: str = Form("fa")):
1101
+ task_id = str(uuid.uuid4())[:8]
1102
+ ext = file.filename.split('.')[-1]
1103
+ raw_path = f"{TEMP_DIR}/{task_id}_raw.{ext}"
1104
+ fixed_path = f"{TEMP_DIR}/{task_id}.mp4"
1105
+ audio_path = f"{TEMP_DIR}/{task_id}.mp3"
1106
+ lang = normalize_language(language)
1107
+
1108
+ try:
1109
+ with open(raw_path, "wb") as f:
1110
+ shutil.copyfileobj(file.file, f)
1111
+
1112
+ upload_jobs[task_id] = UploadJob(task_id, file.filename, lang)
1113
+
1114
+ background_tasks.add_task(
1115
+ bg_process_upload, task_id, raw_path, fixed_path, audio_path, lang
1116
+ )
1117
+
1118
+ return {"task_id": task_id, "status": UploadJobStatus.QUEUED, "language": lang}
1119
+ except Exception as e:
1120
+ if os.path.exists(raw_path):
1121
+ try: os.remove(raw_path)
1122
+ except: pass
1123
+ raise HTTPException(500, f"Failed to queue upload task: {str(e)}")
1124
+
1125
+ @app.get("/api/upload-status/{task_id}")
1126
+ async def get_upload_status(task_id: str):
1127
+ job = upload_jobs.get(task_id)
1128
+ if not job:
1129
+ raise HTTPException(404, "Upload job not found")
1130
+
1131
+ response = {"task_id": job.id, "status": job.status, "language": job.language}
1132
+ if job.status == UploadJobStatus.COMPLETED:
1133
+ response["result"] = job.result
1134
+ elif job.status == UploadJobStatus.FAILED:
1135
+ response["error"] = job.error_message
1136
+ return response
1137
+
1138
+ import glob
1139
+
1140
+ @app.delete("/api/delete-project/{file_id}")
1141
+ async def delete_project_files(file_id: str):
1142
+ if "/" in file_id or "\\" in file_id or ".." in file_id:
1143
+ raise HTTPException(400, "Invalid file_id")
1144
+
1145
+ raw_video_path = os.path.join(TEMP_DIR, f"{file_id}.mp4")
1146
+ if os.path.exists(raw_video_path):
1147
+ try: os.remove(raw_video_path)
1148
+ except Exception as e: print(f"Error removing raw video: {e}")
1149
+
1150
+ final_patterns = os.path.join(TEMP_DIR, f"{file_id}_final_*.mp4")
1151
+ for f_path in glob.glob(final_patterns):
1152
+ try: os.remove(f_path)
1153
+ except Exception as e: print(f"Error removing final video {f_path}: {e}")
1154
+
1155
+ return {"status": "success", "message": "Files deleted successfully"}
1156
+
1157
+ @app.post("/api/reupload")
1158
+ async def reupload_video(file: UploadFile = File(...), file_id: str = Form(...)):
1159
+ if not file_id or '/' in file_id or '\\' in file_id: raise HTTPException(400, "Invalid file_id")
1160
+ target_path = os.path.join(TEMP_DIR, f"{file_id}.mp4")
1161
+ try:
1162
+ with open(target_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer)
1163
+ except Exception as e: raise HTTPException(500, f"Could not save file: {e}")
1164
+ finally: await file.close()
1165
+ return {"status": "success", "message": f"File {file_id}.mp4 restored."}
1166
+
1167
+ @app.post("/api/enqueue-render")
1168
+ async def enqueue_render(req: ProcessRequest):
1169
+ if not os.path.exists(os.path.join(TEMP_DIR, f"{req.file_id}.mp4")):
1170
+ return JSONResponse(status_code=200, content={"error": "Video not found", "error_code": "VIDEO_NOT_FOUND"})
1171
+ job_id = str(uuid.uuid4())
1172
+ jobs_db[job_id] = Job(job_id, req)
1173
+ await render_queue.put(job_id)
1174
+ return {"job_id": job_id, "status": JobStatus.QUEUED}
1175
+
1176
+ @app.get("/api/job-status/{job_id}")
1177
+ async def get_job_status(job_id: str):
1178
+ job = jobs_db.get(job_id)
1179
+ if not job: raise HTTPException(404, "Job not found")
1180
+ response = {"job_id": job.id, "status": job.status}
1181
+ if job.status == JobStatus.QUEUED:
1182
+ response["queue_position"] = sum(1 for j in jobs_db.values() if j.status == JobStatus.QUEUED and j.created_at < job.created_at) + 1
1183
+ elif job.status == JobStatus.COMPLETED: response["url"] = job.result_url # اصلاح نام متغیر COMPLETED به JobStatus.COMPLETED
1184
+ elif job.status == JobStatus.FAILED: response["error"] = job.error_message
1185
+ return response
index.html ADDED
@@ -0,0 +1,1376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="fa" dir="rtl">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>Subtitle Studio Pro</title>
7
+ <!-- لینک فونت‌ها -->
8
+ <link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;700;900&display=swap" rel="stylesheet">
9
+ <link href="https://fonts.googleapis.com/css2?family=Lalezar&display=swap" rel="stylesheet">
10
+ <link href="https://fonts.googleapis.com/css2?family=Amiri:wght@400;700&display=swap" rel="stylesheet">
11
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
12
+ <!-- لینک به فایل CSS -->
13
+ <link rel="stylesheet" href="/static/css/style.css">
14
+
15
+ <!-- استایل‌های اختصاصی باکس‌ها (به روز شده برای سایز جدید) -->
16
+ <style>
17
+ .box-style-preview {
18
+ background-color: #ffffff;
19
+ border-radius: 8px;
20
+ padding: 4px 8px;
21
+ display: flex;
22
+ align-items: center;
23
+ justify-content: center;
24
+ gap: 2px;
25
+ direction: rtl;
26
+ font-family: 'Vazirmatn';
27
+ font-weight: 900;
28
+ font-size: 0.75rem;
29
+ box-shadow: 0 4px 15px rgba(0,0,0,0.5);
30
+ max-width: 100%;
31
+ overflow: hidden;
32
+ white-space: nowrap;
33
+ }
34
+ .box-word { padding: 2px 4px; border-radius: 4px; transition: all 0.25s cubic-bezier(0.25, 1, 0.5, 1); color: #d1d1d6; position: relative; z-index: 1; }
35
+ .box-word.past { color: #000000; }
36
+ .box-word.active { background-color: #1a1a1a; color: #ffffff; transform: scale(1.1); box-shadow: 0 4px 10px rgba(0,0,0,0.2); z-index: 2; }
37
+
38
+ /* استایل آلفا (به روز شده برای سایز جدید) */
39
+ .alpha-style-preview {
40
+ background-color: transparent; border-radius: 14px; padding: 5px; display: flex; align-items: center; justify-content: center;
41
+ gap: 2px;
42
+ direction: rtl; font-family: 'Vazirmatn'; font-weight: 900; font-size: 0.75rem; max-width: 100%; overflow: hidden; white-space: nowrap;
43
+ }
44
+ .alpha-word { padding: 2px 4px; border-radius: 8px; transition: all 0.25s; color: rgba(255, 255, 255, 0.35); background-color: transparent; position: relative; z-index: 1; }
45
+ .alpha-word.past { color: #ffffff; background-color: transparent; }
46
+ .alpha-word.active { background: linear-gradient(135deg, #7c4dff, #b388ff); color: #ffffff; transform: scale(1.1); z-index: 2; box-shadow: 0 4px 12px rgba(124, 77, 255, 0.4); }
47
+
48
+ /* --- استایل پیغام هوشمندانه هوش مصنوعی --- */
49
+ .coach-overlay {
50
+ position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(5px);
51
+ z-index: 9999; display: none; opacity: 0; transition: opacity 0.3s;
52
+ }
53
+ .coach-overlay.show { opacity: 1; }
54
+
55
+ .coach-tooltip {
56
+ position: fixed; left: 50%; transform: translateX(-50%);
57
+ background: linear-gradient(145deg, #1e1e2e, #161622); border: 2px solid #7C4DFF;
58
+ border-radius: 24px; padding: 25px; width: 90%; max-width: 380px; text-align: center;
59
+ box-shadow: 0 20px 50px rgba(124, 77, 255, 0.5);
60
+ animation: popIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
61
+ opacity: 0;
62
+ z-index: 10001;
63
+ }
64
+
65
+ @keyframes popIn {
66
+ 0% { opacity: 0; transform: translateX(-50%) scale(0.8) translateY(20px); }
67
+ 100% { opacity: 1; transform: translateX(-50%) scale(1) translateY(0); }
68
+ }
69
+
70
+ .coach-icon {
71
+ width: 75px; height: 75px; background: linear-gradient(135deg, #FF0080, #7C4DFF);
72
+ border-radius: 50%; display: flex; align-items: center; justify-content: center;
73
+ font-size: 2.5rem; color: #fff; margin: -60px auto 15px auto;
74
+ box-shadow: 0 10px 20px rgba(124, 77, 255, 0.6); border: 4px solid #1e1e2e;
75
+ }
76
+
77
+ .coach-title { font-size: 1.3rem; font-weight: 900; color: #fff; margin-bottom: 12px; }
78
+ .coach-text { font-size: 0.95rem; color: #e2e8f0; line-height: 1.7; margin-bottom: 25px; }
79
+
80
+ .coach-btn {
81
+ background: linear-gradient(90deg, #7C4DFF, #00D7FF); border: none; padding: 15px 30px;
82
+ border-radius: 18px; color: #fff; font-size: 1.1rem; font-weight: bold; font-family: inherit;
83
+ cursor: pointer; box-shadow: 0 5px 20px rgba(0, 215, 255, 0.4); transition: transform 0.2s;
84
+ width: 100%;
85
+ }
86
+ .coach-btn:active { transform: scale(0.95); }
87
+
88
+ .coach-arrow {
89
+ position: fixed; font-size: 2.5rem; color: #00D7FF;
90
+ animation: bounceArrowDown 1.5s infinite;
91
+ filter: drop-shadow(0 0 15px rgba(0,215,255,0.9));
92
+ z-index: 10001;
93
+ }
94
+
95
+ @keyframes bounceArrowDown {
96
+ 0%, 100% { transform: translateX(-50%) translateY(0); }
97
+ 50% { transform: translateX(-50%) translateY(-15px); }
98
+ }
99
+
100
+ .relative-z-coach {
101
+ position: relative;
102
+ z-index: 10000 !important;
103
+ box-shadow: 0 0 0 4px rgba(124, 77, 255, 0.8), 0 0 20px rgba(124, 77, 255, 0.8);
104
+ border-radius: 12px;
105
+ background: #2a2a2a !important;
106
+ transition: all 0.3s;
107
+ }
108
+
109
+ /* --- استایل‌های دکمه + (اضافه کردن کلمه) کاملاً جدا از کلمه --- */
110
+ .add-word-btn {
111
+ display: none; /* فقط وقتی کلمه قبلیش اکتیو هست دیده میشه */
112
+ width: 32px;
113
+ height: 32px;
114
+ border-radius: 50%;
115
+ background: var(--chip-bg); /* همرنگ پس‌زمینه کلمات دیگر */
116
+ color: #aaa; /* همرنگ متن کلمات دیگر */
117
+ align-items: center;
118
+ justify-content: center;
119
+ font-size: 1.1rem;
120
+ margin-right: 8px; /* ایجاد فاصله از کلمه */
121
+ cursor: pointer;
122
+ transition: transform 0.2s;
123
+ z-index: 10;
124
+ }
125
+ .add-word-btn:active {
126
+ transform: scale(0.85);
127
+ }
128
+ /* نمایش دکمه بدون انیمیشن */
129
+ .word-chip.active + .add-word-btn {
130
+ display: flex;
131
+ }
132
+
133
+ /* استایل کلمات اضافه شده دستی (زیر کلمه) */
134
+ .word-chip.manual-word {
135
+ font-size: 0.8rem;
136
+ padding: 6px 12px;
137
+ background: rgba(124, 77, 255, 0.1);
138
+ border: 1px dashed #7C4DFF;
139
+ color: #ddd;
140
+ }
141
+ .word-chip.manual-word.active {
142
+ background: var(--primary);
143
+ border: 1px solid #fff;
144
+ color: #fff;
145
+ font-weight: bold;
146
+ }
147
+ </style>
148
+ </head>
149
+ <body>
150
+
151
+ <!-- Toast -->
152
+ <div id="toastNotification" class="toast-notification">
153
+ <i class="fa-solid fa-circle-check"></i>
154
+ <span>این رنگ در حافظه رنگ ها ذخیره شد</span>
155
+ </div>
156
+
157
+ <!-- شیت انتخاب زبان زیرنویس -->
158
+ <div class="action-sheet-overlay" id="languageSheetOverlay" onclick="closeLanguageSheet()">
159
+ <div class="action-sheet lang-sheet" id="languageSheet" onclick="event.stopPropagation()">
160
+ <div class="sheet-title-main">زبان زیرنویس را انتخاب کنید</div>
161
+ <p class="lang-sheet-hint">هوش مصنوعی زیرنویس را دقیقاً به همین زبان تولید می‌کند</p>
162
+ <div class="lang-list" id="languageList"></div>
163
+ </div>
164
+ </div>
165
+
166
+ <!-- کارت تایید پیش از آپلود (انتخاب ویدیو + انتخاب زبان + شروع آپلود) -->
167
+ <div class="pre-upload-overlay" id="preUploadOverlay">
168
+ <div class="pre-upload-card">
169
+ <button class="pre-upload-close" onclick="cancelPreUpload()"><i class="fa-solid fa-xmark"></i></button>
170
+ <div class="pre-upload-icon"><i class="fa-solid fa-clapperboard"></i></div>
171
+ <div class="pre-upload-filename" id="preUploadFileName">video.mp4</div>
172
+ <div class="pre-upload-filesize" id="preUploadFileSize">۰ مگابایت</div>
173
+
174
+ <div class="pre-upload-lang-row">
175
+ <span class="pre-upload-lang-label"><i class="fa-solid fa-language"></i> زبان زیرنویس</span>
176
+ <button class="lang-pill" id="langPillBtn" onclick="openLanguageSheet()">
177
+ <span id="langPillFlag">🇮🇷</span> <span id="langPillLabel">فارسی</span>
178
+ <i class="fa-solid fa-chevron-down"></i>
179
+ </button>
180
+ </div>
181
+
182
+ <button class="btn-start-upload" onclick="confirmStartUpload()">
183
+ <i class="fa-solid fa-wand-magic-sparkles"></i> شروع آپلود و تولید زیرنویس
184
+ </button>
185
+ </div>
186
+ </div>
187
+
188
+ <!-- اینپوت فایل مخفی (برای آپلود) -->
189
+ <input type="file" id="fileIn" hidden accept="video/*" onchange="onVideoFileChosen()">
190
+
191
+ <!-- مدال تغییر نام (پروژه) -->
192
+ <div id="renameModal" class="modal-overlay">
193
+ <div class="modal-box">
194
+ <div class="modal-title">تغییر نام پروژه</div>
195
+ <input type="text" id="renameInput" class="input-field" placeholder="نام جدید را وارد کنید">
196
+ <div class="modal-actions">
197
+ <button class="modal-btn btn-confirm" onclick="saveRename()">ذخیره</button>
198
+ <button class="modal-btn btn-cancel" onclick="closeRenameModal()">لغو</button>
199
+ </div>
200
+ </div>
201
+ </div>
202
+
203
+ <!-- مدال اضافه کردن کلمه جدید دستی -->
204
+ <div id="addWordModal" class="modal-overlay">
205
+ <div class="modal-box">
206
+ <div class="modal-icon" style="color: #00D7FF;"><i class="fa-solid fa-keyboard"></i></div>
207
+ <div class="modal-title">اضافه کردن کلمه جدید</div>
208
+ <div class="modal-desc">کلمه جدید دقیقاً بعد از کلمه انتخاب شده اضافه می‌شود و زمان‌بندی به صورت خودکار محاسبه می‌گردد.</div>
209
+ <input type="text" id="newWordInput" class="input-field" placeholder="کلمه را تایپ کنید...">
210
+ <div class="modal-actions">
211
+ <button class="modal-btn btn-confirm" style="background: #00D7FF; color: #000;" onclick="confirmAddWord()">اضافه کن</button>
212
+ <button class="modal-btn btn-cancel" onclick="closeAddWordModal()">انصراف</button>
213
+ </div>
214
+ </div>
215
+ </div>
216
+
217
+ <!-- مدال تایید (عمومی) -->
218
+ <div id="confirmationModal" class="modal-overlay">
219
+ <div class="modal-box">
220
+ <div id="confirmIcon" class="modal-icon"><i class="fa-solid fa-question-circle"></i></div>
221
+ <div id="confirmTitle" class="modal-title">آیا مطمئن هستید؟</div>
222
+ <div id="confirmDesc" class="modal-desc">این عمل غیرقابل بازگشت است.</div>
223
+ <div class="modal-actions">
224
+ <button id="confirmBtn" class="modal-btn btn-confirm-action">بله</button>
225
+ <button class="modal-btn btn-cancel" onclick="closeConfirmationModal()">انصراف</button>
226
+ </div>
227
+ </div>
228
+ </div>
229
+
230
+ <!-- منوی مدیریت پروژه (پایین صفحه) -->
231
+ <div class="action-sheet-overlay" id="projectActionOverlay" onclick="closeActionSheet()">
232
+ <div class="action-sheet" id="projectActionSheet" onclick="event.stopPropagation()">
233
+ <div class="sheet-title-main">مدیریت پروژه</div>
234
+ <button class="sheet-btn" onclick="openRenameModal()">
235
+ <i class="fa-solid fa-pen"></i> تغییر نام
236
+ </button>
237
+ <button class="sheet-btn delete-btn" onclick="openDeleteConfirmation()">
238
+ <i class="fa-solid fa-trash-can"></i> حذف پروژه
239
+ </button>
240
+ </div>
241
+ </div>
242
+
243
+ <!-- صفحه خانه -->
244
+ <div id="homeScreen">
245
+ <div class="home-header">پروژه‌های من</div>
246
+ <div id="noProjectsPlaceholder" style="display: none;">
247
+ <div class="placeholder-icon"><i class="fa-solid fa-film"></i></div>
248
+ <div class="placeholder-text">هنوز پروژه‌ای ساخته نشده!</div>
249
+ <p style="color:#555; font-size: 0.9rem; margin-top:10px;">برای شروع، روی دکمه "ایجاد پروژه" بزنید</p>
250
+ </div>
251
+ <div class="projects-grid" id="projectsGrid"></div>
252
+ <button class="fab-add" onclick="document.getElementById('fileIn').click()">
253
+ <i class="fa-solid fa-plus-circle"></i> انتخاب ویدیو
254
+ </button>
255
+ </div>
256
+
257
+ <!-- بخش ادیتور -->
258
+ <div id="editorScreen">
259
+ <div class="top-bar">
260
+ <button class="btn-home" onclick="goHome()">
261
+ <i class="fa-solid fa-arrow-right"></i>
262
+ </button>
263
+ <div style="font-weight:bold" id="currentProjectTitle">پروژه</div>
264
+ <button class="btn-exp" onclick="render()" style="padding: 8px 20px; font-size: 0.9rem;">خروجی نهایی <i class="fa-solid fa-chevron-left"></i></button>
265
+ </div>
266
+
267
+ <div id="workspace">
268
+ <div id="scaler">
269
+ <div id="videoContainer" onclick="togglePlay()">
270
+ <video id="vid" playsinline webkit-playsinline></video>
271
+ <div id="playOverlay"><i class="fa-solid fa-play"></i></div>
272
+ <div id="subtitleLayer"><div id="activeText" ondblclick="toggleTool('text')"></div></div>
273
+ </div>
274
+ </div>
275
+ </div>
276
+
277
+ <div class="magic-bar">
278
+ <button class="btn-ai-magic" onclick="toggleTool('magic')" id="btn-magic">
279
+ <i class="fa-solid fa-wand-magic-sparkles"></i>
280
+ <span>طراحـی هوشمـنـد AI</span>
281
+ </button>
282
+ </div>
283
+
284
+ <div class="controls-bar">
285
+ <button class="tool-btn" onclick="toggleTool('style')" id="btn-style"><i class="fa-solid fa-layer-group"></i><span>استایل</span></button>
286
+ <button class="tool-btn" onclick="toggleTool('appearance')" id="btn-appearance"><i class="fa-solid fa-palette"></i><span>ظاهر</span></button>
287
+ <button class="tool-btn" onclick="toggleTool('font')" id="btn-font"><i class="fa-solid fa-sliders"></i><span>تنظیمات</span></button>
288
+ <button class="tool-btn" onclick="toggleTool('text')" id="btn-text"><i class="fa-solid fa-pen-to-square"></i><span>متن</span></button>
289
+ </div>
290
+
291
+ <div id="toolsContainer">
292
+ <!-- ابزار هوش مصنوعی -->
293
+ <div id="section-magic" class="tool-section">
294
+ <label>توصیف کن چه استایل متحرکی می‌خوای بسازم:</label>
295
+ <textarea id="magicPrompt" style="min-height: 60px;" placeholder="مثال: یه استایل آبشاری با کلمات نئونی که از بالا می‌ریزن..."></textarea>
296
+ <button class="btn-magic-action" onclick="generateAIStyle()">
297
+ <i class="fa-solid fa-sparkles"></i> بساز و اعمال کن
298
+ </button>
299
+ </div>
300
+
301
+ <!-- ابزار استایل -->
302
+ <div id="section-style" class="tool-section">
303
+ <div class="custom-style-container" id="customAccordion" onclick="toggleCustomAccordion()">
304
+ <div class="custom-header">
305
+ <div class="custom-header-title">
306
+ <i class="fa-solid fa-sliders"></i> استایل اختصاصی
307
+ </div>
308
+ <i class="fa-solid fa-chevron-down toggle-icon"></i>
309
+ </div>
310
+
311
+ <div class="custom-content" onclick="event.stopPropagation()">
312
+ <label>نوع نمایش:</label>
313
+ <div class="style-grid-2col" style="margin-bottom: 25px;">
314
+ <div class="style-card" onclick="activateCustomStyle(); setStylePreset('classic', this)" ondblclick="handleClassicDoubleClick(this)" style="background:rgba(0,0,0,0.3); min-height: 100px; aspect-ratio: auto;">
315
+ <div style="font-weight:bold; font-size:1rem; color:#fff;">ساده</div>
316
+ <div style="color:#ccc; font-size:0.8rem;">کلاسیک</div>
317
+ </div>
318
+ <div class="style-card" onclick="activateCustomStyle(); setStylePreset('progressive_write', this)" style="background:rgba(0,0,0,0.3); min-height: 100px; aspect-ratio: auto;">
319
+ <div style="font-weight:bold; font-size:1rem; color:#fff;">کلمه به کلمه</div>
320
+ <div style="color:#ccc; font-size:0.8rem;">تایپ تدریجی</div>
321
+ </div>
322
+ </div>
323
+
324
+ <div class="color-trigger-row">
325
+ <div class="color-trigger" onclick="openColorPicker('main', event)">
326
+ <span class="color-label">رنگ متن</span>
327
+ <div class="color-preview-circle" id="preview-main-btn" style="background-color:#ffffff;"></div>
328
+ </div>
329
+ <div class="color-trigger" onclick="openColorPicker('bg', event)">
330
+ <span class="color-label">رنگ حاشیه</span>
331
+ <div class="color-preview-circle" id="preview-bg-btn" style="background-color:#000000;"></div>
332
+ </div>
333
+ </div>
334
+
335
+ <div class="row" style="display:flex; gap:10px;">
336
+ <button class="btn-exp mode-btn active" onclick="activateCustomStyle(); setMode('solid')">کادر پر</button>
337
+ <button class="btn-exp mode-btn" onclick="activateCustomStyle(); setMode('transparent')">شیشه‌ای</button>
338
+ <button class="btn-exp mode-btn" onclick="activateCustomStyle(); setMode('outline')">فقط دورخط</button>
339
+ </div>
340
+ </div>
341
+ </div>
342
+
343
+ <div style="border-top:1px solid #333; margin: 20px 0 15px 0;"></div>
344
+ <p style="color:#888; font-size:0.85rem; margin-bottom:15px;">قالب‌های آماده هوشمند:</p>
345
+
346
+ <div class="style-grid">
347
+
348
+ <!-- ردیف ۱ راست: نئون آلفا -->
349
+ <div class="style-card selected" onclick="setStylePreset('alpha_gradient', this)">
350
+ <div class="alpha-style-preview demo-row" id="animPreviewAlpha">
351
+ <span class="alpha-word" data-dw="0">سلام</span>
352
+ <span class="alpha-word" data-dw="1">اینجا</span>
353
+ <span class="alpha-word" data-dw="2">آلفا</span>
354
+ <span class="alpha-word" data-dw="3">است</span>
355
+ </div>
356
+ <div class="card-info">
357
+ <div class="card-title-style">نئون آلفا</div>
358
+ </div>
359
+ </div>
360
+
361
+ <!-- ردیف ۱ چپ: موزیک پلیر -->
362
+ <div class="style-card" onclick="setStylePreset('music_player', this)">
363
+ <div class="music-style-preview" style="height: auto; width:100%; display: flex; align-items: center; justify-content: center; background: transparent;">
364
+ <div style="background: #ffffff; padding: 4px 15px; border-radius: 50px; box-shadow: 0 4px 10px rgba(0,0,0,0.2); color: #000000; font-weight: 900; font-size: 0.7rem; font-family: 'Vazirmatn';">
365
+ <span class="demo-full">سلام اینجا آلفا است</span>
366
+ </div>
367
+ </div>
368
+ <div class="card-info">
369
+ <div class="card-title-style">موزیک پلیر</div>
370
+ </div>
371
+ </div>
372
+
373
+ <!-- ردیف ۲ راست: باکس متحرک -->
374
+ <div class="style-card" onclick="setStylePreset('instagram_box', this)">
375
+ <div class="box-style-preview demo-row" id="animPreviewBox">
376
+ <span class="box-word" data-dw="0">سلام</span>
377
+ <span class="box-word" data-dw="1">اینجا</span>
378
+ <span class="box-word" data-dw="2">آلفا</span>
379
+ <span class="box-word" data-dw="3">است</span>
380
+ </div>
381
+ <div class="card-info">
382
+ <div class="card-title-style">باکس متحرک</div>
383
+ </div>
384
+ </div>
385
+
386
+ <!-- ردیف ۲ چپ: کارائوکه رنگی -->
387
+ <div class="style-card" onclick="setStylePreset('karaoke_static', this)">
388
+ <div class="anim-box demo-row" id="animPreviewStatic">
389
+ <span class="anim-word static-anim" data-dw="0">سلام</span>
390
+ <span class="anim-word static-anim" data-dw="1">اینجا</span>
391
+ <span class="anim-word static-anim" data-dw="2">آلفا</span>
392
+ <span class="anim-word static-anim" data-dw="3">است</span>
393
+ </div>
394
+ <div class="card-info">
395
+ <div class="card-title-style">کارائوکه رنگی</div>
396
+ </div>
397
+ </div>
398
+
399
+ <!-- ردیف ۳ راست: هایلایت آبی -->
400
+ <div class="style-card" onclick="setStylePreset('auto_director', this)">
401
+ <div class="anim-box demo-row">
402
+ <span class="anim-word" data-dw="0" style="color:#00D7FF; text-shadow:0 0 10px rgba(0, 215, 255, 0.6);">سلام</span>
403
+ <span class="anim-word" data-dw-range="1-3">اینجا آلفا است</span>
404
+ </div>
405
+ <div class="card-info">
406
+ <div class="card-title-style">هایلایت آبی</div>
407
+ </div>
408
+ </div>
409
+
410
+ <!-- ردیف ۳ چپ: سفید مینیمال -->
411
+ <div class="style-card" onclick="setStylePreset('plain_white', this)">
412
+ <div class="demo-full" style="font-size:0.9rem; color:#fff; font-weight:900; white-space:nowrap;">سلام اینجا آلفا است</div>
413
+ <div class="card-info">
414
+ <div class="card-title-style">سفید مینیمال</div>
415
+ </div>
416
+ </div>
417
+
418
+ <!-- ردیف ۴: حاشیه مشکی -->
419
+ <div class="style-card" onclick="setStylePreset('white_outline', this)">
420
+ <div class="demo-full" style="font-size:0.9rem; color:#fff; font-weight:900; -webkit-text-stroke: 0.8px #000; text-shadow: 1px 1px 2px rgba(0,0,0,0.5); white-space:nowrap;">سلام اینجا آلفا است</div>
421
+ <div class="card-info">
422
+ <div class="card-title-style">حاشیه مشکی</div>
423
+ </div>
424
+ </div>
425
+
426
+ <!-- استایل جدید: نوار ساده -->
427
+ <div class="style-card" onclick="setStylePreset('simple_bar', this)">
428
+ <div style="width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;">
429
+ <div style="width: 60%; height: 4px; background: #fff; border-radius: 2px; position: relative;">
430
+ <div style="width: 50%; height: 6px; background: #FDD835; position: absolute; top: -1px; left: 0; border-radius: 2px; box-shadow: 0 0 5px rgba(253,216,53,0.5);"></div>
431
+ </div>
432
+ <div class="demo-full" style="background: rgba(255,255,255,0.2); border-radius: 8px; padding: 4px 10px; font-size: 0.7rem; color: #fff; font-weight: bold;">
433
+ سلام اینجا آلفا است
434
+ </div>
435
+ </div>
436
+ <div class="card-info">
437
+ <div class="card-title-style">نوار ساده</div>
438
+ </div>
439
+ </div>
440
+
441
+ <!-- استایل جدید: لبه‌های تیره -->
442
+ <div class="style-card" onclick="setStylePreset('dark_edges', this)">
443
+ <div class="demo-full" style="
444
+ color: #fff;
445
+ font-weight: 900;
446
+ font-size: 0.75rem;
447
+ padding: 5px 12px;
448
+ border-radius: 8px;
449
+ background: linear-gradient(to left, rgba(0,0,0,0.8) 0%, transparent 50%, rgba(0,0,0,0.8) 100%), #AA00FF;
450
+ border: 1px solid rgba(255, 255, 255, 0.2);
451
+ white-space: nowrap;
452
+ ">
453
+ سلام اینجا آلفا است
454
+ </div>
455
+ <div class="card-info">
456
+ <div class="card-title-style">آبی نئون</div>
457
+ </div>
458
+ </div>
459
+
460
+ <!-- استایل جدید: ابر نئونی -->
461
+ <div class="style-card" onclick="setStylePreset('blue_cloud', this)">
462
+ <div class="demo-full" style="
463
+ background: transparent; /* حذف کادر سیاه و شفاف‌سازی پس‌زمینه */
464
+ padding: 6px 12px;
465
+ color: #00b4ff;
466
+ font-family: 'Sarbaz', sans-serif; /* هماهنگ کردن فونت پیش‌فرض به سرباز */
467
+ font-weight: 900;
468
+ font-size: 0.9rem;
469
+ -webkit-text-stroke: 0.5px #ffffff;
470
+ text-shadow: 0 0 10px rgba(0, 162, 255, 0.9);
471
+ white-space: nowrap;
472
+ ">سلام اینجا آلفا است</div>
473
+ <div class="card-info">
474
+ <div class="card-title-style">ابر نئونی</div> <!-- تغییر نام به عنوان شکیل‌تر -->
475
+ </div>
476
+ </div>
477
+
478
+ <!-- استایل جدید: آبی دورسفید (بدون ابر) -->
479
+ <div class="style-card" onclick="setStylePreset('blue_outline', this)">
480
+ <div class="demo-full" style="
481
+ background: transparent;
482
+ padding: 6px 12px;
483
+ color: #0000FF;
484
+ font-family: 'Sarbaz', sans-serif;
485
+ font-weight: 900;
486
+ font-size: 0.9rem;
487
+ -webkit-text-stroke: 1.3px #ffffff;
488
+ paint-order: stroke fill;
489
+ -webkit-paint-order: stroke fill;
490
+ white-space: nowrap;
491
+ ">سلام اینجا آلفا است</div>
492
+ <div class="card-info">
493
+ <div class="card-title-style">حاشیه سفید</div>
494
+ </div>
495
+ </div>
496
+
497
+ <!-- استایل جدید: پاپ‌آپ فنری -->
498
+ <div class="style-card" onclick="setStylePreset('spring_popup', this)">
499
+ <div class="demo-row" style="display: flex; gap: 4px; justify-content: center; align-items: center; background: transparent; width: 100%; height: 35px; direction: rtl; font-family: 'Vazirmatn', sans-serif;">
500
+ <span data-dw="0" style="font-weight: 900; font-size: 0.75rem; color: rgba(255, 255, 255, 0.35);">سلام</span>
501
+ <span data-dw="1" style="font-weight: 900; font-size: 0.8rem; color: #ffffff;">اینجا</span>
502
+ <span data-dw="2" style="font-weight: 900; font-size: 0.75rem; color: rgba(255, 255, 255, 0.35);">آلفا</span>
503
+ <span data-dw="3" style="font-weight: 900; font-size: 0.75rem; color: rgba(255, 255, 255, 0.35);">است</span>
504
+ </div>
505
+ <div class="card-info">
506
+ <div class="card-title-style">پاپ‌آپ فنری</div>
507
+ </div>
508
+ </div>
509
+
510
+ <!-- استایل جدید: ریزان ساده (بدون کادر) -->
511
+ <div class="style-card" onclick="setStylePreset('falling_plain', this)">
512
+ <div class="demo-row" style="display: flex; gap: 4px; justify-content: center; align-items: center; background: transparent; width: 100%; height: 35px; direction: rtl; font-family: 'Vazirmatn', sans-serif;">
513
+ <span class="fpl-anim fpl-1" data-dw="0" style="font-weight: 900; font-size: 0.75rem;">سلام</span>
514
+ <span class="fpl-anim fpl-2" data-dw="1" style="font-weight: 900; font-size: 0.75rem;">اینجا</span>
515
+ <span class="fpl-anim fpl-3" data-dw="2" style="font-weight: 900; font-size: 0.75rem;">آلفا</span>
516
+ <span class="fpl-anim fpl-4" data-dw="3" style="font-weight: 900; font-size: 0.75rem;">است</span>
517
+ </div>
518
+ <style>
519
+ .fpl-anim { display: inline-block; opacity: 0; transform: translateY(-15px); color: #fff; }
520
+ .fpl-1 { animation: fplDrop1 4s infinite; }
521
+ .fpl-2 { animation: fplDrop2 4s infinite; }
522
+ .fpl-3 { animation: fplDrop3 4s infinite; }
523
+ .fpl-4 { animation: fplDrop4 4s infinite; }
524
+
525
+ @keyframes fplDrop1 {
526
+ 0% { opacity: 0; transform: translateY(-15px); color: #FFEB3B; }
527
+ 10%, 20% { opacity: 1; transform: translateY(0); color: #FFEB3B; }
528
+ 21%, 85% { opacity: 1; transform: translateY(0); color: #fff; }
529
+ 86%, 100% { opacity: 0; transform: translateY(0); }
530
+ }
531
+ @keyframes fplDrop2 {
532
+ 0%, 20% { opacity: 0; transform: translateY(-15px); color: #FFEB3B; }
533
+ 30%, 40% { opacity: 1; transform: translateY(0); color: #FFEB3B; }
534
+ 41%, 85% { opacity: 1; transform: translateY(0); color: #fff; }
535
+ 86%, 100% { opacity: 0; transform: translateY(0); }
536
+ }
537
+ @keyframes fplDrop3 {
538
+ 0%, 40% { opacity: 0; transform: translateY(-15px); color: #FFEB3B; }
539
+ 50%, 60% { opacity: 1; transform: translateY(0); color: #FFEB3B; }
540
+ 61%, 85% { opacity: 1; transform: translateY(0); color: #fff; }
541
+ 86%, 100% { opacity: 0; transform: translateY(0); }
542
+ }
543
+ @keyframes fplDrop4 {
544
+ 0%, 60% { opacity: 0; transform: translateY(-15px); color: #FFEB3B; }
545
+ 70%, 85% { opacity: 1; transform: translateY(0); color: #FFEB3B; }
546
+ 86%, 100% { opacity: 0; transform: translateY(0); }
547
+ }
548
+ </style>
549
+ <div class="card-info">
550
+ <div class="card-title-style">ریزان ساده</div>
551
+ </div>
552
+ </div>
553
+
554
+ <!-- استایل جدید: کلمات ریزان (نسخه با کلمه فعال) -->
555
+
556
+ <!-- استایل جدید: کلمات ریزان (نسخه با کلمه فعال) -->
557
+ <div class="style-card" onclick="setStylePreset('falling_words', this)">
558
+ <div class="demo-row" style="background-color: #F77D57; padding: 6px 14px; border-radius: 12px; direction: rtl; font-family: 'Vazirmatn'; font-weight: 900; font-size: 0.75rem; color: #fff; display: flex; gap: 4px; min-width: 100px; justify-content: center;">
559
+ <span class="fw-anim fw-1" data-dw="0">سلام</span>
560
+ <span class="fw-anim fw-2" data-dw="1">اینجا</span>
561
+ <span class="fw-anim fw-3" data-dw="2">آلفا</span>
562
+ <span class="fw-anim fw-4" data-dw="3">است</span>
563
+ </div>
564
+ <style>
565
+ .fw-anim { display: inline-block; opacity: 0; transform: translateY(-15px); color: #fff; }
566
+
567
+ .fw-1 { animation: fwDrop1 4s infinite; }
568
+ .fw-2 { animation: fwDrop2 4s infinite; }
569
+ .fw-3 { animation: fwDrop3 4s infinite; }
570
+ .fw-4 { animation: fwDrop4 4s infinite; }
571
+
572
+ @keyframes fwDrop1 {
573
+ 0% { opacity: 0; transform: translateY(-15px); color: #FFEB3B; }
574
+ 10%, 20% { opacity: 1; transform: translateY(0); color: #FFEB3B; }
575
+ 21%, 85% { opacity: 1; transform: translateY(0); color: #fff; }
576
+ 86%, 100% { opacity: 0; transform: translateY(0); }
577
+ }
578
+ @keyframes fwDrop2 {
579
+ 0%, 20% { opacity: 0; transform: translateY(-15px); color: #FFEB3B; }
580
+ 30%, 40% { opacity: 1; transform: translateY(0); color: #FFEB3B; }
581
+ 41%, 85% { opacity: 1; transform: translateY(0); color: #fff; }
582
+ 86%, 100% { opacity: 0; transform: translateY(0); }
583
+ }
584
+ @keyframes fwDrop3 {
585
+ 0%, 40% { opacity: 0; transform: translateY(-15px); color: #FFEB3B; }
586
+ 50%, 60% { opacity: 1; transform: translateY(0); color: #FFEB3B; }
587
+ 61%, 85% { opacity: 1; transform: translateY(0); color: #fff; }
588
+ 86%, 100% { opacity: 0; transform: translateY(0); }
589
+ }
590
+ @keyframes fwDrop4 {
591
+ 0%, 60% { opacity: 0; transform: translateY(-15px); color: #FFEB3B; }
592
+ 70%, 85% { opacity: 1; transform: translateY(0); color: #FFEB3B; }
593
+ 86%, 100% { opacity: 0; transform: translateY(0); }
594
+ }
595
+ </style>
596
+ <div class="card-info">
597
+ <div class="card-title-style">کلمات ریزان</div>
598
+ </div>
599
+ </div>
600
+
601
+ <!-- استایل جدید: کادر حاشیه قرمز -->
602
+ <div class="style-card" onclick="setStylePreset('red_outline_box', this)">
603
+ <div class="demo-row" style="background-color: #F77D57; padding: 6px 14px; border-radius: 12px; direction: rtl; font-family: 'Vazirmatn'; font-weight: 900; font-size: 0.75rem; color: #fff; display: flex; gap: 4px; min-width: 100px; justify-content: center; -webkit-text-stroke: 1px #ff0000; paint-order: stroke fill;">
604
+ <span data-dw="0">سلام</span>
605
+ <span data-dw="1">اینجا</span>
606
+ <span data-dw="2">آلفا</span>
607
+ <span data-dw="3">است</span>
608
+ </div>
609
+ <div class="card-info">
610
+ <div class="card-title-style">حاشیه قرمز</div>
611
+ </div>
612
+ </div>
613
+
614
+ <!-- استایل جدید: کارائوکه ریزان (Falling Karaoke) -->
615
+ <div class="style-card" onclick="setStylePreset('falling_karaoke', this)">
616
+ <div class="demo-row" style="display: flex; gap: 2px; justify-content: center; width: 100%; direction: rtl;">
617
+ <span class="fk-prev-word fkw-1"><span class="fk-prev-text" data-dw="0">سلام</span></span>
618
+ <span class="fk-prev-word fkw-2"><span class="fk-prev-text" data-dw="1">اینجا</span></span>
619
+ <span class="fk-prev-word fkw-3"><span class="fk-prev-text" data-dw="2">آلفا</span></span>
620
+ <span class="fk-prev-word fkw-4"><span class="fk-prev-text" data-dw="3">است</span></span>
621
+ </div>
622
+ <style>
623
+ .fk-prev-word {
624
+ position: relative;
625
+ display: inline-block;
626
+ padding: 2px 5px;
627
+ border-radius: 6px;
628
+ font-weight: 900;
629
+ font-family: 'Vazirmatn', sans-serif;
630
+ font-size: 0.75rem;
631
+ color: #fff;
632
+ z-index: 1;
633
+ }
634
+ .fk-prev-word::before {
635
+ content: '';
636
+ position: absolute;
637
+ inset: 0;
638
+ background: #F77D57;
639
+ border-radius: 6px;
640
+ z-index: -1;
641
+ opacity: 0;
642
+ }
643
+ .fk-prev-text {
644
+ display: inline-block;
645
+ opacity: 0;
646
+ transform: translateY(-15px);
647
+ }
648
+
649
+ /* اتصال انیمیشن‌ها به ترتیب */
650
+ .fkw-1 .fk-prev-text { animation: fkText1 4s infinite; }
651
+ .fkw-1::before { animation: fkBg1 4s infinite; }
652
+ .fkw-2 .fk-prev-text { animation: fkText2 4s infinite; }
653
+ .fkw-2::before { animation: fkBg2 4s infinite; }
654
+ .fkw-3 .fk-prev-text { animation: fkText3 4s infinite; }
655
+ .fkw-3::before { animation: fkBg3 4s infinite; }
656
+ .fkw-4 .fk-prev-text { animation: fkText4 4s infinite; }
657
+ .fkw-4::before { animation: fkBg4 4s infinite; }
658
+
659
+ /* انیمیشن سقوط کلمات */
660
+ @keyframes fkText1 {
661
+ 0%, 5% { opacity: 0; transform: translateY(-15px); }
662
+ 10%, 90% { opacity: 1; transform: translateY(0); }
663
+ 95%, 100% { opacity: 0; transform: translateY(0); }
664
+ }
665
+ @keyframes fkText2 {
666
+ 0%, 25% { opacity: 0; transform: translateY(-15px); }
667
+ 30%, 90% { opacity: 1; transform: translateY(0); }
668
+ 95%, 100% { opacity: 0; transform: translateY(0); }
669
+ }
670
+ @keyframes fkText3 {
671
+ 0%, 50% { opacity: 0; transform: translateY(-15px); }
672
+ 55%, 90% { opacity: 1; transform: translateY(0); }
673
+ 95%, 100% { opacity: 0; transform: translateY(0); }
674
+ }
675
+ @keyframes fkText4 {
676
+ 0%, 75% { opacity: 0; transform: translateY(-15px); }
677
+ 80%, 90% { opacity: 1; transform: translateY(0); }
678
+ 95%, 100% { opacity: 0; transform: translateY(0); }
679
+ }
680
+
681
+ /* انیمیشن کادر پشت کلمات (حالت آمپولی) */
682
+ @keyframes fkBg1 {
683
+ 0%, 5% { opacity: 0; }
684
+ 10%, 25% { opacity: 1; }
685
+ 30%, 100% { opacity: 0; }
686
+ }
687
+ @keyframes fkBg2 {
688
+ 0%, 25% { opacity: 0; }
689
+ 30%, 50% { opacity: 1; }
690
+ 55%, 100% { opacity: 0; }
691
+ }
692
+ @keyframes fkBg3 {
693
+ 0%, 50% { opacity: 0; }
694
+ 55%, 75% { opacity: 1; }
695
+ 80%, 100% { opacity: 0; }
696
+ }
697
+ @keyframes fkBg4 {
698
+ 0%, 75% { opacity: 0; }
699
+ 80%, 90% { opacity: 1; }
700
+ 95%, 100% { opacity: 0; }
701
+ }
702
+ </style>
703
+ <div class="card-info">
704
+ <div class="card-title-style">کارائوکه ریزان</div>
705
+ </div>
706
+ </div>
707
+
708
+ <!-- ===== ۱۰ استایل آماده جدید ===== -->
709
+
710
+ <!-- حباب نرم درخشان -->
711
+ <div class="style-card" onclick="setStylePreset('soft_glow_pill', this)">
712
+ <div class="demo-row" style="display:flex; gap:6px; justify-content:center; align-items:center; direction:rtl;">
713
+ <span data-dw="0" style="font-weight:900; font-size:0.75rem; color:#fff;">سلام</span>
714
+ <span data-dw="1" style="font-weight:900; font-size:0.8rem; color:#fff; background:#7C4DFF; border-radius:999px; padding:2px 10px; box-shadow:0 0 10px #7C4DFF;">اینجا</span>
715
+ <span data-dw="2" style="font-weight:900; font-size:0.75rem; color:#fff;">آلفا</span>
716
+ </div>
717
+ <div class="card-info"><div class="card-title-style">حباب نرم درخشان</div></div>
718
+ </div>
719
+
720
+ <!-- حباب کمیک -->
721
+ <div class="style-card" onclick="setStylePreset('comic_bubble', this)">
722
+ <div class="demo-row" style="display:flex; gap:6px; justify-content:center; align-items:center; direction:rtl;">
723
+ <span data-dw="0" style="font-weight:900; font-size:0.75rem; color:#111; background:#fff; border:2.5px solid #111; border-radius:8px; padding:1px 8px;">سلام</span>
724
+ <span data-dw="1" style="font-weight:900; font-size:0.8rem; color:#fff; background:#FF3B30; border:2.5px solid #111; border-radius:8px; padding:1px 8px;">اینجا</span>
725
+ <span data-dw="2" style="font-weight:900; font-size:0.75rem; color:#111; background:#fff; border:2.5px solid #111; border-radius:8px; padding:1px 8px;">آلفا</span>
726
+ </div>
727
+ <div class="card-info"><div class="card-title-style">حباب کمیک</div></div>
728
+ </div>
729
+
730
+ <!-- هایلایت ماژیک -->
731
+ <div class="style-card" onclick="setStylePreset('handwritten_marker', this)">
732
+ <div class="demo-full" style="font-size:0.9rem; color:#fff; font-weight:900; white-space:nowrap; background:linear-gradient(180deg, transparent 55%, #7C4DFF 55%, #7C4DFF 90%, transparent 90%); padding:0 4px;">سلام اینجا آلفا است</div>
733
+ <div class="card-info"><div class="card-title-style">هایلایت ماژیک</div></div>
734
+ </div>
735
+
736
+ <!-- ضربه‌ای بولد -->
737
+ <div class="style-card" onclick="setStylePreset('bold_caps_impact', this)">
738
+ <div class="demo-full" style="font-size:1rem; color:#fff; font-weight:900; -webkit-text-stroke:1.5px #000; white-space:nowrap;">سلام اینجا آلفا است</div>
739
+ <div class="card-info"><div class="card-title-style">ضربه‌ای بولد</div></div>
740
+ </div>
741
+
742
+ <!-- گزارش ورزشی -->
743
+ <div class="style-card" onclick="setStylePreset('sport_lower_third', this)">
744
+ <div class="demo-row" style="display:flex; gap:2px; justify-content:center; align-items:center; direction:rtl;">
745
+ <span data-dw="0" style="font-weight:900; font-size:0.75rem; color:#fff; background:#FF3B30; padding:2px 8px;">سلام</span>
746
+ <span data-dw="1" style="font-weight:900; font-size:0.75rem; color:#fff; background:#007AFF; padding:2px 8px;">اینجا</span>
747
+ <span data-dw="2" style="font-weight:900; font-size:0.75rem; color:#fff; background:#34C759; padding:2px 8px;">آلفا</span>
748
+ </div>
749
+ <div class="card-info"><div class="card-title-style">گزارش ورزشی</div></div>
750
+ </div>
751
+
752
+ <!-- گلیچ رترو -->
753
+ <div class="style-card" onclick="setStylePreset('retro_vhs_glitch', this)">
754
+ <div class="demo-full" style="font-size:0.9rem; color:#fff; font-weight:900; white-space:nowrap; text-shadow: -2px 0 #FF003C, 2px 0 #00DCFF;">سلام اینجا آلفا است</div>
755
+ <div class="card-info"><div class="card-title-style">گلیچ رترو</div></div>
756
+ </div>
757
+
758
+ <!-- ظریف و مینیمال -->
759
+ <div class="style-card" onclick="setStylePreset('elegant_thin_serif', this)">
760
+ <div class="demo-full" style="font-size:0.9rem; color:#fff; font-weight:300; white-space:nowrap; letter-spacing:0.5px;">سلام اینجا آلفا است</div>
761
+ <div class="card-info"><div class="card-title-style">ظریف و مینیمال</div></div>
762
+ </div>
763
+
764
+ <!-- سینمایی letterbox -->
765
+ <div class="style-card" onclick="setStylePreset('cinematic_letterbox', this)">
766
+ <div class="demo-full" style="font-size:0.9rem; color:#fff; font-weight:900; white-space:nowrap; background:#000; padding:6px 14px;">سلام اینجا آلفا است</div>
767
+ <div class="card-info"><div class="card-title-style">سینمایی letterbox</div></div>
768
+ </div>
769
+
770
+ <!-- ماشین‌تحریر سبز -->
771
+ <div class="style-card" onclick="setStylePreset('typewriter_mono', this)">
772
+ <div class="demo-full" style="font-size:0.9rem; color:#20FF5A; font-weight:900; white-space:nowrap; font-family:monospace; background:#0a0a0a; padding:6px 14px; border:1px solid #20FF5A;">سلام اینجا آلفا است</div>
773
+ <div class="card-info"><div class="card-title-style">ماشین‌تحریر سبز</div></div>
774
+ </div>
775
+
776
+ <!-- گرادینت رنگی -->
777
+ <div class="style-card" onclick="setStylePreset('gradient_text', this)">
778
+ <div class="demo-full" style="font-size:0.95rem; font-weight:900; white-space:nowrap; background:linear-gradient(90deg, #00E5FF, #7C4DFF, #FF2D95); -webkit-background-clip:text; background-clip:text; color:transparent;">سلام اینجا آلفا است</div>
779
+ <div class="card-info"><div class="card-title-style">گرادینت رنگی</div></div>
780
+ </div>
781
+
782
+ </div> <!-- این تگ بسته شدنِ style-grid هست -->
783
+
784
+ </div>
785
+
786
+ <!-- ابزار ظاهر -->
787
+ <div id="section-appearance" class="tool-section">
788
+ <div class="control-group-panel">
789
+ <div class="control-group">
790
+ <div class="header-neon">
791
+ <div class="title-neon"><i class="fa-solid fa-text-height"></i><span>اندازه متن</span></div>
792
+ <div class="percent-badge" id="lbl-size">50%</div>
793
+ </div>
794
+ <div class="range-container">
795
+ <input type="range" id="fz" min="0" max="100" value="50" oninput="updateRange(this, 'lbl-size'); upd()">
796
+ </div>
797
+ </div>
798
+ <div class="control-group">
799
+ <div class="header-neon">
800
+ <div class="title-neon"><i class="fa-solid fa-arrows-left-right"></i><span>موقعیت افقی متن</span></div>
801
+ <div class="percent-badge" id="lbl-x">0%</div>
802
+ </div>
803
+ <div class="range-container">
804
+ <div class="center-marker"></div>
805
+ <input type="range" id="posX" min="-100" max="100" value="0" oninput="updateRange(this, 'lbl-x'); upd()">
806
+ </div>
807
+ </div>
808
+ <div class="control-group">
809
+ <div class="header-neon">
810
+ <div class="title-neon"><i class="fa-solid fa-arrows-up-down"></i><span>موقعیت عمودی متن</span></div>
811
+ <div class="percent-badge" id="lbl-y">20%</div>
812
+ </div>
813
+ <div class="range-container">
814
+ <input type="range" id="pos" min="0" max="100" value="20" oninput="updateRange(this, 'lbl-y'); upd()">
815
+ </div>
816
+ </div>
817
+ </div>
818
+
819
+ <div class="control-group-panel" id="boxControlsPanel">
820
+ <div class="box-preview-area">
821
+ <div id="boxPreview" class="box-preview-element">
822
+ <span>متن نمونه</span>
823
+ </div>
824
+ </div>
825
+ <div class="control-group">
826
+ <div class="header-neon">
827
+ <div class="title-neon"><i class="fa-solid fa-vector-square"></i><span>گردی کادر</span></div>
828
+ <div class="percent-badge" id="lbl-radius">16px</div>
829
+ </div>
830
+ <div class="range-container">
831
+ <input type="range" id="radius" min="1" max="50" value="16" oninput="updateRange(this, 'lbl-radius', 'px'); upd()">
832
+ </div>
833
+ </div>
834
+ <div class="control-group">
835
+ <div class="header-neon">
836
+ <div class="title-neon"><i class="fa-solid fa-arrows-left-right-to-line"></i><span>فاصله افقی</span></div>
837
+ <div class="percent-badge" id="lbl-paddingX">20px</div>
838
+ </div>
839
+ <div class="range-container">
840
+ <input type="range" id="paddingX" min="5" max="100" value="20" oninput="updateRange(this, 'lbl-paddingX', 'px'); upd()">
841
+ </div>
842
+ </div>
843
+ <div class="control-group">
844
+ <div class="header-neon">
845
+ <div class="title-neon"><i class="fa-solid fa-ruler-vertical"></i><span>فاصله عمودی</span></div>
846
+ <div class="percent-badge" id="lbl-paddingY">10px</div>
847
+ </div>
848
+ <div class="range-container">
849
+ <input type="range" id="paddingY" min="5" max="50" value="10" oninput="updateRange(this, 'lbl-paddingY', 'px'); upd()">
850
+ </div>
851
+ </div>
852
+ </div>
853
+ </div>
854
+
855
+ <!-- ابزار فونت و تنظیمات -->
856
+ <div id="section-font" class="tool-section">
857
+ <div class="sub-nav-container">
858
+ <div class="sub-nav-btn" onclick="switchSubTab('opacity', this)">انیمیشن</div>
859
+ <div class="sub-nav-btn active" onclick="switchSubTab('settings', this)">رنگ</div>
860
+ <div class="sub-nav-btn" onclick="switchSubTab('fonts', this)">فونت</div>
861
+ </div>
862
+
863
+ <!-- تب تنظیمات -->
864
+ <div id="sub-settings" class="sub-section-content active">
865
+ <div id="settingsContainerInline"></div>
866
+ <div style="text-align:center; color:#666; font-size:0.8rem; margin-top:20px;">
867
+ تنظیمات اختصاصی استایل انتخاب شده
868
+ </div>
869
+ <!-- پایان تب تنظیمات -->
870
+ </div>
871
+
872
+ <!-- تب شفافیت (بخش جدید) -->
873
+ <div id="sub-opacity" class="sub-section-content">
874
+ <div class="settings-row-toggle active">
875
+ <div class="setting-header" onclick="toggleUnreadOpacity(this)">
876
+ <div class="settings-left-part">
877
+ <div class="settings-icon-box">
878
+ <svg viewBox="0 0 24 24" fill="var(--primary)" style="width:20px; height:20px;"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
879
+ </div>
880
+ <span class="settings-label">کم رنگ شدن کلمات جلو</span>
881
+ </div>
882
+ <div class="premium-toggle"></div>
883
+ </div>
884
+ </div>
885
+ <div style="text-align:center; color:#666; font-size:0.8rem; margin-top:15px; padding:0 10px;">
886
+ با فعال کردن این گزینه کلمات جلو کمرنگ خواهد شد.
887
+ </div>
888
+
889
+ <!-- دکمه جدید برای کمرنگ شدن جلو و عقب -->
890
+ <div id="surroundingToggleCard" class="settings-row-toggle" style="margin-top: 15px;">
891
+ <div class="setting-header" onclick="toggleSurroundingOpacity(this)">
892
+ <div class="settings-left-part">
893
+ <div class="settings-icon-box">
894
+ <svg viewBox="0 0 24 24" fill="var(--primary)" style="width:20px; height:20px;"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-2-5.5l5-3.5-5-3.5v7z"/></svg>
895
+ </div>
896
+ <span class="settings-label">کم رنگ شدن کلمات جلو و عقب</span>
897
+ </div>
898
+ <div class="premium-toggle"></div>
899
+ </div>
900
+ </div>
901
+ <div style="text-align:center; color:#666; font-size:0.8rem; margin-top:15px; padding:0 10px;">
902
+ با فعال کردن این گزینه، کلمات قبل و بعد از کلمه فعال کمرنگ می‌شوند.
903
+ </div>
904
+
905
+ <div id="typewriterToggleCard" class="settings-row-toggle" style="margin-top: 15px;">
906
+ <div class="setting-header" onclick="toggleTypewriter(this)">
907
+ <div class="settings-left-part">
908
+ <div class="settings-icon-box">
909
+ <svg viewBox="0 0 24 24" fill="var(--primary)" style="width:20px; height:20px;"><path d="M4 6h16v2H4zm0 5h16v2H4zm0 5h16v2H4z"/></svg>
910
+ </div>
911
+ <span class="settings-label">انیمیشن تایپ شدن حروف</span>
912
+ </div>
913
+ <div class="premium-toggle"></div>
914
+ </div>
915
+ </div>
916
+ <div style="text-align:center; color:#666; font-size:0.8rem; margin-top:15px; padding:0 10px;">
917
+ با فعال کردن این گزینه، کلمات آینده مخفی شده و حروف به نرمی تایپ می‌شوند.
918
+ </div>
919
+ </div>
920
+
921
+ <!-- تب فونت -->
922
+ <div id="sub-fonts" class="sub-section-content">
923
+ <div class="font-grid">
924
+ <button class="font-btn" style="font-family:'Lalezar'; font-size:1rem;" onclick="setFont('lalezar', this)">لاله زار</button>
925
+ <button class="font-btn ticked" style="font-family:'Pinar'; font-size:1rem;" onclick="setFont('pinar', this)">پینار</button>
926
+ <button class="font-btn" style="font-family:'Amiri'; font-weight:bold; font-size:1rem;" onclick="setFont('amiri', this)">امیری</button>
927
+ <button class="font-btn" style="font-family:'Sarbaz'; font-size:1rem;" onclick="setFont('sarbaz', this)">سرباز</button>
928
+ <button class="font-btn" style="font-family:'IranNastaliq'; font-size:1rem;" onclick="setFont('nastaliq', this)">نستعلیق</button>
929
+ <button class="font-btn" style="font-family:'VazirThin'; font-weight:100; font-size:1rem;" onclick="setFont('vazir-thin', this)">وزیر نازک</button>
930
+ <button class="font-btn" style="font-family:'MadaThin'; font-weight:200; font-size:1rem;" onclick="setFont('mada-thin', this)">مادا نازک</button>
931
+ <button class="font-btn" style="font-family:'ArefBold';" onclick="setFont('aref-bold', this)">دست نویس بولد</button>
932
+ <button class="font-btn" style="font-family:'Dastnevis'; font-size:1rem;" onclick="setFont('dastnevis', this)">دست‌نویس</button>
933
+ <button class="font-btn" style="font-family:'Entazar'; font-size:1rem;" onclick="setFont('entazar', this)">انتظار</button>
934
+ <button class="font-btn" style="font-family:'Kamran'; font-size:1rem;" onclick="setFont('kamran', this)">تو خالی</button>
935
+ <button class="font-btn" style="font-family:'Gharib'; font-size:1rem;" onclick="setFont('gharib', this)">غریب</button>
936
+ <button class="font-btn" style="font-family:'Vazirmatn'; font-weight:900; font-size:1rem;" onclick="setFont('vazir', this)">وزیر ضخیم</button>
937
+ <button class="font-btn" style="font-family:'Hasti'; font-size:1rem;" onclick="setFont('hasti', this)">هستی</button>
938
+ </div>
939
+ </div>
940
+ </div>
941
+
942
+ <!-- ابزار متن -->
943
+ <div id="section-text" class="tool-section" style="padding: 0;">
944
+ <div class="text-editor-ui">
945
+ <div class="toolbar-area" id="toolbar">
946
+ <div class="tool-circle" onclick="openSheet('delete')"><i class="fa-solid fa-trash-can"></i></div>
947
+ <div class="tool-circle" onclick="openColorPicker('word', event)"><i class="fa-solid fa-palette"></i></div>
948
+ <div class="tool-circle" onclick="openSheet('text')"><i class="fa-solid fa-pen"></i></div>
949
+ <div class="tool-circle" onclick="openSheet('time')"><i class="fa-regular fa-clock"></i></div>
950
+ <div class="tool-circle" id="btnSplit" onclick="toggleSplit()"><i class="fa-solid fa-level-down-alt" style="transform: rotate(90deg);"></i></div>
951
+ <!-- دکمه تنظیمات/تولید مجدد -->
952
+ <div class="tool-circle" onclick="openSheet('regenerate')"><i class="fa-solid fa-gear"></i></div>
953
+ </div>
954
+ <div class="fixed-info-bar">
955
+ <span class="global-time-text" id="globalTimeDisplay">00:00 / 00:00</span>
956
+ </div>
957
+ <div class="timeline-scroll-area" id="timelineScroll">
958
+ <div class="spacer"></div>
959
+ <div class="spacer"></div>
960
+ </div>
961
+ </div>
962
+ </div>
963
+ </div>
964
+ </div>
965
+
966
+ <!-- Backdrop انتخاب رنگ -->
967
+ <div id="pickerBackdrop" class="picker-backdrop" onclick="closePicker()"></div>
968
+
969
+ <!-- Modal اصلی انتخاب رنگ -->
970
+ <div id="colorPickerModal" class="picker-modal">
971
+ <div class="picker-header">
972
+ <div class="picker-icon" onclick="saveAndClosePicker()"><i class="fa-solid fa-check"></i></div>
973
+ <div style="height: 5px; width: 46px; background: rgba(255,255,255,.18); border-radius: 999px;"></div>
974
+ <div class="picker-icon" onclick="closePicker()"><i class="fa-solid fa-xmark"></i></div>
975
+ </div>
976
+
977
+ <div class="picker-tabs">
978
+ <div class="picker-tab" id="tab-manual" onclick="switchTab('manual')">دستی</div>
979
+ <div class="picker-tab active" id="tab-spectrum" onclick="switchTab('spectrum')">طیف</div>
980
+ <div class="picker-tab" id="tab-grid" onclick="switchTab('grid')">گرید</div>
981
+ </div>
982
+
983
+ <!-- بخش طیف -->
984
+ <div id="view-spectrum" class="view-section active-view">
985
+ <div class="spectrum-container" id="spectrumArea">
986
+ <div class="spectrum-overlay"></div>
987
+ <div class="spectrum-handle" id="spectrumHandle"></div>
988
+ </div>
989
+ </div>
990
+
991
+ <!-- بخش گرید -->
992
+ <div id="view-grid" class="view-section">
993
+ <div class="grid-view-container" id="gridContainer"></div>
994
+ </div>
995
+
996
+ <!-- بخش دستی -->
997
+ <div id="view-manual" class="view-section">
998
+ <div class="manual-view-container">
999
+ <div class="manual-control-row">
1000
+ <input type="number" id="input-r" class="manual-num-input" value="200" oninput="updateRGBFromInputs()">
1001
+ <div class="manual-slider-group">
1002
+ <div class="manual-label-text">قرمز</div>
1003
+ <input type="range" id="slider-r" class="rgb-slider" min="0" max="255" value="200" oninput="updateRGBFromSliders()">
1004
+ </div>
1005
+ </div>
1006
+ <div class="manual-control-row">
1007
+ <input type="number" id="input-g" class="manual-num-input" value="200" oninput="updateRGBFromInputs()">
1008
+ <div class="manual-slider-group">
1009
+ <div class="manual-label-text">سبز</div>
1010
+ <input type="range" id="slider-g" class="rgb-slider" min="0" max="255" value="200" oninput="updateRGBFromSliders()">
1011
+ </div>
1012
+ </div>
1013
+ <div class="manual-control-row">
1014
+ <input type="number" id="input-b" class="manual-num-input" value="200" oninput="updateRGBFromInputs()">
1015
+ <div class="manual-slider-group">
1016
+ <div class="manual-label-text">آبی</div>
1017
+ <input type="range" id="slider-b" class="rgb-slider" min="0" max="255" value="200" oninput="updateRGBFromSliders()">
1018
+ </div>
1019
+ </div>
1020
+ <div class="hex-row">
1021
+ <input type="text" id="input-hex" class="hex-input" value="C8C8C8" oninput="updateFromHexInput()">
1022
+ <span class="hex-label"># Hex Color</span>
1023
+ </div>
1024
+ </div>
1025
+ </div>
1026
+
1027
+ <!-- اسلایدرهای سراسری -->
1028
+ <div class="sliders-global-wrapper">
1029
+ <div class="global-slider-row" id="brightness-row">
1030
+ <div class="value-pill" title="روشنایی">
1031
+ <i class="fa-solid fa-sun"></i>
1032
+ <span><span id="disp-brightness">100</span>%</span>
1033
+ </div>
1034
+ <div class="global-slider-container">
1035
+ <div class="global-label-top">روشنایی</div>
1036
+ <input type="range" id="brightness-slider" class="common-slider" min="0" max="100" value="100" oninput="updateBrightness()">
1037
+ </div>
1038
+ </div>
1039
+ <div class="global-slider-row">
1040
+ <div class="value-pill" title="شفافیت">
1041
+ <i class="fa-solid fa-droplet"></i>
1042
+ <span><span id="disp-alpha">56</span>%</span>
1043
+ </div>
1044
+ <div class="global-slider-container" style="background: transparent; border:none; padding:0;">
1045
+ <div class="global-label-top" style="right: 2px;">شفافیت</div>
1046
+ <div class="alpha-checkered">
1047
+ <input type="range" id="alpha-slider" class="common-slider" min="0" max="100" value="56" oninput="updateAlpha()">
1048
+ </div>
1049
+ </div>
1050
+ </div>
1051
+ </div>
1052
+
1053
+ <!-- Footer Modal -->
1054
+ <div class="picker-footer">
1055
+ <div class="large-preview" id="largeColorPreview"></div>
1056
+ <div class="saved-colors-container" id="savedColorsContainer">
1057
+ <div class="btn-save-text" onclick="saveCurrentColor()">
1058
+ <i class="fa-solid fa-save"></i>
1059
+ <span>ذخیره رنگ</span>
1060
+ </div>
1061
+ </div>
1062
+ </div>
1063
+ </div>
1064
+
1065
+ <!-- Modal Delete Color -->
1066
+ <div id="deleteModal" class="delete-overlay">
1067
+ <div class="delete-box">
1068
+ <div id="delColorPreview" class="del-preview"></div>
1069
+ <p class="del-title">آیا این رنگ حذف شود؟</p>
1070
+ <div class="del-actions">
1071
+ <button class="del-btn del-btn-confirm" type="button" onclick="confirmDelete()">حذف</button>
1072
+ <button class="del-btn del-btn-cancel" type="button" onclick="closeDeleteModal()">انصراف</button>
1073
+ </div>
1074
+ </div>
1075
+ </div>
1076
+
1077
+ <!-- شیت‌های ویرایش پایین صفحه -->
1078
+ <div class="bottom-sheet-overlay" id="sheetOverlay" onclick="closeAllSheets()">
1079
+ <!-- ویرایش متن -->
1080
+ <div class="bottom-sheet" id="sheet-text" onclick="event.stopPropagation()">
1081
+ <div class="sheet-handle"></div>
1082
+ <div class="sheet-title">ویرایش متن</div>
1083
+ <input type="text" id="textInput" class="sheet-input">
1084
+ <div class="sheet-meta-row">
1085
+ <span class="time-display-text" id="textTimeDisplay">00.00 -> 00.00</span>
1086
+ <button class="mini-play-btn" id="btnPreviewPlay" onclick="playPreviewWord()"><i class="fa-solid fa-play"></i></button>
1087
+ </div>
1088
+ <div class="sheet-actions">
1089
+ <button class="action-btn btn-apply-sheet" onclick="saveText()">اعمال</button>
1090
+ <button class="action-btn btn-cancel-sheet" onclick="closeAllSheets()">لغو</button>
1091
+ </div>
1092
+ </div>
1093
+
1094
+ <!-- ویرایش زمان (بازطراحی شده) -->
1095
+ <div class="bottom-sheet" id="sheet-time" onclick="event.stopPropagation()">
1096
+ <div class="sheet-handle"></div>
1097
+
1098
+ <!-- هدر: دکمه پخش و زمان -->
1099
+ <div class="trimmer-header">
1100
+ <button id="btnTimeSheetPlay" class="trimmer-play-btn" onclick="playPreviewWord()">
1101
+ <i id="btnTimeSheetPlayIcon" class="fa-solid fa-play"></i>
1102
+ </button>
1103
+ <div class="trimmer-time-display">
1104
+ <span id="trimStartDisp">00:03.62</span> - <span id="trimEndDisp">00:03.88</span>
1105
+ </div>
1106
+ </div>
1107
+
1108
+ <!-- بدنه: نوار برش و کلمات قبل/بعد -->
1109
+ <div class="trimmer-body">
1110
+ <!-- کلمه قبل -->
1111
+ <div class="word-preview-side left" id="prevWordPreview">دردی</div>
1112
+
1113
+ <!-- نوار زمانی -->
1114
+ <div class="trimmer-container" id="trimmerContainer">
1115
+ <div class="timeline-strip" id="timelineStrip">
1116
+ <!-- اینجا هندل‌ها قرار می‌گیرند -->
1117
+ <div class="trim-handle left" id="handleLeft">
1118
+ <i class="fa-solid fa-chevron-left"></i>
1119
+ </div>
1120
+ <div class="trim-track-active" id="trackActive"></div>
1121
+ <div class="trim-handle right" id="handleRight">
1122
+ <i class="fa-solid fa-chevron-right"></i>
1123
+ </div>
1124
+ </div>
1125
+ </div>
1126
+
1127
+ <!-- کلمه بعد -->
1128
+ <div class="word-preview-side right" id="nextWordPreview">رفتی</div>
1129
+ </div>
1130
+
1131
+ <!-- کلمه جاری (وسط) -->
1132
+ <div class="trimmer-current-word" id="timeSheetWord">چه</div>
1133
+
1134
+ <!-- دکمه‌های اکشن -->
1135
+ <div class="sheet-actions">
1136
+ <button class="action-btn" style="background: #7C4DFF; color: #fff; font-size: 1.1rem;" onclick="confirmTimeChanges()">اعمال</button>
1137
+ <button class="action-btn btn-cancel-sheet" onclick="cancelTimeChanges()" style="font-size: 1rem; background: transparent; color: #fff;">لغو</button>
1138
+ </div>
1139
+ </div>
1140
+
1141
+ <!-- حذف -->
1142
+ <div class="bottom-sheet" id="sheet-delete" onclick="event.stopPropagation()">
1143
+ <div class="sheet-handle"></div>
1144
+ <div class="delete-icon-wrap"><i class="fa-solid fa-trash-can"></i></div>
1145
+ <div class="delete-msg" id="deleteMsgText"></div>
1146
+ <div class="sheet-actions">
1147
+ <button class="action-btn btn-delete-sheet" onclick="confirmDeleteWord()">بله، حذف کن</button>
1148
+ <button class="action-btn btn-cancel-sheet" onclick="closeAllSheets()">انصراف</button>
1149
+ </div>
1150
+ </div>
1151
+
1152
+ <!-- تنظیمات / بازتولید -->
1153
+ <div class="bottom-sheet" id="sheet-regenerate" onclick="event.stopPropagation()">
1154
+ <div class="sheet-handle"></div>
1155
+
1156
+ <!-- حالت اول: دکمه انجام -->
1157
+ <div id="regen-step-start" style="width:100%; display:flex; flex-direction:column; gap:15px;">
1158
+ <div class="sheet-title" style="font-size:1rem; font-weight:bold; color:#fff;">تنظیمات زیرنویس</div>
1159
+
1160
+ <button class="action-btn" style="background: #333; color: white;" onclick="openSheet('wordcount')">
1161
+ <i class="fa-solid fa-list-ol"></i> تعداد کلمات در هر بخش
1162
+ </button>
1163
+
1164
+ <button class="action-btn" style="background: linear-gradient(135deg, #FF4081, #7C4DFF); color: white;" onclick="preConfirmRegenerate()">
1165
+ <i class="fa-solid fa-wand-magic-sparkles"></i> تولید مجدد زیرنویس
1166
+ </button>
1167
+
1168
+ <p style="color:#666; font-size:0.8rem; text-align:center; margin:0;">
1169
+ در صورت عدم رضایت، هوش مصنوعی دوباره تلاش می‌کند.
1170
+ </p>
1171
+ <button class="action-btn btn-cancel-sheet" onclick="closeAllSheets()">بستن</button>
1172
+ </div>
1173
+
1174
+ <!-- حالت دوم: لودینگ -->
1175
+ <div id="regen-step-loading" style="display:none; width:100%; flex-direction:column; align-items:center; justify-content:center; gap:20px; padding: 20px 0;">
1176
+ <div class="sheet-spinner-ring"></div>
1177
+ <div style="text-align:center;">
1178
+ <div style="color:#fff; font-weight:bold; margin-bottom:5px;">هوش مصنوعی در حال بازنویسی...</div>
1179
+ <div style="color:#888; font-size:0.85rem;">لطفاً صبر کنید...</div>
1180
+ </div>
1181
+ </div>
1182
+
1183
+ <!-- حالت سوم: موفقیت -->
1184
+ <div id="regen-step-success" style="display:none; width:100%; flex-direction:column; align-items:center; justify-content:center; gap:15px; padding: 20px 0;">
1185
+ <div class="success-checkmark-anim">
1186
+ <i class="fa-solid fa-check"></i>
1187
+ </div>
1188
+ <div style="color:#00e676; font-weight:bold; font-size:1.1rem;">زیرنویس مجدد تولید شد!</div>
1189
+ </div>
1190
+ </div>
1191
+
1192
+ <!-- انتخاب تعداد کلمات -->
1193
+ <div class="bottom-sheet" id="sheet-wordcount" onclick="event.stopPropagation()">
1194
+ <div class="sheet-handle"></div>
1195
+ <div class="sheet-title">تعداد کلمات در هر بخش</div>
1196
+ <div class="word-count-grid">
1197
+ <button class="wc-btn" onclick="resegmentByWordCount(1)">1</button>
1198
+ <button class="wc-btn" onclick="resegmentByWordCount(2)">2</button>
1199
+ <button class="wc-btn" onclick="resegmentByWordCount(3)">3</button>
1200
+ <button class="wc-btn" onclick="resegmentByWordCount(4)">4</button>
1201
+ <button class="wc-btn" onclick="resegmentByWordCount(5)">5</button>
1202
+ <button class="wc-btn" onclick="resegmentByWordCount(6)">6</button>
1203
+ <button class="wc-btn" onclick="resegmentByWordCount(7)">7</button>
1204
+ <button class="wc-btn" onclick="resegmentByWordCount(8)">8</button>
1205
+ <button class="wc-btn" onclick="resegmentByWordCount(9)">9</button>
1206
+ <button class="wc-btn" onclick="resegmentByWordCount(10)">10</button>
1207
+ </div>
1208
+ <button class="action-btn btn-cancel-sheet" onclick="openSheet('regenerate')">بازگشت</button>
1209
+ </div>
1210
+ </div>
1211
+
1212
+ <!-- پیغام هوشمند هوش مصنوعی -->
1213
+ <div id="aiCoachMarkOverlay" class="coach-overlay" onclick="closeAITooltip()">
1214
+ <div id="coachTooltip" class="coach-tooltip" onclick="event.stopPropagation()">
1215
+ <div class="coach-icon">
1216
+ <i class="fa-solid fa-robot"></i>
1217
+ </div>
1218
+ <div class="coach-title">هوش مصنوعی کارشو انجام داد!</div>
1219
+ <div class="coach-text">
1220
+ متن این ویدیو توسط هوش مصنوعی تولید شده و ممکنه بعضی کلمات به خاطر لحن گفتار نیاز به اصلاح داشته باشن.<br><br>
1221
+ اگه اشتباه املایی دیدی، کافیه از بخش <b style="color: #00D7FF;">«متن»</b> روی کلمه مورد نظر کلیک کنی و خیلی راحت اصلاحش کنی!
1222
+ </div>
1223
+ <button class="coach-btn" onclick="closeAITooltip()">متوجه شدم</button>
1224
+ </div>
1225
+ <!-- فلش راهنما به سمت پایین -->
1226
+ <div id="coachArrow" class="coach-arrow">
1227
+ <i class="fa-solid fa-arrow-down"></i>
1228
+ </div>
1229
+ </div>
1230
+
1231
+ <!-- لودینگ نئونی جدید -->
1232
+ <div id="loader">
1233
+ <div class="loader-wrapper">
1234
+ <div class="modern-loader">
1235
+ <div class="loader-ring"></div>
1236
+ <div class="loader-ring"></div>
1237
+ <div class="loader-ring"></div>
1238
+ <div class="loader-icon">
1239
+ <i class="fa-solid fa-wand-magic-sparkles"></i>
1240
+ </div>
1241
+ </div>
1242
+
1243
+ <div class="text-container">
1244
+ <div class="loading-title" id="loaderTitle">در حال آماده سازی...</div>
1245
+ <div class="loading-sub" id="queueStatusMsg">لطفاً صبور باشید ☘️</div>
1246
+
1247
+ <!-- نوار پیشرفت واقعی -->
1248
+ <div id="uploadProgressContainer" style="display: none; width: 100%; height: 8px; background: rgba(255,255,255,0.1); border-radius: 10px; margin-top: 15px; overflow: hidden; position: relative;">
1249
+ <div id="uploadProgressBar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #b000ff, #0077ff); transition: width 0.2s ease;"></div>
1250
+ <!-- افکت درخشش روی نوار -->
1251
+ <div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); animation: progressGlow 1.5s infinite;"></div>
1252
+ </div>
1253
+ </div>
1254
+ <style>
1255
+ @keyframes progressGlow {
1256
+ 0% { transform: translateX(-100%); }
1257
+ 100% { transform: translateX(100%); }
1258
+ }
1259
+ </style>
1260
+ </div>
1261
+ </div>
1262
+
1263
+ <!-- صفحه نتیجه -->
1264
+ <div id="resultScreen">
1265
+ <h3 style="color:#fff; margin-bottom: 15px;">ویدیو آماده شد!</h3>
1266
+ <div style="width: 90%; max-width: 400px; height: 50vh; background: #111; border-radius: 15px; overflow: hidden; margin-bottom: 25px; display: flex; align-items: center; justify-content: center; border: 1px solid #333;">
1267
+ <video id="resultVideo" controls style="max-width:100%; max-height:100%; width:100%; height:100%;"></video>
1268
+ </div>
1269
+ <div style="display: flex; gap: 15px;">
1270
+ <a id="downloadBtn" href="#" download class="btn-exp" style="background: #00C853; text-decoration: none; display: flex; align-items: center; gap: 8px;"><i class="fa-solid fa-download"></i> دانلود</a>
1271
+ <button class="btn-exp" style="background: #444;" onclick="closeResult()">بستن</button>
1272
+ </div>
1273
+ </div>
1274
+
1275
+ <!-- اسکریپت کنترل پیغام هوش مصنوعی (اصلاح شده برای قرارگیری بالای دکمه) -->
1276
+ <script>
1277
+ function showAITooltip() {
1278
+ const overlay = document.getElementById('aiCoachMarkOverlay');
1279
+ const targetBtn = document.getElementById('btn-text');
1280
+ const tooltip = document.getElementById('coachTooltip');
1281
+ const arrow = document.getElementById('coachArrow');
1282
+ const controlsBar = document.querySelector('.controls-bar');
1283
+
1284
+ if (!targetBtn || !overlay || !tooltip || !arrow) return;
1285
+
1286
+ // بالا آوردن z-index نوار کنترل تا دکمه متن بالای لایه مشکی قرار بگیرد
1287
+ if (controlsBar) controlsBar.style.zIndex = '10000';
1288
+ targetBtn.classList.add('relative-z-coach');
1289
+
1290
+ overlay.style.display = 'block';
1291
+
1292
+ // محاسبه جایگاه دقیق دکمه نسبت به صفحه
1293
+ const rect = targetBtn.getBoundingClientRect();
1294
+
1295
+ // محاسبه فاصله از پایین صفحه تا بالای دکمه متن
1296
+ const distFromBottom = window.innerHeight - rect.top;
1297
+
1298
+ // قرار دادن پیام بالای دکمه (روی ویدیو)
1299
+ tooltip.style.top = 'auto';
1300
+ tooltip.style.bottom = (distFromBottom + 50) + 'px';
1301
+
1302
+ // قرار دادن فلش دقیقاً بالای دکمه
1303
+ arrow.style.top = 'auto';
1304
+ arrow.style.bottom = (distFromBottom + 10) + 'px';
1305
+ arrow.style.left = (rect.left + rect.width / 2) + 'px';
1306
+
1307
+ setTimeout(() => { overlay.classList.add('show'); }, 10);
1308
+ }
1309
+
1310
+ function closeAITooltip() {
1311
+ const overlay = document.getElementById('aiCoachMarkOverlay');
1312
+ const targetBtn = document.getElementById('btn-text');
1313
+ const controlsBar = document.querySelector('.controls-bar');
1314
+
1315
+ if (overlay) {
1316
+ overlay.classList.remove('show');
1317
+ setTimeout(() => {
1318
+ overlay.style.display = 'none';
1319
+ // برگرداندن z-index به حالت اولیه پس از بسته شدن
1320
+ if (controlsBar) controlsBar.style.zIndex = '90';
1321
+ if (targetBtn) targetBtn.classList.remove('relative-z-coach');
1322
+ }, 300);
1323
+ }
1324
+ }
1325
+ </script>
1326
+
1327
+ <!-- اسکریپت انیمیشن باکس -->
1328
+ <script>
1329
+ function startBoxAnim() {
1330
+ const container = document.getElementById('animPreviewBox');
1331
+ if(!container) return;
1332
+ const words = container.querySelectorAll('.box-word');
1333
+ let index = 0;
1334
+ setInterval(() => {
1335
+ words.forEach((w, i) => {
1336
+ w.className = 'box-word';
1337
+ if (i < index) w.classList.add('past');
1338
+ else if (i === index) w.classList.add('active');
1339
+ });
1340
+ index++; if (index > words.length) index = 0;
1341
+ }, 700);
1342
+ }
1343
+
1344
+ function startAlphaAnim() {
1345
+ const container = document.getElementById('animPreviewAlpha');
1346
+ if(!container) return;
1347
+ const words = container.querySelectorAll('.alpha-word');
1348
+ let index = 0;
1349
+ setInterval(() => {
1350
+ words.forEach((w, i) => {
1351
+ w.className = 'alpha-word';
1352
+ if (i < index) w.classList.add('past');
1353
+ else if (i === index) w.classList.add('active');
1354
+ });
1355
+ index++; if (index > words.length) index = 0;
1356
+ }, 700);
1357
+ }
1358
+
1359
+ // اجرای انیمیشن هنگام لود
1360
+ window.addEventListener('load', () => {
1361
+ startBoxAnim();
1362
+ startAlphaAnim();
1363
+ });
1364
+ </script>
1365
+
1366
+ <!-- بارگذاری اسکریپت‌ها -->
1367
+ <script src="/static/js/globals.js"></script>
1368
+ <script src="/static/js/utils.js"></script>
1369
+ <script src="/static/js/db.js"></script>
1370
+ <script src="/static/js/api.js"></script>
1371
+ <script src="/static/js/ui.js"></script>
1372
+ <script src="/static/js/editor.js"></script>
1373
+ <script src="/static/js/main.js"></script>
1374
+
1375
+ </body>
1376
+ </html>
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
4
+ google-generativeai
5
+ pillow
6
+ requests
7
+ numpy
8
+ pydub
9
+ gradio-client
10
+ groq
11
+ huggingface_hub
static/css/style.css ADDED
@@ -0,0 +1,1023 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @font-face { font-family: 'Sarbaz'; src: url('/static/fonts/Sarbaz.ttf'); }
2
+ @font-face { font-family: 'IranNastaliq'; src: url('/static/fonts/IranNastaliq.ttf'); }
3
+ @font-face { font-family: 'VazirThin'; src: url('/static/fonts/Vazirmatn-Thin.ttf'); }
4
+ @font-face { font-family: 'MadaThin'; src: url('/static/fonts/Mada-ExtraLight.ttf'); }
5
+ @font-face { font-family: 'ArefBold'; src: url('/static/fonts/ArefRuqaa-Bold.ttf'); }
6
+ @font-face { font-family: 'Dastnevis'; src: url('/static/fonts/Dastnevis.ttf'); }
7
+ @font-face { font-family: 'Entazar'; src: url('/static/fonts/Entazar.ttf'); }
8
+ @font-face { font-family: 'Kamran'; src: url('/static/fonts/Kamran.ttf'); }
9
+ @font-face { font-family: 'Gharib'; src: url('/static/fonts/Gharib.ttf'); }
10
+ @font-face { font-family: 'Pinar'; src: url('/static/fonts/Pinar-Bold.ttf'); }
11
+ @font-face { font-family: 'Hasti'; src: url('/static/fonts/Hasti.ttf'); }
12
+ :root { --bg: #0f0f0f; --surface: #1e1e1e; --primary: #7C4DFF; --text: #fff; --highlight: #2a2a2a; --panel-bg: #151515; --static-color: #A020F0; --accent-orange: #FF7043; --danger-red: #ff5252; --chip-bg: #2c2c2c; --input-bg: #2a2a2a; }
13
+
14
+ * { box-sizing: border-box; user-select: none; -webkit-tap-highlight-color: transparent; outline: none; }
15
+
16
+ body { margin: 0; background: var(--bg); color: var(--text); font-family: 'Vazirmatn'; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; min-height: 100vh; padding-bottom: 50px;}
17
+
18
+ /* --- صفحه خانه (پروژه‌ها) --- */
19
+ #homeScreen { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #121212; z-index: 5000; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; }
20
+ .home-header { font-size: 1.5rem; font-weight: 900; margin-bottom: 20px; text-align: right; color: #eee; }
21
+
22
+ .projects-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; padding-bottom: 80px; }
23
+
24
+ .project-card {
25
+ background: #252525; border-radius: 20px; overflow: hidden; position: relative;
26
+ box-shadow: 0 4px 10px rgba(0,0,0,0.3); transition: 0.2s; cursor: pointer; aspect-ratio: 1 / 1.1; display: flex; flex-direction: column;
27
+ }
28
+ .project-card:active { transform: scale(0.97); }
29
+
30
+ .card-thumb { width: 100%; height: 70%; background: #333; background-size: cover; background-position: center; position: relative; }
31
+ .card-overlay { position: absolute; bottom: 0; width: 100%; height: 50%; background: linear-gradient(to top, rgba(0,0,0,0.8), transparent); }
32
+ .card-duration { position: absolute; bottom: 10px; right: 10px; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); padding: 2px 8px; border-radius: 10px; font-size: 0.75rem; color: #fff; font-weight: bold; }
33
+ .card-menu { position: absolute; top: 10px; left: 10px; width: 35px; height: 35px; background: rgba(0,0,0,0.4); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 1rem; backdrop-filter: blur(4px); transition: 0.2s; z-index: 10; }
34
+ .card-menu:active { background: var(--primary); transform: scale(0.9); }
35
+
36
+ .card-details { padding: 10px; flex: 1; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; }
37
+ .card-title { font-weight: bold; font-size: 1.1rem; color: #fff; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
38
+ .card-date { font-size: 0.75rem; color: #888; }
39
+
40
+ #noProjectsPlaceholder { display: none; flex-direction: column; align-items: center; justify-content: center; text-align: center; color: #666; position: absolute; top: 45%; left: 50%; transform: translate(-50%, -50%); width: 100%; }
41
+ .placeholder-icon { width: 100px; height: 100px; border: 3px solid var(--primary); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 2.5rem; color: var(--primary); margin-bottom: 20px; animation: pulse 2.5s infinite ease-in-out; background: rgba(124, 77, 255, 0.05); }
42
+ .placeholder-text { font-size: 1.1rem; font-weight: bold; color: #ddd; }
43
+ @keyframes pulse { 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(124, 77, 255, 0.7); } 70% { transform: scale(1); box-shadow: 0 0 0 15px rgba(124, 77, 255, 0); } 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(124, 77, 255, 0); } }
44
+
45
+ .fab-add { position: fixed; bottom: 50px; left: 50%; transform: translateX(-50%); background: var(--accent-orange); color: #fff; padding: 15px 40px; border-radius: 30px; font-size: 1.1rem; font-weight: bold; box-shadow: 0 5px 20px rgba(255, 112, 67, 0.4); border: none; cursor: pointer; display: flex; align-items: center; gap: 10px; z-index: 5001; transition: 0.2s; font-family: inherit; width: 90%; max-width: 400px; justify-content: center; }
46
+ .fab-add:active { transform: translateX(-50%) scale(0.95); }
47
+
48
+ .action-sheet-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); z-index: 6000; display: none; align-items: flex-end; justify-content: center; animation: fadeInOverlay 0.2s forwards; padding-bottom: 20px; }
49
+ .action-sheet { background: #222; width: 90%; max-width: 500px; border-radius: 20px; padding: 25px; display: flex; flex-direction: column; gap: 15px; transform: translateY(100%); animation: slideUp 0.3s forwards; }
50
+ .sheet-title-main { color: #888; font-size: 0.9rem; text-align: center; margin-bottom: 10px; }
51
+ .sheet-btn { background: #333; color: #fff; border: none; padding: 15px; border-radius: 15px; font-size: 1rem; font-family: inherit; display: flex; align-items: center; gap: 15px; cursor: pointer; }
52
+ .sheet-btn:active { background: #444; }
53
+ .sheet-btn.delete-btn { color: #ff5252; }
54
+ .sheet-btn i { width: 25px; text-align: center; }
55
+
56
+ .input-field { width: 100%; background: #111; border: 1px solid #444; color: #fff; padding: 12px; border-radius: 10px; font-family: inherit; font-size: 1rem; margin-bottom: 20px; text-align: center; }
57
+ @keyframes slideUp { to { transform: translateY(0); } }
58
+ @keyframes fadeInOverlay { from { opacity: 0; } to { opacity: 1; } }
59
+
60
+ .modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); backdrop-filter: blur(5px); z-index: 7000; display: none; align-items: center; justify-content: center; opacity: 0; transition: opacity: 0.3s; }
61
+ .modal-overlay.show { opacity: 1; }
62
+ .modal-box { background: #222; border: 1px solid #444; width: 90%; max-width: 350px; border-radius: 20px; padding: 25px; text-align: center; transform: scale(0.8); transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); box-shadow: 0 10px 40px rgba(0,0,0,0.5); }
63
+ .modal-overlay.show .modal-box { transform: scale(1); }
64
+ .modal-icon { font-size: 3rem; color: var(--primary); margin-bottom: 15px; }
65
+ .modal-title { font-size: 1.2rem; font-weight: bold; margin-bottom: 10px; color: #fff; }
66
+ .modal-desc { font-size: 0.9rem; color: #aaa; margin-bottom: 25px; line-height: 1.5; }
67
+ .modal-actions { display: flex; gap: 10px; justify-content: center; }
68
+ .modal-btn { flex: 1; padding: 12px; border-radius: 12px; border: none; font-family: inherit; font-weight: bold; cursor: pointer; transition: 0.2s; }
69
+ .btn-confirm { background: var(--primary); color: #fff; }
70
+ .btn-cancel { background: #333; color: #fff; }
71
+ .btn-confirm-action.split { background: #00C853; }
72
+ .btn-confirm-action.delete { background: #d32f2f; }
73
+
74
+ #editorScreen { display: none; width: 100%; flex-direction: column; min-height: 100vh; }
75
+ .top-bar { position: sticky; top: 0; height: 60px; background: rgba(30,30,30,0.95); backdrop-filter: blur(10px); display: flex; align-items: center; justify-content: space-between; padding: 0 20px; z-index: 100; border-bottom: 1px solid #333; box-shadow: 0 4px 10px rgba(0,0,0,0.3); }
76
+ .btn-home { background: transparent; color: #ccc; border: none; font-size: 1.2rem; cursor: pointer; padding: 10px; }
77
+
78
+ #workspace {
79
+ width: 100%;
80
+ display: flex;
81
+ justify-content: center;
82
+ align-items: center;
83
+ background: #000;
84
+ padding: 20px 0;
85
+ min-height: 300px;
86
+ flex-shrink: 0;
87
+ overflow: hidden;
88
+ }
89
+ #scaler {
90
+ transition: transform 0.1s ease-out;
91
+ display: flex;
92
+ justify-content: center;
93
+ align-items: center;
94
+ }
95
+ #videoContainer { position: relative; background: #000; overflow: hidden; cursor: pointer; box-shadow: 0 0 30px rgba(124, 77, 255, 0.1); border-radius: 8px;}
96
+ video { width: 100%; height: 100%; object-fit: contain; display: block; }
97
+ #playOverlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; background: rgba(0,0,0,0.2); z-index: 10; transition: opacity 0.2s; }
98
+ #playOverlay.playing { opacity: 0; pointer-events: none; }
99
+ #playOverlay i { font-size: 4rem; color: rgba(255,255,255,0.8); filter: drop-shadow(0 0 5px rgba(0,0,0,0.8)); pointer-events: none; }
100
+ #subtitleLayer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; display: flex; justify-content: center; z-index: 20; }
101
+ #activeText { position: absolute; white-space: pre; pointer-events: auto; cursor: move; padding: 0 0 8px 0; line-height: 1; font-weight: bold; transition: background-color 0.2s, text-shadow 0.2s, -webkit-text-stroke 0.2s; transform-origin: center center; text-align: center; left: 50%; transform: translateX(-50%); }
102
+ .word-active { background-color: var(--static-color); color: #fff; border-radius: 8px; padding: 0 8px; margin: 0 2px; box-shadow: 0 2px 8px rgba(160, 32, 240, 0.5); -webkit-box-decoration-break: clone; box-decoration-break: clone; }
103
+ .word-highlight { color: #00D7FF !important; transform: scale(1.2); display:inline-block; text-shadow: 0 0 10px rgba(0, 215, 255, 0.5); }
104
+
105
+ .magic-bar { padding: 15px 20px 5px 20px; background: var(--bg); display: flex; justify-content: center; position: sticky; top: 60px; z-index: 95; }
106
+ .btn-ai-magic { background: linear-gradient(90deg, #FF0080 0%, #7928CA 50%, #FF0080 100%); background-size: 200% auto; color: white; border: none; width: 100%; max-width: 600px; padding: 12px 20px; border-radius: 16px; font-weight: 900; font-size: 1.1rem; display: flex; align-items: center; justify-content: center; gap: 12px; cursor: pointer; box-shadow: 0 4px 20px rgba(121, 40, 202, 0.5); transition: all 0.3s; animation: gradientMove 3s linear infinite; font-family: 'Vazirmatn', sans-serif; }
107
+ .btn-ai-magic:active { transform: scale(0.98); }
108
+ @keyframes gradientMove { to { background-position: 200% center; } }
109
+
110
+ .controls-bar { display: flex; justify-content: center; gap: 10px; padding: 10px 10px; background: var(--bg); border-bottom: 1px solid #222; position: sticky; top: 115px; z-index: 90; flex-wrap: wrap; }
111
+ .tool-btn { display: flex; flex-direction: column; align-items: center; gap: 5px; background: var(--surface); border: 1px solid #333; color: #aaa; cursor: pointer; transition: 0.2s; padding: 8px 10px; border-radius: 12px; min-width: 70px; flex: 1; max-width: 100px; }
112
+ .tool-btn i { font-size: 1.2rem; margin-bottom: 2px; }
113
+ .tool-btn span { font-size: 0.75rem; font-weight: bold; }
114
+ .tool-btn:hover { color: #fff; background: #2a2a2a; }
115
+ .tool-btn.active-tool { color: #fff; background: var(--primary); border-color: var(--primary); }
116
+
117
+ #toolsContainer { width: 100%; background: var(--panel-bg); overflow: hidden; min-height: 0; transition: none; border-bottom: 1px solid #333; }
118
+ .tool-section { display: none; padding: 20px; animation: fadeIn 0.3s ease; max-width: 800px; margin: 0 auto; }
119
+ .tool-section.active-section { display: block; }
120
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
121
+
122
+ .btn-exp { background: var(--primary); color: #fff; border: none; padding: 10px 25px; border-radius: 12px; font-weight: bold; cursor: pointer; transition: 0.2s; font-family: inherit; }
123
+ .btn-magic-action { background: linear-gradient(135deg, #FF4081, #7C4DFF); width: 100%; border: none; padding: 12px; border-radius: 12px; color: white; font-weight: bold; font-family: inherit; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 10px; }
124
+
125
+ .row { margin-bottom: 20px; }
126
+ label { display: block; margin-bottom: 8px; color: #ccc; font-size: 0.9rem; }
127
+ textarea { width: 100%; background: #222; color: #fff; border: 1px solid #444; padding: 15px; border-radius: 12px; font-family: inherit; font-size: 1rem; margin-bottom: 15px; resize: vertical; min-height: 80px; }
128
+
129
+ /* --- تغییرات اصلی این بخش: گرید و ابعاد کارت --- */
130
+ .style-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; width: 100%; }
131
+
132
+ .style-grid-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
133
+
134
+ .style-card {
135
+ background: #333;
136
+ border: 2px solid transparent;
137
+ border-radius: 15px;
138
+ padding: 5px;
139
+ cursor: pointer;
140
+ display: flex;
141
+ flex-direction: column;
142
+ align-items: center;
143
+ justify-content: center;
144
+ min-height: 0; /* تغییر: حذف حداقل ارتفاع */
145
+ width: 100%;
146
+ position: relative;
147
+ transition: all 0.2s;
148
+ box-shadow: 0 4px 6px rgba(0,0,0,0.2);
149
+ aspect-ratio: 1.4 / 1; /* تغییر: اضافه شدن نسبت ابعاد */
150
+ }
151
+
152
+ .style-grid-2col .style-card { min-height: 100px; height: auto; gap: 8px; padding: 10px; aspect-ratio: auto; } /* ریست کردن aspect ratio برای بخش بالایی */
153
+
154
+ .style-card:active { transform: scale(0.98); }
155
+ .style-card.selected { border-color: var(--primary); background: #2a2a2a; }
156
+ .style-card.selected::after { content: '\f00c'; font-family: "Font Awesome 6 Free"; font-weight: 900; position: absolute; top: 8px; left: 8px; font-size: 0.7rem; color: #fff; background: var(--primary); width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 5px rgba(0,0,0,0.3); }
157
+
158
+ .card-info { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; width: 100%; margin-top: 6px; padding-top: 4px; border-top: 1px solid rgba(255,255,255,0.1); text-align: center; }
159
+ .card-title-style { font-weight: bold; font-size: 0.8rem; color: #fff; white-space: nowrap; }
160
+ .card-desc { color: #aaa; font-size: 0.65rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
161
+
162
+ .anim-box { font-family: 'Lalezar'; font-size: 0.9rem; direction: rtl; display: flex; gap: 3px; margin-bottom: 5px; }
163
+ .anim-word { color: white; transition: 0.2s; padding: 0 2px; border-radius: 4px;}
164
+ .anim-word.active { color: #fff; background-color: #A020F0; transform: scale(1.05); padding: 0 4px; }
165
+ .static-anim.active { background-color: var(--static-color) !important; color: #fff; }
166
+
167
+ .card-settings { position: absolute; bottom: 5px; left: 5px; width: 25px; height: 25px; background: rgba(255,255,255,0.1); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 0.8rem; transition: 0.2s; z-index: 5; border: 1px solid rgba(255,255,255,0.1); }
168
+
169
+ .mode-btn { flex: 1; padding: 12px; border-radius: 8px; background: #333; color: #aaa; border: 2px solid transparent; cursor: pointer; }
170
+ .mode-btn.active { background: var(--primary); color: #fff; border-color: var(--primary); }
171
+ .font-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
172
+ .font-btn { padding: 15px; background: #333; border-radius: 8px; cursor: pointer; border: 2px solid transparent; position: relative; color: #fff; }
173
+ .font-btn.ticked { border-color: var(--primary); }
174
+ .font-btn.ticked::after { content: '\f00c'; font-family: "Font Awesome 6 Free"; font-weight: 900; position: absolute; top: 10px; left: 10px; color: var(--primary); }
175
+
176
+ .custom-style-container { width: 100%; background: linear-gradient(90deg, #252525, #1e1e1e); border: 1px solid #444; border-right: 6px solid var(--primary); border-radius: 12px; margin-bottom: 25px; overflow: hidden; transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s; max-height: 60px; cursor: pointer; position: relative; box-shadow: 0 4px 10px rgba(0,0,0,0.2); }
177
+ .custom-style-container.open { max-height: 600px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); border-color: #666; }
178
+ .custom-header { height: 60px; padding: 0 20px; display: flex; align-items: center; justify-content: space-between; font-family: 'Lalezar'; font-size: 1.2rem; color: #fff; }
179
+ .custom-header-title { display: flex; align-items: center; gap: 10px; }
180
+ .custom-header-title i { color: var(--primary); font-size: 1.4rem; }
181
+ .toggle-icon { transition: transform 0.3s; font-size: 0.9rem; color: #888; }
182
+ .custom-style-container.open .toggle-icon { transform: rotate(180deg); color: #fff; }
183
+ .custom-content { padding: 20px; opacity: 0; transition: opacity 0.3s ease 0.1s; border-top: 1px solid #333; background: #202020; }
184
+ .custom-style-container.open .custom-content { opacity: 1; }
185
+
186
+ .text-editor-ui { display: flex; flex-direction: column; width: 100%; height: 100%; background-color: var(--surface); padding-bottom: 10px; }
187
+
188
+ .toolbar-area {
189
+ height: 60px; display: flex; align-items: center; justify-content: center; gap: 20px;
190
+ opacity: 0; transform: translateY(20px); transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
191
+ pointer-events: none; margin-top: 10px;
192
+ }
193
+ .toolbar-area.show { opacity: 1; transform: translateY(0); pointer-events: all; }
194
+
195
+ .tool-circle {
196
+ width: 45px; height: 45px; border-radius: 50%; background: #252525;
197
+ border: 1px solid #444; color: #ccc; display: flex; align-items: center; justify-content: center;
198
+ font-size: 1.1rem; cursor: pointer; transition: 0.2s;
199
+ }
200
+ .tool-circle:active { transform: scale(0.9); border-color: var(--primary); color: var(--primary); }
201
+ .tool-circle.active-state { background: rgba(124, 77, 255, 0.15); border-color: var(--primary); color: var(--primary); }
202
+
203
+ .fixed-info-bar { width: 100%; height: 30px; display: flex; align-items: center; justify-content: flex-start; padding: 0 20px; border-bottom: 1px solid #222; background-color: var(--surface); }
204
+ .global-time-text { color: #888; font-size: 0.8rem; font-family: monospace; direction: ltr; }
205
+
206
+ .timeline-scroll-area {
207
+ flex: 1; width: 100%; overflow-x: auto; white-space: nowrap; padding: 15px 0;
208
+ display: flex; align-items: center; scrollbar-width: none;
209
+ }
210
+ .timeline-scroll-area::-webkit-scrollbar { display: none; }
211
+ .spacer { min-width: 50vw; }
212
+
213
+ .word-chip {
214
+ background-color: var(--chip-bg); color: #aaa; padding: 8px 18px;
215
+ border-radius: 12px; margin: 0 4px; font-size: 0.95rem; cursor: pointer;
216
+ transition: 0.2s; display: inline-flex; align-items: center; justify-content: center;
217
+ border: 1px solid transparent;
218
+ }
219
+ .word-chip.active { background-color: var(--primary); color: #fff; font-weight: bold; transform: scale(1.1); }
220
+ .newline-indicator { width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; color: #555; margin: 0 10px; }
221
+
222
+ .bottom-sheet-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); z-index: 6000; display: none; align-items: flex-end; justify-content: center; }
223
+ .bottom-sheet-overlay.show { display: flex; }
224
+
225
+ .bottom-sheet {
226
+ width: 100%; background: var(--surface); border-top-left-radius: 30px; border-top-right-radius: 30px;
227
+ padding: 25px 25px 80px 25px; transform: translateY(100%);
228
+ transition: transform 0.4s cubic-bezier(0.19, 1, 0.22, 1);
229
+ display: none; flex-direction: column; gap: 20px;
230
+ box-shadow: 0 -10px 40px rgba(0,0,0,0.6);
231
+ }
232
+ .bottom-sheet.active { display: flex; transform: translateY(0); }
233
+
234
+ .sheet-handle { width: 50px; height: 5px; background: #333; border-radius: 3px; margin: 0 auto 5px auto; }
235
+ .sheet-title { text-align: center; color: #888; font-size: 0.9rem; margin-bottom: 5px; }
236
+
237
+ .sheet-actions { display: flex; gap: 12px; margin-top: 10px; }
238
+ .action-btn { flex: 1; padding: 15px; border-radius: 18px; border: none; font-family: inherit; font-size: 1rem; font-weight: bold; cursor: pointer; }
239
+ .btn-cancel-sheet { background: #333; color: #ccc; }
240
+ .btn-apply-sheet { background: var(--primary); color: #fff; }
241
+ .btn-delete-sheet { background: var(--danger-red); color: #fff; }
242
+
243
+ .sheet-input { width: 100%; background: var(--input-bg); border: none; border-radius: 18px; padding: 18px; color: white; font-family: inherit; font-size: 1.1rem; text-align: center; }
244
+
245
+ .sheet-meta-row { display: flex; align-items: center; justify-content: center; gap: 15px; background: #252525; padding: 10px; border-radius: 15px; }
246
+ .time-display-text { color: #bbb; font-family: monospace; font-size: 1rem; letter-spacing: 1px; direction: ltr; }
247
+
248
+ .mini-play-btn { width: 35px; height: 35px; border-radius: 50%; background: #333; color: #fff; border: none; display: flex; align-items: center; justify-content: center; cursor: pointer; }
249
+
250
+ .time-control-row { display: flex; align-items: center; justify-content: space-between; background: var(--input-bg); padding: 10px 15px; border-radius: 18px; }
251
+ .time-label { color: #aaa; font-size: 0.9rem; width: 50px; }
252
+ .time-adjuster { display: flex; align-items: center; gap: 15px; }
253
+ .time-btn { width: 40px; height: 40px; background: #333; border: none; border-radius: 12px; color: #fff; font-size: 1.2rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
254
+ .time-value { font-family: monospace; font-size: 1.1rem; letter-spacing: 1px; width: 60px; text-align: center; direction: ltr; }
255
+
256
+ .delete-icon-wrap { width: 70px; height: 70px; border-radius: 50%; background: rgba(255, 82, 82, 0.1); color: var(--danger-red); display: flex; align-items: center; justify-content: center; font-size: 2rem; margin: 0 auto; }
257
+ .delete-msg { text-align: center; color: #fff; font-size: 1.1rem; margin-bottom: 10px; line-height: 1.6; }
258
+ .highlight-word { color: var(--primary); font-weight: bold; padding: 0 5px; }
259
+
260
+ /* --- استایل‌های لودینگ نئونی جدید --- */
261
+ #loader {
262
+ --color-1: #b000ff;
263
+ --color-2: #0077ff;
264
+ --color-3: #00eeff;
265
+ --bg-dark: #090909;
266
+ --bg-light: #1a1a2e;
267
+
268
+ position: fixed;
269
+ top: 0;
270
+ left: 0;
271
+ width: 100%;
272
+ height: 100%;
273
+ z-index: 6000;
274
+ display: none;
275
+ justify-content: center;
276
+ align-items: center;
277
+ background: radial-gradient(circle at center, var(--bg-light) 0%, var(--bg-dark) 100%);
278
+ font-family: 'Tahoma', sans-serif;
279
+ overflow: hidden;
280
+
281
+ }
282
+
283
+ /* استایل صفحه نتیجه */
284
+ #resultScreen {
285
+ position: fixed; top:0; left:0; width:100%; height:100%; background: var(--bg); z-index: 6000; display: none; flex-direction: column; justify-content: center; align-items: center;
286
+ }
287
+
288
+ .loader-wrapper {
289
+ display: flex;
290
+ flex-direction: column;
291
+ align-items: center;
292
+ position: relative;
293
+ }
294
+
295
+ /* هاله نور پشت کل لودر */
296
+ .loader-wrapper::before {
297
+ content: '';
298
+ position: absolute;
299
+ top: 20%;
300
+ width: 300px;
301
+ height: 300px;
302
+ background: radial-gradient(circle, rgba(124, 77, 255, 0.15) 0%, transparent 70%);
303
+ border-radius: 50%;
304
+ z-index: -1;
305
+ filter: blur(20px);
306
+ }
307
+
308
+ .modern-loader {
309
+ position: relative;
310
+ width: 160px;
311
+ height: 160px;
312
+ margin-bottom: 50px;
313
+ display: flex;
314
+ justify-content: center;
315
+ align-items: center;
316
+ }
317
+
318
+ .loader-ring {
319
+ position: absolute;
320
+ border-radius: 50%;
321
+ border: 3px solid transparent;
322
+ border-top-color: var(--ring-color);
323
+ animation: spin var(--dur) cubic-bezier(0.5, 0, 0.5, 1) infinite;
324
+ }
325
+
326
+ /* اضافه کردن نقطه نورانی به سر حلقه‌ها */
327
+ .loader-ring::before {
328
+ content: '';
329
+ position: absolute;
330
+ border-radius: 50%;
331
+ width: 8px;
332
+ height: 8px;
333
+ background: var(--ring-color);
334
+ box-shadow: 0 0 15px 2px var(--ring-color);
335
+ top: -5px;
336
+ right: 50%;
337
+ transform: translateX(50%);
338
+ }
339
+
340
+ /* حلقه اول */
341
+ .loader-ring:nth-child(1) {
342
+ width: 100%;
343
+ height: 100%;
344
+ --ring-color: var(--color-1);
345
+ --dur: 2.5s;
346
+ filter: drop-shadow(0 0 10px rgba(176, 0, 255, 0.5));
347
+ }
348
+
349
+ /* حلقه دوم */
350
+ .loader-ring:nth-child(2) {
351
+ width: 75%;
352
+ height: 75%;
353
+ --ring-color: var(--color-2);
354
+ --dur: 2s;
355
+ animation-direction: reverse;
356
+ filter: drop-shadow(0 0 10px rgba(0, 119, 255, 0.5));
357
+ }
358
+
359
+ /* حلقه سوم */
360
+ .loader-ring:nth-child(3) {
361
+ width: 50%;
362
+ height: 50%;
363
+ --ring-color: var(--color-3);
364
+ --dur: 1.5s;
365
+ filter: drop-shadow(0 0 10px rgba(0, 238, 255, 0.5));
366
+ }
367
+
368
+ .loader-icon {
369
+ font-size: 3rem;
370
+ color: #fff;
371
+ animation: float-pulse 3s ease-in-out infinite;
372
+ filter: drop-shadow(0 0 20px var(--color-1));
373
+ z-index: 10;
374
+ }
375
+
376
+ .text-container {
377
+ text-align: center;
378
+ position: relative;
379
+ z-index: 2;
380
+ }
381
+
382
+ .loading-title {
383
+ font-size: 1.5rem;
384
+ font-weight: 800;
385
+ margin-bottom: 12px;
386
+ letter-spacing: -0.5px;
387
+ background: linear-gradient(135deg, #fff 30%, var(--color-2), var(--color-1));
388
+ -webkit-background-clip: text;
389
+ -webkit-text-fill-color: transparent;
390
+ text-shadow: 0 0 30px rgba(124, 77, 255, 0.3);
391
+ animation: text-glow 3s ease-in-out infinite alternate;
392
+ }
393
+
394
+ .loading-sub {
395
+ font-size: 1rem;
396
+ color: #a0a0a0;
397
+ font-weight: 300;
398
+ letter-spacing: 1px;
399
+ opacity: 0.8;
400
+ animation: fade-text 2s infinite alternate;
401
+ }
402
+
403
+ /* انیمیشن‌ها */
404
+ @keyframes spin {
405
+ 0% { transform: rotate(0deg); }
406
+ 100% { transform: rotate(360deg); }
407
+ }
408
+
409
+ @keyframes float-pulse {
410
+ 0%, 100% {
411
+ transform: scale(1) translateY(0);
412
+ opacity: 0.8;
413
+ filter: drop-shadow(0 0 10px var(--color-1));
414
+ }
415
+ 50% {
416
+ transform: scale(1.1) translateY(-5px);
417
+ opacity: 1;
418
+ filter: drop-shadow(0 0 25px var(--color-3));
419
+ }
420
+ }
421
+
422
+ @keyframes text-glow {
423
+ from { text-shadow: 0 0 10px rgba(124, 77, 255, 0.2); }
424
+ to { text-shadow: 0 0 20px rgba(124, 77, 255, 0.6); }
425
+ }
426
+
427
+ @keyframes fade-text {
428
+ from { opacity: 0.5; }
429
+ to { opacity: 1; }
430
+ }
431
+
432
+ /* ========================================= */
433
+ /* === استایل‌های بخش ظاهر (نئونی) === */
434
+ /* ========================================= */
435
+ #section-appearance {
436
+ --track-off: #27272a;
437
+ --neon-primary: #8b5cf6;
438
+ --neon-secondary: #ec4899;
439
+ }
440
+
441
+ .controls-panel {
442
+ width: 100%;
443
+ background: #18181b;
444
+ border-radius: 24px;
445
+ padding: 25px;
446
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
447
+ border: 1px solid #333;
448
+ margin-top: 10px;
449
+ }
450
+
451
+ .control-group {
452
+ margin-bottom: 30px;
453
+ }
454
+ .control-group:last-child { margin-bottom: 0; }
455
+
456
+ .header-neon {
457
+ display: flex;
458
+ justify-content: space-between;
459
+ align-items: center;
460
+ margin-bottom: 15px;
461
+ }
462
+
463
+ .title-neon {
464
+ display: flex;
465
+ align-items: center;
466
+ gap: 10px;
467
+ font-weight: 700;
468
+ font-size: 0.95rem;
469
+ color: #e4e4e7;
470
+ }
471
+
472
+ .title-neon i {
473
+ color: var(--neon-primary);
474
+ font-size: 1.1rem;
475
+ text-shadow: 0 0 10px rgba(139, 92, 246, 0.5);
476
+ }
477
+
478
+ .percent-badge {
479
+ font-family: monospace;
480
+ font-size: 1rem;
481
+ font-weight: bold;
482
+ color: #fff;
483
+ background: rgba(139, 92, 246, 0.15);
484
+ padding: 4px 12px;
485
+ border-radius: 8px;
486
+ border: 1px solid rgba(139, 92, 246, 0.3);
487
+ min-width: 60px;
488
+ text-align: center;
489
+ }
490
+
491
+ .range-container {
492
+ position: relative;
493
+ width: 100%;
494
+ height: 30px;
495
+ display: flex;
496
+ align-items: center;
497
+ }
498
+
499
+ .range-container input[type=range] {
500
+ -webkit-appearance: none;
501
+ width: 100%;
502
+ background: transparent;
503
+ cursor: pointer;
504
+ z-index: 2;
505
+ margin: 0;
506
+ }
507
+
508
+ .range-container input[type=range]::-webkit-slider-runnable-track {
509
+ width: 100%;
510
+ height: 8px;
511
+ background: var(--track-off);
512
+ border-radius: 20px;
513
+ border: none;
514
+ background-image: linear-gradient(90deg, var(--neon-primary), var(--neon-secondary));
515
+ background-size: 0% 100%;
516
+ background-repeat: no-repeat;
517
+ }
518
+
519
+ .range-container input[type=range]::-webkit-slider-thumb {
520
+ -webkit-appearance: none;
521
+ height: 24px;
522
+ width: 24px;
523
+ border-radius: 50%;
524
+ background: #fff;
525
+ margin-top: -8px;
526
+ border: 4px solid #18181b;
527
+ box-shadow: 0 0 0 2px var(--neon-primary), 0 0 15px rgba(139, 92, 246, 0.8);
528
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
529
+ }
530
+
531
+ .range-container input[type=range]:active::-webkit-slider-thumb {
532
+ transform: scale(1.3);
533
+ background: var(--neon-primary);
534
+ border-color: #fff;
535
+ box-shadow: 0 0 0 0 transparent, 0 0 25px rgba(236, 72, 153, 0.9);
536
+ }
537
+
538
+ .center-marker {
539
+ position: absolute;
540
+ left: 50%;
541
+ top: 50%;
542
+ transform: translate(-50%, -50%);
543
+ width: 2px;
544
+ height: 12px;
545
+ background: rgba(255,255,255,0.2);
546
+ z-index: 1;
547
+ pointer-events: none;
548
+ }
549
+
550
+ /* ========================================= */
551
+ /* === PRO COLOR PICKER STYLES (iOS/Luxury) === */
552
+ /* ========================================= */
553
+ :root {
554
+ --glass: rgba(28,28,30,.72);
555
+ --glass2: rgba(44,44,46,.80);
556
+ --stroke: rgba(255,255,255,.10);
557
+ --shadow-lg: 0 22px 60px rgba(0,0,0,.62);
558
+ --shadow-md: 0 12px 30px rgba(0,0,0,.46);
559
+ --shadow-sm: 0 8px 18px rgba(0,0,0,.36);
560
+ --separator: rgba(84,84,88,.38);
561
+ --ok: #34C759;
562
+ }
563
+
564
+ .toast-notification{ position: fixed; top: calc(16px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%) translateY(-18px); background: rgba(28,28,30,.78); -webkit-backdrop-filter: blur(18px) saturate(170%); backdrop-filter: blur(18px) saturate(170%); border: 1px solid rgba(255,255,255,.12); box-shadow: var(--shadow-md); color: #fff; padding: 12px 18px; border-radius: 999px; z-index: 20000; font-size: .9rem; font-weight: 900; opacity: 0; visibility: hidden; transition: opacity .25s ease, transform .25s ease, visibility .25s ease; display:flex; align-items:center; gap: 10px; pointer-events:none; }
565
+ .toast-notification i{ color: var(--ok); }
566
+ .toast-notification.show{ opacity:1; visibility: visible; transform: translateX(-50%) translateY(0); }
567
+
568
+ .picker-backdrop{ position: fixed; inset: 0; background: rgba(0,0,0,.46); opacity: 0; visibility: hidden; transition: opacity .22s ease, visibility .22s ease; z-index: 9998; }
569
+ .picker-backdrop.active{ opacity: 1; visibility: visible; }
570
+
571
+ .picker-modal{ position: fixed; bottom: 0; left: 50%; width: min(100%, 720px); transform: translateX(-50%) translateY(110%); transition: transform .28s cubic-bezier(0.2,0.8,0.2,1); background: rgba(28,28,30,.74); -webkit-backdrop-filter: blur(22px) saturate(170%); backdrop-filter: blur(22px) saturate(170%); border: 1px solid rgba(255,255,255,.12); box-shadow: var(--shadow-lg); border-radius: 30px 30px 0 0; z-index: 9999; display:flex; flex-direction:column; max-height: 92vh; padding-bottom: calc(14px + env(safe-area-inset-bottom)); }
572
+ .picker-modal.active{ transform: translateX(-50%) translateY(0); }
573
+
574
+ .picker-header{ display:flex; align-items:center; justify-content:space-between; padding: 14px 16px; border-bottom: 1px solid var(--separator); flex-shrink: 0; }
575
+ .picker-icon{ width: 42px; height: 42px; border-radius: 999px; background: rgba(120,120,128,.22); border: 1px solid rgba(255,255,255,.10); display:flex; align-items:center; justify-content:center; color:#fff; cursor:pointer; transition: transform .12s ease; }
576
+ .picker-icon:active{ transform: scale(.96); }
577
+
578
+ .picker-tabs{ margin: 12px 16px 10px 16px; padding: 4px; border-radius: 14px; background: rgba(120,120,128,.18); border: 1px solid rgba(255,255,255,.10); display:flex; gap: 4px; flex-shrink: 0; }
579
+ .picker-tab{ flex:1; text-align:center; padding: 10px 0; border-radius: 10px; cursor:pointer; color: rgba(235,235,245,.62); font-weight: 900; transition: background .2s ease, color .2s ease; user-select:none; }
580
+ .picker-tab.active{ background: rgba(255,255,255,.14); color:#fff; box-shadow: inset 0 1px 0 rgba(255,255,255,.10); }
581
+
582
+ .view-section{ display:none; overflow-y:auto; padding-bottom: 10px; }
583
+ .view-section.active-view{ display:block; }
584
+
585
+ /* Spectrum */
586
+ .spectrum-container{ margin: 0 16px 6px 16px; height: 220px; border-radius: 18px; border: 1px solid rgba(255,255,255,.10); background: linear-gradient(to right, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); overflow:hidden; position:relative; cursor: crosshair; direction:ltr; box-shadow: inset 0 0 0 1px rgba(0,0,0,.25); }
587
+ .spectrum-container::after{ content:""; position:absolute; inset:0; background: radial-gradient(900px 260px at 15% 0%, rgba(255,255,255,.14), transparent 55%); pointer-events:none; }
588
+ .spectrum-overlay{ position:absolute; inset:0; background: linear-gradient(to bottom, transparent 0%, #ffffff 100%); mix-blend-mode: normal; }
589
+ .spectrum-handle{ position:absolute; width: 28px; height: 28px; border-radius: 999px; border: 3px solid rgba(255,255,255,.95); box-shadow: 0 10px 18px rgba(0,0,0,.45); transform: translate(-50%, -50%); pointer-events:none; background: rgba(0,0,0,.06); }
590
+
591
+ /* GRID */
592
+ .grid-view-container{ margin: 0 16px 0 16px; padding: 12px; background: rgba(120,120,128,.10); border: 1px solid rgba(255,255,255,.10); border-radius: 18px; -webkit-backdrop-filter: blur(16px) saturate(160%); backdrop-filter: blur(16px) saturate(160%); box-shadow: inset 0 1px 0 rgba(255,255,255,.06); display:grid; grid-template-columns: repeat(auto-fill, minmax(28px, 1fr)); gap: 8px; max-height: 320px; overflow-y: auto; overscroll-behavior: contain; scroll-behavior: smooth; }
593
+ .grid-view-container::-webkit-scrollbar{ width: 10px; }
594
+ .grid-view-container::-webkit-scrollbar-track{ background: rgba(255,255,255,.04); border-radius: 999px; }
595
+ .grid-view-container::-webkit-scrollbar-thumb{ background: rgba(255,255,255,.12); border-radius: 999px; border: 2px solid rgba(0,0,0,.0); background-clip: padding-box; }
596
+ .grid-item{ aspect-ratio: 1; border-radius: 10px; cursor:pointer; border: 1px solid rgba(255,255,255,.12); box-shadow: inset 0 1px 0 rgba(255,255,255,.10), 0 8px 14px rgba(0,0,0,.12); transition: transform .12s ease, box-shadow .18s ease, border-color .18s ease; position: relative; overflow: hidden; }
597
+ .grid-item::after{ content:""; position:absolute; inset:0; background: radial-gradient(120% 120% at 20% 15%, rgba(255,255,255,.18), transparent 55%); pointer-events:none; opacity: .55; }
598
+ .grid-item:active{ transform: scale(.94); }
599
+ .grid-item.selected{ border-color: rgba(255,255,255,.90); box-shadow: 0 0 0 3px rgba(255,255,255,.14), inset 0 1px 0 rgba(255,255,255,.12), 0 12px 22px rgba(0,0,0,.18); transform: scale(1.02); }
600
+ .grid-item.selected::before{ content:"\f00c"; font-family: "Font Awesome 6 Free"; font-weight: 900; position:absolute; right: 6px; top: 6px; width: 16px; height: 16px; border-radius: 999px; display:flex; align-items:center; justify-content:center; font-size: 10px; color: rgba(0,0,0,.80); background: rgba(255,255,255,.92); box-shadow: 0 6px 12px rgba(0,0,0,.22); z-index: 2; }
601
+
602
+ /* Manual View */
603
+ .manual-view-container{ padding: 10px 16px; display:flex; flex-direction:column; gap: 10px; }
604
+ .manual-control-row{ display:flex; align-items:center; gap: 10px; justify-content:space-between; }
605
+ .manual-num-input{ width: 64px; border-radius: 14px; padding: 10px 0; text-align:center; font-weight: 900; font-size: 1.05rem; color:#fff; background: rgba(120,120,128,.22); border: 1px solid rgba(255,255,255,.10); }
606
+ .manual-slider-group{ flex:1; display:flex; flex-direction:column; gap: 6px; }
607
+ .manual-label-text{ font-size:.82rem; color: rgba(235,235,245,.62); font-weight:900; padding-right: 6px; }
608
+ .rgb-slider{ -webkit-appearance:none; appearance:none; width: 100%; height: 40px; background: transparent; border-radius: 999px; --track-bg: rgba(255,255,255,.10); }
609
+ .rgb-slider::-webkit-slider-runnable-track{ height: 14px; border-radius: 999px; background: var(--track-bg); box-shadow: inset 0 1px 0 rgba(255,255,255,.10); }
610
+ .rgb-slider::-webkit-slider-thumb{ -webkit-appearance:none; appearance:none; width: 28px; height: 28px; border-radius: 999px; background: rgba(255,255,255,.95); border: 2px solid rgba(255,255,255,.85); margin-top: -7px; box-shadow: 0 12px 20px rgba(0,0,0,.35); cursor:pointer; }
611
+ .hex-row{ margin-top: 6px; display:flex; align-items:center; gap: 10px; }
612
+ .hex-input{ width: 120px; border-radius: 14px; padding: 12px 10px; text-align:center; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-weight: 900; color:#fff; background: rgba(120,120,128,.22); border: 1px solid rgba(255,255,255,.10); letter-spacing: .5px; }
613
+ .hex-label{ color: rgba(235,235,245,.56); font-weight: 900; font-size: .9rem; }
614
+
615
+ /* Global Sliders (Bright/Alpha) */
616
+ .sliders-global-wrapper{ margin-top: 10px; padding: 14px 16px 6px 16px; border-top: 1px solid var(--separator); flex-shrink: 0; }
617
+ .global-slider-row{ display:flex; align-items:center; gap: 10px; margin-bottom: 22px; position:relative; }
618
+ .global-slider-row:last-child{ margin-bottom: 8px; }
619
+ .global-slider-container{ flex:1; position:relative; height: 42px; border-radius: 18px; background: rgba(120,120,128,.12); border: 1px solid rgba(255,255,255,.10); padding: 0 12px; display:flex; align-items:center; -webkit-backdrop-filter: blur(16px) saturate(160%); backdrop-filter: blur(16px) saturate(160%); box-shadow: inset 0 1px 0 rgba(255,255,255,.06); }
620
+ .global-label-top{ position:absolute; right: 2px; top: -22px; font-size: .82rem; font-weight: 900; color: rgba(235,235,245,.62); }
621
+ .value-pill{ min-width: 84px; height: 42px; border-radius: 999px; background: rgba(120,120,128,.22); border: 1px solid rgba(255,255,255,.10); display:flex; align-items:center; justify-content:center; gap: 8px; font-weight: 900; color:#fff; box-shadow: inset 0 1px 0 rgba(255,255,255,.08); }
622
+ .value-pill i{ font-size: .95rem; color: rgba(235,235,245,.72); }
623
+ .common-slider{ -webkit-appearance:none; appearance:none; width: 100%; height: 40px; background: transparent; border-radius: 999px; --track-bg: rgba(255,255,255,.10); direction: ltr !important; }
624
+ .common-slider::-webkit-slider-runnable-track{ height: 14px; border-radius: 999px; background: var(--track-bg); box-shadow: inset 0 1px 0 rgba(255,255,255,.10); }
625
+ .common-slider::-webkit-slider-thumb{ -webkit-appearance:none; appearance:none; width: 30px; height: 30px; border-radius: 999px; background: rgba(255,255,255,.95); border: 2px solid rgba(255,255,255,.85); margin-top: -8px; box-shadow: 0 12px 20px rgba(0,0,0,.35); cursor:pointer; }
626
+
627
+ /* FIX: Photoshop Checkered Pattern for Alpha Container & Preview */
628
+ .alpha-checkered {
629
+ width: 100%;
630
+ height: 42px;
631
+ border-radius: 16px;
632
+ background-image: conic-gradient(#dedede 0.25turn, #ffffff 0.25turn 0.5turn, #dedede 0.5turn 0.75turn, #ffffff 0.75turn);
633
+ background-size: 14px 14px;
634
+ background-position: top left;
635
+ overflow: hidden;
636
+ display: flex;
637
+ align-items: center;
638
+ padding: 0 12px;
639
+ border: 1px solid rgba(255,255,255,.10);
640
+ }
641
+
642
+ /* Footer & Large Preview Fix */
643
+ .picker-footer{ margin-top: auto; padding: 12px 16px 0 16px; display:flex; align-items:center; justify-content:space-between; gap: 12px; border-top: 1px solid var(--separator); flex-shrink: 0; background: transparent; }
644
+
645
+ /* FIX: Photoshop Checkered Pattern for Large Preview */
646
+ .large-preview {
647
+ width: 64px;
648
+ height: 64px;
649
+ border-radius: 18px;
650
+ border: 1px solid rgba(255,255,255,.12);
651
+ box-shadow: 0 14px 24px rgba(0,0,0,.24);
652
+ flex-shrink: 0;
653
+ /* الگوی شطرنجی دقیق فتوشاپ */
654
+ background-image: conic-gradient(#dedede 0.25turn, #ffffff 0.25turn 0.5turn, #dedede 0.5turn 0.75turn, #ffffff 0.75turn);
655
+ background-size: 16px 16px;
656
+ background-position: center;
657
+ }
658
+
659
+ .saved-colors-container{ display:flex; gap: 10px; align-items:center; flex-wrap: wrap; flex:1; justify-content:flex-end; max-height: 90px; overflow-y:auto; padding-right: 4px; }
660
+ .btn-save-text{ height: 38px; border-radius: 999px; padding: 0 14px; display:flex; align-items:center; justify-content:center; gap: 8px; font-weight: 900; font-size: .82rem; color: rgba(255,255,255,.92); background: rgba(120,120,128,.18); border: 1px solid rgba(255,255,255,.10); cursor:pointer; user-select:none; transition: transform .12s ease, background .18s ease; flex-shrink:0; }
661
+ .btn-save-text:active{ transform: scale(.98); background: rgba(10,132,255,.22); border-color: rgba(10,132,255,.35); }
662
+ .saved-color-wrapper{ position:relative; width: 42px; height: 42px; flex-shrink:0; }
663
+ .saved-circle{ width:100%; height:100%; border-radius: 14px; border: 1px solid rgba(255,255,255,.14); box-shadow: inset 0 1px 0 rgba(255,255,255,.10), 0 12px 18px rgba(0,0,0,.18); cursor:pointer; transition: transform .12s ease; }
664
+ .saved-circle:active{ transform: scale(.94); }
665
+ .mini-delete-btn{ position:absolute; top:-6px; right:-6px; width: 20px; height: 20px; border-radius: 999px; background: rgba(255,69,58,.95); border: 2px solid rgba(28,28,30,.88); display:flex; align-items:center; justify-content:center; color:#fff; cursor:pointer; box-shadow: 0 10px 16px rgba(0,0,0,.30); transition: transform .12s ease; z-index:2; }
666
+ .mini-delete-btn i{ font-size: 10px; }
667
+ .mini-delete-btn:active{ transform: scale(.92); }
668
+
669
+ /* Color Triggers inside accordion */
670
+ .color-trigger-row{ display:flex; gap: 12px; margin: 14px 0 16px 0; }
671
+ .color-trigger{ flex:1; border-radius: 16px; background: rgba(44,44,46,.55); border: 1px solid rgba(255,255,255,.10); padding: 12px 12px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; position:relative; transition: transform .12s ease; }
672
+ .color-trigger:active{ transform: scale(.992); }
673
+ .color-trigger::before{ content:"\f054"; font-family: "Font Awesome 6 Free"; font-weight: 900; position:absolute; left: 12px; color: rgba(235,235,245,.42); font-size: .85rem; }
674
+ .color-label{ color: rgba(235,235,245,.80); font-weight: 900; font-size: .92rem; padding-left: 18px; }
675
+ .color-preview-circle{ width: 34px; height: 34px; border-radius: 12px; border: 1px solid rgba(255,255,255,.55); box-shadow: 0 10px 18px rgba(0,0,0,.28); }
676
+
677
+ /* Delete modal specific */
678
+ .delete-overlay{ position: fixed; inset: 0; background: rgba(0,0,0,.58); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); z-index: 10000; display:none; align-items:center; justify-content:center; opacity:0; transition: opacity .22s ease; padding: 18px; }
679
+ .delete-overlay.active{ display:flex; opacity:1; }
680
+ .delete-box{ width: min(340px, 92vw); border-radius: 22px; background: rgba(28,28,30,.88); border: 1px solid rgba(255,255,255,.12); box-shadow: var(--shadow-lg); padding: 22px 18px; text-align:center; transform: scale(.92); opacity:0; transition: transform .22s cubic-bezier(0.2,0.8,0.2,1), opacity .22s ease; }
681
+ .delete-overlay.active .delete-box{ transform: scale(1); opacity:1; }
682
+ .del-preview{ width: 64px; height: 64px; border-radius: 18px; margin: 0 auto 14px auto; border: 1px solid rgba(255,255,255,.12); box-shadow: 0 14px 22px rgba(0,0,0,.22); }
683
+ .del-title{ color:#fff; font-weight: 900; font-size: 1.02rem; margin: 8px 0 16px 0; }
684
+ .del-actions{ display:flex; gap: 10px; }
685
+ .del-btn{ flex:1; border:none; border-radius: 16px; padding: 12px 0; font-weight: 900; font-size: 1rem; cursor:pointer; transition: transform .12s ease, filter .12s ease; }
686
+ .del-btn:active{ transform: scale(.99); filter: brightness(1.05); }
687
+ .del-btn-confirm{ background: rgba(255,69,58,.95); color:#fff; }
688
+ .del-btn-cancel{ background: rgba(120,120,128,.22); color:#fff; border: 1px solid rgba(255,255,255,.10); }
689
+
690
+ /* ================================================== */
691
+ /* === استایل‌های جدید برای بخش ظاهر (Appearance) === */
692
+ /* ================================================== */
693
+ #section-appearance .controls-panel {
694
+ padding: 15px;
695
+ }
696
+
697
+ /* پنل‌های داخلی برای گروه‌بندی کنترل‌ها */
698
+ .control-group-panel {
699
+ background-color: #1f1f23;
700
+ border: 1px solid #3a3a3e;
701
+ border-radius: 18px;
702
+ padding: 20px;
703
+ margin-bottom: 20px;
704
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
705
+ }
706
+ .control-group-panel:last-child {
707
+ margin-bottom: 0;
708
+ }
709
+
710
+ /* بخش پیش‌نمایش زنده برای کادر */
711
+ .box-preview-area {
712
+ width: 100%;
713
+ min-height: 90px;
714
+ margin-bottom: 25px;
715
+ padding: 15px;
716
+ /* --- تغییر اصلی اینجاست --- */
717
+ background-image: conic-gradient(#444 0.25turn, #333 0.25turn 0.5turn, #444 0.5turn 0.75turn, #333 0.75turn);
718
+ background-size: 20px 20px;
719
+ /* -------------------------- */
720
+ border-radius: 12px;
721
+ display: flex;
722
+ justify-content: center;
723
+ align-items: center;
724
+ border: 1px solid #555;
725
+ }
726
+
727
+ .box-preview-element {
728
+ color: #fff;
729
+ font-size: 1.2rem;
730
+ font-weight: bold;
731
+ display: inline-block;
732
+ white-space: nowrap;
733
+ /* اینجا line-height وجود نداشت */
734
+ transition: all 0.15s ease-out;
735
+ }
736
+
737
+ /* ======================================== */
738
+ /* === استایل‌های انیمیشن بازتولید زیرنویس (جدید) === */
739
+ /* ======================================== */
740
+
741
+ .sheet-spinner-ring {
742
+ width: 60px;
743
+ height: 60px;
744
+ border-radius: 50%;
745
+ border: 4px solid #333;
746
+ border-top-color: var(--primary);
747
+ border-right-color: var(--primary);
748
+ animation: ringSpin 1s cubic-bezier(0.5, 0, 0.5, 1) infinite;
749
+ }
750
+
751
+ @keyframes ringSpin {
752
+ 0% { transform: rotate(0deg); }
753
+ 100% { transform: rotate(360deg); }
754
+ }
755
+
756
+ .success-checkmark-anim {
757
+ width: 80px;
758
+ height: 80px;
759
+ border-radius: 50%;
760
+ background: rgba(0, 230, 118, 0.15);
761
+ display: flex;
762
+ align-items: center;
763
+ justify-content: center;
764
+ animation: successPop 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
765
+ }
766
+
767
+ .success-checkmark-anim i {
768
+ font-size: 3rem;
769
+ color: #00e676;
770
+ animation: checkScale 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.1s both;
771
+ }
772
+
773
+ @keyframes successPop {
774
+ from { transform: scale(0); opacity: 0; }
775
+ to { transform: scale(1); opacity: 1; }
776
+ }
777
+
778
+ @keyframes checkScale {
779
+ from { transform: scale(0); }
780
+ to { transform: scale(1); }
781
+ }
782
+
783
+ /* ======================================== */
784
+ /* === استایل‌های دکمه‌های تعداد کلمات === */
785
+ /* ======================================== */
786
+
787
+ .word-count-grid {
788
+ display: flex;
789
+ overflow-x: auto;
790
+ padding: 10px 0;
791
+ gap: 10px;
792
+ width: 100%;
793
+ scrollbar-width: none;
794
+ -ms-overflow-style: none;
795
+ justify-content: space-between;
796
+ }
797
+ .word-count-grid::-webkit-scrollbar {
798
+ display: none;
799
+ }
800
+
801
+ .wc-btn {
802
+ flex: 0 0 auto;
803
+ width: 50px;
804
+ height: 50px;
805
+ border-radius: 14px;
806
+ background: #333;
807
+ color: #fff;
808
+ border: 1px solid #444;
809
+ font-size: 1.2rem;
810
+ font-weight: bold;
811
+ cursor: pointer;
812
+ transition: 0.2s;
813
+ display: flex;
814
+ align-items: center;
815
+ justify-content: center;
816
+ }
817
+ .wc-btn:active {
818
+ background: var(--primary);
819
+ border-color: var(--primary);
820
+ transform: scale(0.9);
821
+ }
822
+
823
+ /* ======================================== */
824
+ /* === استایل‌های جدید Trimmer (ویرایش زمان) === */
825
+ /* ======================================== */
826
+
827
+ .trimmer-header {
828
+ display: flex;
829
+ flex-direction: column;
830
+ align-items: center;
831
+ justify-content: center;
832
+ gap: 10px;
833
+ margin-bottom: 25px;
834
+ width: 100%;
835
+ }
836
+
837
+ .trimmer-play-btn {
838
+ width: 50px;
839
+ height: 50px;
840
+ border-radius: 50%;
841
+ border: 2px solid var(--accent-orange);
842
+ background: transparent;
843
+ color: var(--accent-orange);
844
+ font-size: 1.4rem;
845
+ display: flex;
846
+ align-items: center;
847
+ justify-content: center;
848
+ cursor: pointer;
849
+ transition: transform 0.2s;
850
+ }
851
+ .trimmer-play-btn:active {
852
+ transform: scale(0.9);
853
+ }
854
+
855
+ .trimmer-time-display {
856
+ font-family: monospace;
857
+ font-size: 1.2rem;
858
+ color: #fff;
859
+ font-weight: bold;
860
+ direction: ltr;
861
+ }
862
+
863
+ .trimmer-body {
864
+ display: flex;
865
+ align-items: center;
866
+ justify-content: space-between;
867
+ width: 100%;
868
+ gap: 10px;
869
+ margin-bottom: 20px;
870
+ }
871
+
872
+ .word-preview-side {
873
+ width: 60px;
874
+ height: 60px;
875
+ background: #222;
876
+ border-radius: 12px;
877
+ display: flex;
878
+ align-items: center;
879
+ justify-content: center;
880
+ color: #777;
881
+ font-size: 0.9rem;
882
+ font-weight: bold;
883
+ text-align: center;
884
+ flex-shrink: 0;
885
+ }
886
+
887
+ .trimmer-container {
888
+ flex: 1;
889
+ position: relative;
890
+ height: 60px;
891
+ background: #000;
892
+ border-radius: 10px;
893
+ overflow: hidden;
894
+ /* اگر بخواهیم هندل‌ها بیرون نزنند */
895
+ }
896
+
897
+ /* نوار کلی (ترک) */
898
+ .timeline-strip {
899
+ position: absolute;
900
+ top: 50%;
901
+ left: 10px; /* مارجین برای هندل‌ها */
902
+ right: 10px;
903
+ height: 40px;
904
+ transform: translateY(-50%);
905
+ background: linear-gradient(90deg, #333 0%, #444 50%, #333 100%);
906
+ background-size: 20px 100%;
907
+ border-radius: 4px;
908
+ }
909
+
910
+ /* بخش فعال (بین دو هندل) */
911
+ .trim-track-active {
912
+ position: absolute;
913
+ top: 0;
914
+ bottom: 0;
915
+ background: rgba(255, 255, 255, 0.15);
916
+ border-top: 2px solid #FDD835;
917
+ border-bottom: 2px solid #FDD835;
918
+ left: 0; /* این مقادیر با JS تغییر میکنن */
919
+ right: 0; /* این مقادیر با JS تغییر میکنن */
920
+ }
921
+
922
+ /* هندل‌های زرد رنگ */
923
+ .trim-handle {
924
+ position: absolute;
925
+ top: 0;
926
+ bottom: 0;
927
+ width: 20px;
928
+ background: #FDD835;
929
+ cursor: col-resize;
930
+ display: flex;
931
+ align-items: center;
932
+ justify-content: center;
933
+ color: #000;
934
+ font-size: 0.8rem;
935
+ z-index: 10;
936
+ border-radius: 4px;
937
+ }
938
+
939
+ .trim-handle.left {
940
+ left: 0; /* JS کنترل میکنه */
941
+ border-top-left-radius: 8px;
942
+ border-bottom-left-radius: 8px;
943
+ }
944
+
945
+ .trim-handle.right {
946
+ right: 0; /* JS کنترل میکنه (right: 0 یعنی چسبیده به ته) */
947
+ /* اما برای پوزیشن ابسولوت نسبت به پرنت، ما از style.left استفاده میکنیم */
948
+ border-top-right-radius: 8px;
949
+ border-bottom-right-radius: 8px;
950
+ }
951
+
952
+ .trimmer-current-word {
953
+ text-align: center;
954
+ background: #333;
955
+ padding: 10px 20px;
956
+ border-radius: 16px;
957
+ color: #fff;
958
+ font-size: 1.1rem;
959
+ font-weight: bold;
960
+ align-self: center;
961
+ margin-bottom: 10px;
962
+ min-width: 100px;
963
+ }
964
+ /* --- استایل‌های جدید بخش فونت و تنظیمات --- */
965
+ .sub-nav-container { display: flex; background: #252525; padding: 5px; border-radius: 14px; margin-bottom: 20px; }
966
+ .sub-nav-btn { flex: 1; text-align: center; padding: 10px; border-radius: 10px; color: #aaa; font-weight: 900; font-size: 0.9rem; cursor: pointer; transition: 0.2s; }
967
+ .sub-nav-btn.active { background: var(--primary); color: #fff; box-shadow: 0 4px 10px rgba(124, 77, 255, 0.3); }
968
+ .sub-section-content { display: none; }
969
+ .sub-section-content.active { display: block; animation: fadeIn 0.2s ease; }
970
+
971
+ .settings-row { display: flex; align-items: center; justify-content: space-between; background: #252525; padding: 12px 16px; border-radius: 18px; margin-bottom: 12px; border: 1px solid #333; transition: transform 0.2s, background 0.2s; cursor: pointer; }
972
+ .settings-row:active { transform: scale(0.98); background: #2a2a2a; }
973
+ .settings-left-part { display: flex; align-items: center; gap: 14px; }
974
+ .settings-icon-box { width: 38px; height: 38px; border-radius: 12px; background: rgba(124, 77, 255, 0.1); display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 8px rgba(0,0,0,0.2); }
975
+ .settings-icon-box svg { width: 20px; height: 20px; fill: var(--primary); filter: drop-shadow(0 0 5px rgba(124,77,255,0.5)); }
976
+ .settings-label { font-weight: 900; color: #eee; font-size: 0.95rem; }
977
+ .color-ring-container { width: 42px; height: 42px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.15); padding: 3px; display: flex; align-items: center; justify-content: center; transition: border-color 0.2s; }
978
+ .settings-row:hover .color-ring-container { border-color: var(--primary); }
979
+ .color-circle-inner { width: 100%; height: 100%; border-radius: 50%; box-shadow: inset 0 0 10px rgba(0,0,0,0.2); border: 1px solid rgba(0,0,0,0.1); }
980
+ /* --- استایل‌های جدید سوییچ و کشو برای رنگ کلمه فعال --- */
981
+ .settings-row-toggle { background: #252525; border-radius: 18px; margin-bottom: 12px; border: 1px solid #333; overflow: hidden; transition: 0.3s; }
982
+ .setting-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
983
+ .premium-toggle { width: 48px; height: 28px; background: #3a3a3c; border-radius: 30px; position: relative; transition: 0.3s ease; box-shadow: inset 0 2px 5px rgba(0,0,0,0.3); }
984
+ .premium-toggle::after { content: ''; position: absolute; top: 2px; right: 2px; width: 24px; height: 24px; background: #ffffff; border-radius: 50%; box-shadow: 0 2px 8px rgba(0,0,0,0.4); transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
985
+ .settings-row-toggle.active .premium-toggle { background: var(--primary); box-shadow: inset 0 2px 5px rgba(0,0,0,0.2), 0 0 15px rgba(124, 77, 255, 0.4); }
986
+ .settings-row-toggle.active .premium-toggle::after { transform: translateX(-20px); }
987
+ .settings-row-toggle.active .settings-icon-box { background: rgba(124, 77, 255, 0.15); }
988
+ .settings-row-toggle.active .settings-icon-box svg { fill: var(--primary); filter: drop-shadow(0 0 5px rgba(124,77,255,0.5)); }
989
+ .settings-row-toggle.active .settings-label { color: #ffffff; }
990
+ .dropdown-wrapper { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
991
+ .settings-row-toggle.active .dropdown-wrapper { grid-template-rows: 1fr; }
992
+ .dropdown-inner { overflow: hidden; }
993
+ .dropdown-content { background: #1e1e1e; padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid #333; opacity: 0; transform: translateY(-10px); transition: 0.3s; }
994
+ .settings-row-toggle.active .dropdown-content { opacity: 1; transform: translateY(0); }
995
+ /* ==================== قابلیت چندزبانه (Multi-language) ==================== */
996
+
997
+ /* --- شیت انتخاب زبان --- */
998
+ #languageSheetOverlay { z-index: 7000; }
999
+ .lang-sheet { max-height: 75vh; overflow-y: auto; }
1000
+ .lang-sheet-hint { text-align: center; color: #666; font-size: 0.8rem; margin-top: -10px; margin-bottom: 5px; }
1001
+ .lang-list { display: flex; flex-direction: column; gap: 8px; max-height: 50vh; overflow-y: auto; }
1002
+ .lang-row { display: flex; align-items: center; gap: 12px; background: #2a2a2a; border-radius: 14px; padding: 14px 16px; cursor: pointer; transition: 0.15s; border: 1.5px solid transparent; }
1003
+ .lang-row:active { transform: scale(0.98); }
1004
+ .lang-row.active { border-color: var(--primary); background: rgba(124, 77, 255, 0.12); }
1005
+ .lang-row .lang-flag { font-size: 1.4rem; }
1006
+ .lang-row .lang-name { flex: 1; font-size: 1rem; font-weight: 500; }
1007
+ .lang-row .lang-check { color: var(--primary); font-size: 1.1rem; }
1008
+
1009
+ /* --- کارت تایید پیش از آپلود --- */
1010
+ .pre-upload-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 6500; display: none; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.25s; padding: 20px; }
1011
+ .pre-upload-overlay.show { opacity: 1; }
1012
+ .pre-upload-card { position: relative; background: linear-gradient(160deg, #232323, #1a1a1a); border: 1px solid #333; width: 100%; max-width: 380px; border-radius: 24px; padding: 35px 25px 25px; display: flex; flex-direction: column; align-items: center; text-align: center; transform: scale(0.9) translateY(20px); transition: transform 0.25s; box-shadow: 0 20px 50px rgba(0,0,0,0.5); }
1013
+ .pre-upload-overlay.show .pre-upload-card { transform: scale(1) translateY(0); }
1014
+ .pre-upload-close { position: absolute; top: 14px; left: 14px; background: #2c2c2c; border: none; color: #aaa; width: 32px; height: 32px; border-radius: 50%; cursor: pointer; font-size: 0.9rem; }
1015
+ .pre-upload-icon { width: 70px; height: 70px; border-radius: 50%; background: rgba(124, 77, 255, 0.12); color: var(--primary); display: flex; align-items: center; justify-content: center; font-size: 1.8rem; margin-bottom: 15px; }
1016
+ .pre-upload-filename { font-size: 1.05rem; font-weight: bold; color: #fff; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1017
+ .pre-upload-filesize { font-size: 0.8rem; color: #777; margin-top: 4px; margin-bottom: 22px; }
1018
+ .pre-upload-lang-row { width: 100%; display: flex; align-items: center; justify-content: space-between; background: #2a2a2a; border-radius: 14px; padding: 12px 16px; margin-bottom: 22px; }
1019
+ .pre-upload-lang-label { font-size: 0.9rem; color: #ccc; display: flex; align-items: center; gap: 8px; }
1020
+ .lang-pill { background: #383838; border: none; color: #fff; padding: 8px 14px; border-radius: 12px; display: flex; align-items: center; gap: 8px; font-family: inherit; font-size: 0.9rem; cursor: pointer; }
1021
+ .lang-pill:active { background: #444; }
1022
+ .btn-start-upload { width: 100%; background: var(--accent-orange); color: #fff; border: none; padding: 16px; border-radius: 16px; font-size: 1rem; font-weight: bold; font-family: inherit; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 10px; box-shadow: 0 8px 20px rgba(255, 112, 67, 0.35); }
1023
+ .btn-start-upload:active { transform: scale(0.97); }
static/js/api.js ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // متغیر وضعیت برای بازتولید زیرنویس
2
+ var isRegenerating = false;
3
+
4
+ async function startUpload() {
5
+ console.log("Upload started...");
6
+ const f = document.getElementById('fileIn').files[0];
7
+ if(!f) return;
8
+
9
+ if (!db) {
10
+ try {
11
+ console.log("Waiting for DB...");
12
+ await initDB();
13
+ } catch(e) {
14
+ alert("خطا در بارگذاری دیتابیس. لطفا صفحه را رفرش کنید.");
15
+ return;
16
+ }
17
+ }
18
+
19
+ document.getElementById('homeScreen').style.display = 'none';
20
+ document.getElementById('loader').style.display = 'flex';
21
+
22
+ // تنظیمات UI برای نمایش درصد آپلود
23
+ const titleEl = document.getElementById('loaderTitle');
24
+ const msgEl = document.getElementById('queueStatusMsg');
25
+ const progContainer = document.getElementById('uploadProgressContainer');
26
+ const progBar = document.getElementById('uploadProgressBar');
27
+
28
+ if (titleEl) titleEl.innerText = "در حال آپلود ویدیو...";
29
+ if (msgEl) msgEl.innerText = "لطفاً این پنجره را نبندید ☘️";
30
+ if (progContainer) progContainer.style.display = 'block';
31
+ if (progBar) progBar.style.width = '0%';
32
+
33
+ const txCheck = db.transaction("projects", "readwrite");
34
+ const storeCheck = txCheck.objectStore("projects");
35
+ const reqAll = storeCheck.getAll();
36
+
37
+ reqAll.onsuccess = async (e) => {
38
+ let projects = e.target.result;
39
+ projects.sort((a, b) => a.id - b.id);
40
+ if (projects.length >= 4) {
41
+ const toDelete = projects[0];
42
+ storeCheck.delete(toDelete.id);
43
+ }
44
+
45
+ const defaultName = `پروژه ${projects.length >= 4 ? 4 : projects.length + 1}`;
46
+ const fd = new FormData(); fd.append('file', f); fd.append('language', selectedLanguage || 'fa');
47
+
48
+ try {
49
+ // استفاده از XMLHttpRequest برای دریافت درصد پیشرفت واقعی
50
+ const uploadResponse = await new Promise((resolve, reject) => {
51
+ const xhr = new XMLHttpRequest();
52
+ xhr.open('POST', '/api/upload', true);
53
+
54
+ xhr.upload.onprogress = (event) => {
55
+ if (event.lengthComputable) {
56
+ const percentComplete = Math.round((event.loaded / event.total) * 100);
57
+ if (progBar) progBar.style.width = percentComplete + '%';
58
+ if (msgEl) msgEl.innerText = `در حال ارسال فایل: ${percentComplete}%`;
59
+ }
60
+ };
61
+
62
+ xhr.onload = () => {
63
+ if (xhr.status >= 200 && xhr.status < 300) {
64
+ resolve(JSON.parse(xhr.responseText));
65
+ } else {
66
+ reject(new Error("Server Error: " + xhr.status));
67
+ }
68
+ };
69
+
70
+ xhr.onerror = () => reject(new Error("Network Error occurred"));
71
+ xhr.send(fd);
72
+ });
73
+
74
+ // وقتی آپلود 100 درصد شد، پیام را تغییر می‌دهیم
75
+ if (progContainer) progContainer.style.display = 'none';
76
+ if (titleEl) titleEl.innerText = "در حال تولید زیرنویس...";
77
+ if (msgEl) msgEl.innerText = "هوش مصنوعی در حال پردازش صدا است...";
78
+
79
+ const taskId = uploadResponse.task_id;
80
+ let isCompleted = false;
81
+ let finalData = null;
82
+
83
+ // ایجاد حلقه برای چک کردن وضعیت پردازش بدون دخالت کاربر و بدون ذخیره کد پیگیری
84
+ while (!isCompleted) {
85
+ await new Promise(r => setTimeout(r, 2000)); // وقفه 2 ثانیه ای
86
+ const statusRes = await fetch(`/api/upload-status/${taskId}`);
87
+ if (!statusRes.ok) throw new Error("Status check failed");
88
+ const statusData = await statusRes.json();
89
+
90
+ if (statusData.status === 'completed') {
91
+ isCompleted = true;
92
+ finalData = statusData.result;
93
+ } else if (statusData.status === 'failed') {
94
+ throw new Error(statusData.error || "خطا در پردازش سرور");
95
+ }
96
+ }
97
+
98
+ const d = finalData;
99
+
100
+ state.id = d.file_id; state.w = d.width; state.h = d.height;
101
+ state.language = d.language || selectedLanguage || 'fa';
102
+ state.st = {
103
+ f: getDefaultFontForLang(state.language), fz: 45, col: '#ffffff', bg: '#000000',
104
+ type: 'solid', y: 150, x: 0, name: 'alpha_gradient',
105
+ radius: 24, paddingX: 5, paddingY: 15,
106
+ useActiveColor: true,
107
+ fadeUnread: false,
108
+ fadeSurrounding: false
109
+ };
110
+
111
+ let rawSegs = d.segments.map((s, idx) => ({
112
+ ...s,
113
+ id: s.id || `seg_${Date.now()}_${idx}_${Math.floor(Math.random() * 1000)}`,
114
+ isHidden: false
115
+ }));
116
+ rawSegs.sort((a, b) => a.start - b.start);
117
+
118
+ for(let i = 0; i < rawSegs.length - 1; i++) {
119
+ const curr = rawSegs[i]; const next = rawSegs[i+1];
120
+ if (curr.end > next.start) { curr.end = next.start;
121
+ if (curr.words) {
122
+ curr.words = curr.words.filter(w => w.start < curr.end);
123
+ if(curr.words.length > 0) {
124
+ let lastW = curr.words[curr.words.length - 1];
125
+ if(lastW.end > curr.end) lastW.end = curr.end;
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ // تضمین می‌کنیم که زمان‌های شروع و پایان سگمنت دقیقاً با اولین و آخرین کلمه‌اش منطبق باشند
132
+ rawSegs.forEach(seg => {
133
+ if (seg.words && seg.words.length > 0) {
134
+ seg.words.sort((a,b) => a.start - b.start);
135
+ seg.start = seg.words[0].start;
136
+ seg.end = seg.words[seg.words.length - 1].end;
137
+ }
138
+ });
139
+
140
+ state.segs = rawSegs;
141
+
142
+ const durSec = rawSegs.length > 0 ? rawSegs[rawSegs.length-1].end : 0;
143
+ let mm = Math.floor(durSec / 60);
144
+ let ss = Math.floor(durSec % 60);
145
+ const durStr = `${mm < 10 ? '0'+mm : mm}:${ss < 10 ? '0'+ss : ss}`;
146
+
147
+ const txSave = db.transaction("projects", "readwrite");
148
+ const newProject = {
149
+ id: Date.now(), name: defaultName, dateStr: getPersianDate(),
150
+ lastModified: Date.now(), videoBlob: f, state: JSON.parse(JSON.stringify(state)),
151
+ duration: durStr, thumbnail: null
152
+ };
153
+
154
+ txSave.objectStore("projects").add(newProject);
155
+ txSave.oncomplete = () => {
156
+ openProject(newProject.id);
157
+ // نمایش پیغام قشنگ هوش مصنوعی برای هر ویدیو جدیدی که آپلود میشه
158
+ setTimeout(() => {
159
+ if (typeof showAITooltip === 'function') {
160
+ showAITooltip();
161
+ }
162
+ }, 1000); // 1 ثانیه تاخیر برای رندر شدن محیط ادیتور
163
+ };
164
+
165
+ } catch(e) {
166
+ console.error(e);
167
+ alert("خطا در آپلود یا پردازش: " + e.message); loadHome();
168
+ } finally {
169
+ document.getElementById('loader').style.display='none';
170
+ document.getElementById('fileIn').value = '';
171
+ document.getElementById('queueStatusMsg').innerText = "";
172
+ }
173
+ };
174
+ }
175
+
176
+ // تابع جدید: منطق بازتولید زیرنویس با استفاده از Blob ذخیره شده
177
+ async function regenerateProjectSubtitles() {
178
+ isRegenerating = true; // فعال کردن فلگ وضعیت
179
+
180
+ // 1. تغییر وضعیت UI شیت به حالت لودینگ
181
+ const sheet = document.getElementById('sheet-regenerate');
182
+ const stepStart = document.getElementById('regen-step-start');
183
+ const stepLoad = document.getElementById('regen-step-loading');
184
+ const stepSuccess = document.getElementById('regen-step-success');
185
+
186
+ stepStart.style.display = 'none';
187
+ stepLoad.style.display = 'flex';
188
+ // اطمینان از باز بودن شیت
189
+ document.getElementById('sheetOverlay').classList.add('show');
190
+ sheet.classList.add('active');
191
+
192
+ try {
193
+ // 2. دریافت فایل ویدیو از دیتابیس
194
+ const videoBlob = await getVideoBlobFromDB(currentProjectId);
195
+ if (!videoBlob) throw new Error("فایل ویدیو در دیتابیس یافت نشد");
196
+
197
+ // 3. ارسال به سرور برای پردازش مجدد
198
+ const fd = new FormData();
199
+ fd.append('file', videoBlob);
200
+ fd.append('language', state.language || 'fa');
201
+
202
+ // ارسال درخواست و دریافت task_id (مانند سیستم آپلود جدید)
203
+ const r = await fetch('/api/upload', {method:'POST', body:fd});
204
+ if (!r.ok) throw new Error("خطای سرور: " + r.status);
205
+ const uploadResponse = await r.json();
206
+
207
+ const taskId = uploadResponse.task_id;
208
+ if (!taskId) throw new Error("مشکل در ارتباط با سرور");
209
+
210
+ let isCompleted = false;
211
+ let finalData = null;
212
+
213
+ // 4. حلقه بررسی وضعیت پردازش در پس‌زمینه
214
+ while (!isCompleted) {
215
+ await new Promise(res => setTimeout(res, 2000)); // وقفه 2 ثانیه ای
216
+ const statusRes = await fetch(`/api/upload-status/${taskId}`);
217
+ if (!statusRes.ok) throw new Error("خطا در بررسی وضعیت");
218
+ const statusData = await statusRes.json();
219
+
220
+ if (statusData.status === 'completed') {
221
+ isCompleted = true;
222
+ finalData = statusData.result;
223
+ } else if (statusData.status === 'failed') {
224
+ throw new Error(statusData.error || "خطا در پردازش سرور");
225
+ }
226
+ }
227
+
228
+ const d = finalData;
229
+
230
+ // 5. پردازش سگمنت‌های جدید
231
+ let rawSegs = d.segments.map((s, idx) => ({
232
+ ...s,
233
+ id: s.id || `seg_${Date.now()}_${idx}_${Math.floor(Math.random() * 1000)}`,
234
+ isHidden: false
235
+ }));
236
+ rawSegs.sort((a, b) => a.start - b.start);
237
+
238
+ for(let i = 0; i < rawSegs.length - 1; i++) {
239
+ const curr = rawSegs[i]; const next = rawSegs[i+1];
240
+ if (curr.end > next.start) { curr.end = next.start;
241
+ if (curr.words) {
242
+ curr.words = curr.words.filter(w => w.start < curr.end);
243
+ if(curr.words.length > 0) {
244
+ let lastW = curr.words[curr.words.length - 1];
245
+ if(lastW.end > curr.end) lastW.end = curr.end;
246
+ }
247
+ }
248
+ }
249
+ }
250
+
251
+ // تضمین می‌کنیم که زمان‌های شروع و پایان سگمنت دقیقاً با اولین و آخرین کلمه‌اش منطبق باشند
252
+ rawSegs.forEach(seg => {
253
+ if (seg.words && seg.words.length > 0) {
254
+ seg.words.sort((a,b) => a.start - b.start);
255
+ seg.start = seg.words[0].start;
256
+ seg.end = seg.words[seg.words.length - 1].end;
257
+ }
258
+ });
259
+
260
+ state.id = d.file_id;
261
+ state.segs = rawSegs;
262
+
263
+ // 6. ذخیره و رندر مجدد
264
+ renderSegList();
265
+ upd();
266
+ saveProjectToDB();
267
+
268
+ // 7. اتمام موفقیت آمیز
269
+ isRegenerating = false; // غیرفعال کردن فلگ
270
+
271
+ // نمایش انیمیشن تیک داخل شیت برای لحظه‌ای کوتاه
272
+ stepLoad.style.display = 'none';
273
+ stepSuccess.style.display = 'flex';
274
+
275
+ setTimeout(() => {
276
+ // بستن شیت
277
+ closeAllSheets();
278
+
279
+ // نمایش Toast زیبا در بالا
280
+ if (typeof showToast === 'function') {
281
+ showToast("زیرنویس مجدد تولید شد!", "fa-solid fa-circle-check");
282
+ }
283
+
284
+ // ریست کردن مراحل شیت برای دفعه بعد
285
+ setTimeout(() => {
286
+ stepSuccess.style.display = 'none';
287
+ stepStart.style.display = 'flex';
288
+ }, 400);
289
+ }, 1200);
290
+
291
+ } catch (e) {
292
+ console.error(e);
293
+ isRegenerating = false;
294
+ alert("خطا در بازتولید زیرنویس: " + e.message);
295
+ // بازگشت به حالت اول
296
+ stepLoad.style.display = 'none';
297
+ stepStart.style.display = 'flex';
298
+ }
299
+ }
300
+
301
+ async function generateAIStyle() {
302
+ const desc = document.getElementById('magicPrompt').value.trim();
303
+ if (!desc) {
304
+ showToast("لطفاً اول توضیح بدید چه استایلی می‌خواید", "fa-solid fa-circle-exclamation");
305
+ return;
306
+ }
307
+ const btn = document.querySelector('.btn-magic-action');
308
+ const originalText = btn.innerHTML;
309
+ btn.innerHTML = '<div class="spinner" style="width:20px;height:20px;border-width:3px;"></div> در حال طراحی...';
310
+ btn.disabled = true;
311
+
312
+ try {
313
+ const r = await fetch('/api/generate-style', {
314
+ method: 'POST',
315
+ headers: { 'Content-Type': 'application/json' },
316
+ body: JSON.stringify({ description: desc })
317
+ });
318
+ if (!r.ok) throw new Error('Server returned an error');
319
+ const data = await r.json();
320
+ const design = data.design || data; // سازگاری با پاسخ‌های قدیمی‌تر
321
+
322
+ // اعمال طراحی جدید هوش مصنوعی: این یک استایل کاملاً مجزا و پویا است
323
+ state.st.name = 'ai_dynamic';
324
+ state.st.ai_design = design;
325
+ state.st.col = design.primaryColor;
326
+ state.st.f = design.font;
327
+ state.st.fz = parseInt(design.fontSize, 10) || 60;
328
+ state.st.type = 'transparent';
329
+
330
+ syncUIWithState();
331
+
332
+ document.querySelectorAll('.font-btn').forEach(b => b.classList.remove('ticked'));
333
+ const fontBtn = Array.from(document.querySelectorAll('.font-btn')).find(b => b.getAttribute('onclick').includes(`'${state.st.f}'`));
334
+ if (fontBtn) fontBtn.classList.add('ticked');
335
+
336
+ upd();
337
+ toggleTool('style');
338
+
339
+ if (data.success === false) {
340
+ // هوش مصنوعی واقعاً پاسخ نداد؛ یک طراحی نمونه اعمال شد ولی صادقانه به کاربر اطلاع می‌دهیم
341
+ console.error('AI style generation failed, fallback used:', data.error);
342
+ showToast("ارتباط با هوش مصنوعی برقرار نشد؛ یک طراحی نمونه اعمال شد. لطفاً دوباره امتحان کنید.", "fa-solid fa-triangle-exclamation");
343
+ } else {
344
+ showToast("طراحی هوشمند جدید اعمال شد! 🎨", "fa-solid fa-wand-magic-sparkles");
345
+ }
346
+
347
+ } catch (e) {
348
+ console.error(e);
349
+ showToast("خطا در ارتباط با سرور. لطفاً دوباره تلاش کنید.", "fa-solid fa-triangle-exclamation");
350
+ } finally {
351
+ btn.innerHTML = originalText;
352
+ btn.disabled = false;
353
+ }
354
+ }
355
+
356
+
357
+ async function render() {
358
+ v.pause(); togglePlayIcon(false);
359
+ document.getElementById('loader').style.display='flex';
360
+ const statusMsg = document.getElementById('queueStatusMsg');
361
+ statusMsg.innerText = "در حال ارسال به صف...";
362
+
363
+ const activeSegments = state.segs.filter(s => !s.isHidden);
364
+
365
+ // پاکسازی داده‌ها برای ارسال به سرور (رفع خطای 422)
366
+ const cleanSegments = activeSegments.map((s, idx) => ({
367
+ id: s.id || `seg_${Date.now()}_${idx}_${Math.floor(Math.random() * 1000)}`,
368
+ start: s.start,
369
+ end: s.end,
370
+ text: s.text,
371
+ words: s.words ? s.words.map(w => ({
372
+ word: w.word,
373
+ start: w.start,
374
+ end: w.end,
375
+ highlight: w.highlight || false,
376
+ color: w.color || null // ارسال رنگ فقط در صورت وجود
377
+ })) : []
378
+ }));
379
+
380
+ // اطمینان از وجود ابعاد ویدیو
381
+ let finalW = parseInt(state.w) || 0;
382
+ let finalH = parseInt(state.h) || 0;
383
+
384
+ // اگر ابعاد صفر بود، سعی کن از ویدیو پلیر بگیری
385
+ if ((finalW === 0 || finalH === 0) && v) {
386
+ finalW = v.videoWidth || 1080;
387
+ finalH = v.videoHeight || 1920;
388
+ }
389
+
390
+ const pl = {
391
+ file_id: state.id,
392
+ segments: cleanSegments,
393
+ video_width: finalW,
394
+ video_height: finalH,
395
+ style: {
396
+ font: state.st.f,
397
+ fontSize: state.st.fz,
398
+ primaryColor: state.st.col,
399
+ outlineColor: state.st.bg,
400
+ backType: state.st.type,
401
+ marginV: state.st.y,
402
+ x: state.st.x || 0,
403
+ name: state.st.name,
404
+ radius: state.st.radius || 16,
405
+ paddingX: state.st.paddingX || 20,
406
+ paddingY: state.st.paddingY || 10,
407
+ useActiveColor: ((state.st.styleActiveWordToggles && state.st.styleActiveWordToggles[state.st.name]) !== false),
408
+ fadeUnread: state.st.fadeUnread !== false,
409
+ fadeSurrounding: state.st.fadeSurrounding || false,
410
+ typewriter: state.st.typewriter || false,
411
+ styleBgColors: state.st.styleBgColors || {},
412
+ styleColors: state.st.styleColors || {},
413
+ styleActiveColors: state.st.styleActiveColors || {},
414
+ language: state.language || 'fa',
415
+ ai_design: state.st.ai_design || null
416
+ }
417
+ };
418
+
419
+ try {
420
+ let r = await fetch('/api/enqueue-render', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(pl)});
421
+ let d = await r.json();
422
+
423
+ if (d.error_code && d.error_code === 'VIDEO_NOT_FOUND') {
424
+ statusMsg.innerText = "ویدیو در سرور یافت نشد، در حال بارگذاری مجدد...";
425
+ const videoBlob = await getVideoBlobFromDB(currentProjectId);
426
+ const fd = new FormData(); fd.append('file', videoBlob); fd.append('file_id', state.id);
427
+ const reuploadResponse = await fetch('/api/reupload', { method: 'POST', body: fd });
428
+ if (!reuploadResponse.ok) throw new Error('خطا در بارگذاری مجدد ویدیو.');
429
+ statusMsg.innerText = "بارگذاری مجدد موفق بود، ارسال دوباره به صف...";
430
+ r = await fetch('/api/enqueue-render', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(pl)});
431
+ d = await r.json();
432
+ }
433
+
434
+ if (d.job_id) { pollStatus(d.job_id); }
435
+ else { throw new Error(d.error || "خطا لطفاً این پروژه را حذف و ویدیو را مجدداً آپلود کنید."); }
436
+
437
+ } catch(e) {
438
+ console.error(e);
439
+ alert('خطا: ' + e.message); document.getElementById('loader').style.display='none';
440
+ }
441
+ }
442
+
443
+ function pollStatus(jobId) {
444
+ const statusMsg = document.getElementById('queueStatusMsg');
445
+ const interval = setInterval(async () => {
446
+ try {
447
+ const r = await fetch(`/api/job-status/${jobId}`);
448
+ const d = await r.json();
449
+ if (d.status === 'queued') statusMsg.innerHTML = `شما نفر <span style="color:#00e676; font-size:1.3em;">${d.queue_position}</span> در صف ساخت هستید...`;
450
+ else if (d.status === 'processing') statusMsg.innerText = "نوبت شماست! در حال ساخت ویدیو...";
451
+ else if (d.status === 'completed') { clearInterval(interval); showFinalResult(d.url); }
452
+ else if (d.status === 'failed') { clearInterval(interval); alert("خطا در ساخت ویدیو: " + d.error); document.getElementById('loader').style.display='none'; }
453
+ } catch (e) { console.error("خطا در چک کردن وضعیت", e); }
454
+ }, 2500);
455
+ }
456
+
457
+ function showFinalResult(url) {
458
+ document.getElementById('loader').style.display='none';
459
+ const resVid = document.getElementById('resultVideo');
460
+ resVid.src = url + "?t=" + new Date().getTime();
461
+ resVid.load();
462
+ const fullUrl = new URL(url, window.location.origin).href;
463
+ const dlBtn = document.getElementById('downloadBtn');
464
+ dlBtn.href = fullUrl;
465
+ dlBtn.onclick = function(e) {
466
+ e.preventDefault();
467
+ window.parent.postMessage({ type: 'DOWNLOAD_REQUEST', url: fullUrl }, '*');
468
+ };
469
+ document.getElementById('resultScreen').style.display='flex';
470
+ resVid.play();
471
+ }
472
+
473
+ function closeResult() { document.getElementById('resultScreen').style.display='none'; const rv = document.getElementById('resultVideo'); rv.pause(); rv.src = ""; }
static/js/db.js ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // مدیریت IndexedDB
2
+ function initDB() {
3
+ return new Promise((resolve, reject) => {
4
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
5
+ request.onerror = e => reject(e);
6
+ request.onupgradeneeded = e => {
7
+ const db = e.target.result;
8
+ if (!db.objectStoreNames.contains("projects")) {
9
+ db.createObjectStore("projects", { keyPath: "id" });
10
+ }
11
+ };
12
+ request.onsuccess = e => {
13
+ db = e.target.result;
14
+ resolve(db);
15
+ };
16
+ });
17
+ }
18
+
19
+ function saveProjectToDB() {
20
+ if (!currentProjectId) return;
21
+
22
+ const tx = db.transaction("projects", "readwrite");
23
+ const store = tx.objectStore("projects");
24
+
25
+ store.get(currentProjectId).onsuccess = (e) => {
26
+ const data = e.target.result;
27
+ if (data) {
28
+ data.state = state;
29
+ if(v && v.readyState >= 2) {
30
+ try {
31
+ const canvas = document.createElement('canvas');
32
+ canvas.width = 200; canvas.height = 350;
33
+ const ctx = canvas.getContext('2d');
34
+ ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
35
+ data.thumbnail = canvas.toDataURL('image/jpeg', 0.5);
36
+ } catch(err) {}
37
+ }
38
+ store.put(data);
39
+ }
40
+ };
41
+ }
42
+
43
+ function getVideoBlobFromDB(projectId) {
44
+ return new Promise((resolve, reject) => {
45
+ if (!db) return reject("Database not initialized.");
46
+ const tx = db.transaction("projects", "readonly");
47
+ const store = tx.objectStore("projects");
48
+ const request = store.get(projectId);
49
+ request.onsuccess = e => {
50
+ const projectData = e.target.result;
51
+ if (projectData && projectData.videoBlob) resolve(projectData.videoBlob);
52
+ else reject("Video blob not found.");
53
+ };
54
+ request.onerror = e => reject("Error fetching from DB.");
55
+ });
56
+ }
static/js/editor.js ADDED
The diff for this file is too large to render. See raw diff
 
static/js/globals.js ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // متغیرهای دیتابیس
2
+ const DB_NAME = "SubtitleStudioDB";
3
+ const DB_VERSION = 1;
4
+ let db;
5
+ let currentProjectId = null;
6
+ let currentActionProjectId = null;
7
+ let currentActionProjectName = "";
8
+
9
+ // وضعیت سراسری برنامه
10
+ let state = {
11
+ id: null, w: 1080, h: 1920, segs: [], language: 'fa',
12
+ st: { f: 'pinar', fz: 45, col: '#A020F0', bg: '#000000', type: 'solid', y: 150, x: 0, name: 'alpha_gradient', radius: 24, paddingX: 5, paddingY: 15, useActiveColor: true }
13
+ };
14
+ let curSeg = -1;
15
+ let manualOverride = false;
16
+
17
+ // --- چندزبانه (Multi-language) ---
18
+ // لیست پیش‌فرض (در صورت عدم دسترسی به سرور موقع بارگذاری صفحه از همین استفاده می‌شود)
19
+ let LANGUAGES_LIST = [
20
+ { code: 'fa', label: 'فارسی', flag: '🇮🇷', rtl: true },
21
+ { code: 'en', label: 'English', flag: '🇺🇸', rtl: false },
22
+ { code: 'ar', label: 'العربية', flag: '🇸🇦', rtl: true },
23
+ { code: 'tr', label: 'Türkçe', flag: '🇹🇷', rtl: false },
24
+ { code: 'es', label: 'Español', flag: '🇪🇸', rtl: false },
25
+ { code: 'fr', label: 'Français', flag: '🇫🇷', rtl: false },
26
+ { code: 'de', label: 'Deutsch', flag: '🇩🇪', rtl: false },
27
+ { code: 'it', label: 'Italiano', flag: '🇮🇹', rtl: false },
28
+ { code: 'pt', label: 'Português', flag: '🇵🇹', rtl: false },
29
+ { code: 'ur', label: 'اردو', flag: '🇵🇰', rtl: true },
30
+ ];
31
+ let selectedLanguage = 'fa'; // زبانی که کاربر برای پروژه بعدی انتخاب کرده
32
+ let pendingSelectedVideoFile = null; // فایل انتخاب‌شده پیش از شروع آپلود
33
+
34
+ function getLangInfo(code) {
35
+ return LANGUAGES_LIST.find(l => l.code === code) || LANGUAGES_LIST[0];
36
+ }
37
+
38
+ function getDefaultFontForLang(code) {
39
+ if (code === 'fa') return 'pinar';
40
+ if (code === 'ar') return 'amiri';
41
+ if (code === 'ur') return 'nastaliq';
42
+ return 'vazir';
43
+ }
44
+
45
+ async function initLanguagesList() {
46
+ try {
47
+ const r = await fetch('/api/languages');
48
+ if (r.ok) {
49
+ const d = await r.json();
50
+ if (d.languages && d.languages.length > 0) {
51
+ LANGUAGES_LIST = d.languages;
52
+ }
53
+ }
54
+ } catch (e) { console.warn('Could not fetch languages list, using defaults', e); }
55
+ renderLanguageList();
56
+ updateLanguageButtons();
57
+ }
58
+
59
+ function renderLanguageList() {
60
+ const box = document.getElementById('languageList');
61
+ if (!box) return;
62
+ box.innerHTML = LANGUAGES_LIST.map(l => `
63
+ <div class="lang-row ${l.code === selectedLanguage ? 'active' : ''}" onclick="selectLanguage('${l.code}')">
64
+ <span class="lang-flag">${l.flag}</span>
65
+ <span class="lang-name">${l.label}</span>
66
+ ${l.code === selectedLanguage ? '<i class="fa-solid fa-circle-check lang-check"></i>' : ''}
67
+ </div>
68
+ `).join('');
69
+ }
70
+
71
+ function selectLanguage(code) {
72
+ selectedLanguage = code;
73
+ renderLanguageList();
74
+ updateLanguageButtons();
75
+ closeLanguageSheet();
76
+ }
77
+
78
+ function updateLanguageButtons() {
79
+ const info = getLangInfo(selectedLanguage);
80
+ const homeFlag = document.getElementById('homeLangFlag');
81
+ const homeLabel = document.getElementById('homeLangLabel');
82
+ if (homeFlag) homeFlag.innerText = info.flag;
83
+ if (homeLabel) homeLabel.innerText = info.label;
84
+
85
+ const pillFlag = document.getElementById('langPillFlag');
86
+ const pillLabel = document.getElementById('langPillLabel');
87
+ if (pillFlag) pillFlag.innerText = info.flag;
88
+ if (pillLabel) pillLabel.innerText = info.label;
89
+ }
90
+
91
+ function openLanguageSheet() {
92
+ renderLanguageList();
93
+ document.getElementById('languageSheetOverlay').style.display = 'flex';
94
+ }
95
+
96
+ function closeLanguageSheet() {
97
+ document.getElementById('languageSheetOverlay').style.display = 'none';
98
+ }
99
+
100
+ function formatFileSize(bytes) {
101
+ if (!bytes) return '۰ مگابایت';
102
+ const mb = bytes / (1024 * 1024);
103
+ if (mb < 1) return Math.round(bytes / 1024) + ' کیلوبایت';
104
+ return mb.toFixed(1) + ' مگابایت';
105
+ }
106
+
107
+ // وقتی کاربر ویدیویی را از گالری/فایل‌ها انتخاب می‌کند: بلافاصله آپلود شروع نمی‌شود
108
+ // بلکه کارت پیش‌نمایش با انتخاب زبان نمایش داده می‌شود تا کاربر تایید کند
109
+ function onVideoFileChosen() {
110
+ const inp = document.getElementById('fileIn');
111
+ const f = inp.files && inp.files[0];
112
+ if (!f) return;
113
+ pendingSelectedVideoFile = f;
114
+
115
+ document.getElementById('preUploadFileName').innerText = f.name;
116
+ document.getElementById('preUploadFileSize').innerText = formatFileSize(f.size);
117
+ updateLanguageButtons();
118
+
119
+ const overlay = document.getElementById('preUploadOverlay');
120
+ overlay.style.display = 'flex';
121
+ setTimeout(() => overlay.classList.add('show'), 10);
122
+ }
123
+
124
+ function cancelPreUpload() {
125
+ pendingSelectedVideoFile = null;
126
+ document.getElementById('fileIn').value = '';
127
+ const overlay = document.getElementById('preUploadOverlay');
128
+ overlay.classList.remove('show');
129
+ setTimeout(() => overlay.style.display = 'none', 250);
130
+ }
131
+
132
+ function confirmStartUpload() {
133
+ const overlay = document.getElementById('preUploadOverlay');
134
+ overlay.classList.remove('show');
135
+ setTimeout(() => overlay.style.display = 'none', 250);
136
+ startUpload();
137
+ }
138
+
139
+ // --- ترجمه متن نمونه در قالب‌های آماده (سلام اینجا آلفا است) ---
140
+ const DEMO_WORDS = {
141
+ fa: ["سلام", "اینجا", "آلفا", "است"],
142
+ en: ["Hi", "this", "is", "Alpha"],
143
+ ar: ["مرحباً", "هنا", "ألفا", "الآن"],
144
+ tr: ["Merhaba", "burada", "Alfa", "şimdi"],
145
+ es: ["Hola", "aquí", "Alfa", "ahora"],
146
+ fr: ["Salut", "ici", "Alpha", "maintenant"],
147
+ de: ["Hallo", "hier", "Alpha", "jetzt"],
148
+ it: ["Ciao", "qui", "Alfa", "adesso"],
149
+ pt: ["Oi", "aqui", "Alfa", "agora"],
150
+ ur: ["ہیلو", "یہاں", "الفا", "ہے"],
151
+ };
152
+
153
+ function applyDemoLanguage(langCode) {
154
+ const info = getLangInfo(langCode);
155
+ const words = DEMO_WORDS[langCode] || DEMO_WORDS.fa;
156
+ const isRtl = info.rtl;
157
+
158
+ // چیدمان راست‌به‌چپ/چپ‌به‌راست کارت‌های پیش‌نمایش قالب‌ها بر اساس زبان پروژه
159
+ document.querySelectorAll('.demo-row').forEach(row => {
160
+ row.style.direction = isRtl ? 'rtl' : 'ltr';
161
+ });
162
+ // کلمه‌های تکی
163
+ document.querySelectorAll('[data-dw]').forEach(el => {
164
+ const idx = parseInt(el.getAttribute('data-dw'), 10);
165
+ if (!isNaN(idx) && words[idx] !== undefined) el.textContent = words[idx];
166
+ });
167
+ // بازه‌ای از کلمات (مثلاً کلمات ۱ تا ۳ با هم در یک span)
168
+ document.querySelectorAll('[data-dw-range]').forEach(el => {
169
+ const range = el.getAttribute('data-dw-range').split('-').map(Number);
170
+ el.textContent = words.slice(range[0], range[1] + 1).join(' ');
171
+ });
172
+ // عبارت کامل در یک تکه (بدون نیاز به تغییر جهت چون یک متن یکپارچه است)
173
+ document.querySelectorAll('.demo-full').forEach(el => {
174
+ el.textContent = words.join(' ');
175
+ });
176
+ }
177
+
178
+ function aiEase(name, p) {
179
+ p = Math.max(0, Math.min(1, p));
180
+ if (name === 'linear') return p;
181
+ if (name === 'punch') return 1 - Math.pow(1 - p, 3);
182
+ if (name === 'elastic') {
183
+ if (p <= 0 || p >= 1) return p;
184
+ return Math.pow(2, -10 * p) * Math.sin((p * 10 - 0.75) * (2 * Math.PI) / 3) + 1;
185
+ }
186
+ // smooth (پیش‌فرض)
187
+ return 1 - (1 - p) * (1 - p);
188
+ }
189
+
190
+ function applyEditorTextDirection(langCode) {
191
+ const info = getLangInfo(langCode);
192
+ const dir = info.rtl ? 'rtl' : 'ltr';
193
+ const activeTextEl = document.getElementById('activeText');
194
+ if (activeTextEl) activeTextEl.dir = dir;
195
+ const timelineEl = document.getElementById('timelineScroll');
196
+ if (timelineEl) timelineEl.dir = dir;
197
+ }
198
+
199
+ // المان‌های رابط کاربری
200
+ const v = document.getElementById('vid');
201
+ const tEl = document.getElementById('activeText');
202
+ const toolsContainer = document.getElementById('toolsContainer');
203
+
204
+ // متغیرهای ویرایش متن
205
+ let activeWordId = null; // Format: "segIndex-wordIndex"
206
+ let previewInterval;
207
+ let tempStartTime = 0;
208
+ let tempEndTime = 0;
209
+
210
+ // متغیرهای تاچ
211
+ let initialY = 0, initialX = 0, initialBottom = 0, initialXState = 0, initialDist = 0, initialFontSize = 0, touchMode = null;
212
+
213
+ // --- تابع اصلی نمایش زیرنویس در مرورگر (با منطق جدید) ---
214
+ function updateOverlayContent(currentTime) {
215
+ if (!state.segs) return;
216
+ const idx = state.segs.findIndex(s => currentTime >= s.start && currentTime < s.end);
217
+ if(idx !== -1) {
218
+ const seg = state.segs[idx];
219
+ if(seg.isHidden) { tEl.style.opacity = 0; }
220
+ else {
221
+ tEl.style.opacity = 1;
222
+
223
+ // ==========================================================
224
+ // === بلوک ۱: منطق قدیمی برای استایل‌های کارائوکه (دست‌نخورده) ===
225
+ // ==========================================================
226
+ const karaokeStyles = ['auto_director', 'karaoke_static', 'instagram_box', 'alpha_gradient'];
227
+ if(karaokeStyles.includes(state.st.name) && seg.words) {
228
+ let html = "";
229
+ const GAP = 5;
230
+
231
+ seg.words.forEach((w, i) => {
232
+ let isActive = (currentTime >= w.start && currentTime <= w.end);
233
+
234
+ let boxColor = state.st.col;
235
+ let textColor = '#ffffff';
236
+
237
+ if (state.st.name === 'instagram_box') {
238
+ boxColor = '#1a1a1a';
239
+ if (isActive) { textColor = '#ffffff'; }
240
+ else if (currentTime > w.end) { textColor = '#000000'; }
241
+ else { textColor = '#d1d1d6'; }
242
+ } else if (state.st.name === 'alpha_gradient') {
243
+ boxColor = 'linear-gradient(135deg, #7c4dff, #b388ff)';
244
+ if (isActive) { textColor = '#ffffff'; }
245
+ else if (currentTime > w.end) { textColor = '#ffffff'; }
246
+ else { textColor = 'rgba(255,255,255,0.35)'; }
247
+ } else if (state.st.name === 'auto_director') {
248
+ boxColor = (i % 2 === 0 ? '#00D7FF' : '#FF0080');
249
+ }
250
+
251
+ if (w.color) textColor = w.color;
252
+ let baseStyle = "display: inline-block; vertical-align: middle; transition: all 0.1s; font-family: inherit;";
253
+
254
+ if(isActive) {
255
+ let py = state.st.paddingY; let px = state.st.paddingX;
256
+ html += `<span style="${baseStyle} background: ${boxColor}; color: ${textColor} !important; border-radius: ${state.st.radius}px; box-shadow: 0 0 10px rgba(0,0,0,0.3); padding: ${py}px ${px}px; margin: -${py}px calc(${GAP}px - ${px}px); position: relative; z-index: 100; white-space: nowrap;">${w.word}</span>`;
257
+ } else {
258
+ html += `<span style="${baseStyle} color: ${textColor} !important; text-shadow: none; margin: 0 ${GAP}px; padding: 0; position: relative; z-index: 1;">${w.word}</span>`;
259
+ }
260
+
261
+ if ((i + 1) % 5 === 0 && i !== seg.words.length - 1) { html += "<br><br>"; }
262
+ });
263
+ tEl.innerHTML = html;
264
+ }
265
+
266
+ // =================================================================
267
+ // === بلوک ۲: منطق قدیمی برای استایل تایپی (Progressive) (دست‌نخورده) ===
268
+ // =================================================================
269
+ else if (state.st.name === 'progressive_write' && seg.words) {
270
+ let html = "";
271
+ seg.words.forEach((w, i) => {
272
+ let styleExtra = w.color ? `color:${w.color} !important;` : "";
273
+ let baseStyle = "display: inline-block; vertical-align: middle; margin: 0px 3px;";
274
+ if(currentTime >= w.start) html += `<span style="${baseStyle} opacity:1; ${styleExtra}">${w.word}</span> `;
275
+ else html += `<span style="${baseStyle} opacity:0; ${styleExtra}">${w.word}</span> `;
276
+ if ((i + 1) % 5 === 0 && i !== seg.words.length - 1) { html += "<br><br>"; }
277
+ });
278
+ tEl.innerHTML = html.trim();
279
+ }
280
+
281
+ // ============================================================================
282
+ // === بلوک ۳: منطق جدید و هوشمند برای موزیکال و استایل‌های آینده (کد جدید) ===
283
+ // ============================================================================
284
+ else if (STYLE_TEMPLATES && STYLE_TEMPLATES[state.st.name]) {
285
+ const template = STYLE_TEMPLATES[state.st.name];
286
+ // برای استایل‌هایی مثل موزیک پلیر، کل متن سگمنت را جایگزین می‌کنیم
287
+ const renderedHtml = template.replace(/{{WORD}}/g, seg.text);
288
+ tEl.innerHTML = renderedHtml;
289
+ }
290
+
291
+ // =========================================================
292
+ // === بلوک ۴: منطق نهایی برای استایل‌های کلاسیک (دست‌نخورده) ===
293
+ // =========================================================
294
+ else {
295
+ if (seg.words && seg.words.length > 0) {
296
+ let html = "";
297
+ seg.words.forEach((w, i) => {
298
+ let styleExtra = w.color ? `color:${w.color} !important;` : "";
299
+ html += `<span style="${styleExtra}">${w.word}</span> `;
300
+ if ((i + 1) % 5 === 0 && i !== seg.words.length - 1) { html += "<br><br>"; }
301
+ });
302
+ tEl.innerHTML = html;
303
+ } else {
304
+ tEl.innerText = seg.text;
305
+ }
306
+ }
307
+ }
308
+ } else { tEl.style.opacity = 0; }
309
+ }
310
+
311
+ function upd() {
312
+ saveProjectToDB();
313
+
314
+ const elFz = document.getElementById('fz');
315
+ if(elFz) state.st.fz = Math.round(10 + (parseFloat(elFz.value) / 100) * 140);
316
+ const elPos = document.getElementById('pos');
317
+ if(elPos) state.st.y = Math.round((parseFloat(elPos.value) / 100) * 1200);
318
+ const elPosX = document.getElementById('posX');
319
+ if(elPosX) state.st.x = Math.round((parseFloat(elPosX.value) / 100) * 500);
320
+ const elRadius = document.getElementById('radius');
321
+ if(elRadius) state.st.radius = parseInt(elRadius.value, 10);
322
+ const elPadX = document.getElementById('paddingX');
323
+ if(elPadX) state.st.paddingX = parseInt(elPadX.value, 10);
324
+ const elPadY = document.getElementById('paddingY');
325
+ if(elPadY) state.st.paddingY = parseInt(elPadY.value, 10);
326
+
327
+ updateColorPreviewButtons();
328
+
329
+ let font = 'Vazirmatn';
330
+ if(state.st.f === 'lalezar') font = 'Lalezar';
331
+ if(state.st.f === 'bangers') font = 'Impact';
332
+ if(state.st.f === 'roboto') font = 'Arial';
333
+
334
+ tEl.style.fontFamily = font;
335
+ tEl.style.fontSize = state.st.fz + 'px';
336
+ tEl.style.lineHeight = '0.8';
337
+ tEl.style.bottom = state.st.y + 'px';
338
+ tEl.style.textAlign = 'center';
339
+ tEl.style.left = '50%';
340
+ tEl.style.transform = `translateX(calc(-50% + ${state.st.x || 0}px))`;
341
+ tEl.style.padding = '0';
342
+ tEl.style.borderRadius = '0px';
343
+
344
+ const karaokeStyles = ['karaoke_static', 'auto_director', 'alpha_gradient'];
345
+
346
+ if (state.st.name === 'instagram_box') {
347
+ tEl.style.backgroundColor = '#ffffff';
348
+ tEl.style.color = '#d1d1d6';
349
+ tEl.style.webkitTextStroke = '0px';
350
+ tEl.style.textShadow = 'none';
351
+
352
+ const finalPaddingTop = (state.st.paddingY || 10) + 4;
353
+ const finalPaddingBottom = (state.st.paddingY || 10) + 4;
354
+ const finalPaddingX = (state.st.paddingX || 20) + 15;
355
+
356
+ tEl.style.padding = `${finalPaddingTop}px ${finalPaddingX}px ${finalPaddingBottom}px ${finalPaddingX}px`;
357
+ tEl.style.borderRadius = `${state.st.radius || 16}px`;
358
+
359
+ } else if(karaokeStyles.includes(state.st.name)) {
360
+ tEl.style.backgroundColor = 'transparent'; tEl.style.color = '#FFFFFF'; tEl.style.webkitTextStroke = '0px'; tEl.style.textShadow = 'none';
361
+ } else if (state.st.name === 'plain_white' || state.st.name === 'white_outline') {
362
+ tEl.style.color = '#FFFFFF'; tEl.style.backgroundColor = 'transparent'; tEl.style.webkitTextStroke = (state.st.name === 'white_outline') ? `${Math.max(3, state.st.fz / 4.5)}px #000000` : '0px'; tEl.style.paintOrder = 'stroke fill'; tEl.style.webkitPaintOrder = 'stroke fill'; tEl.style.textShadow = 'none';
363
+ } else {
364
+ if(!state.st.col) state.st.col = '#FFFFFF'; if(!state.st.bg) state.st.bg = '#000000';
365
+ tEl.style.color = state.st.col;
366
+
367
+ tEl.style.padding = `${state.st.paddingY}px ${state.st.paddingX}px`;
368
+ tEl.style.borderRadius = `${state.st.radius}px`;
369
+
370
+ if(state.st.type === 'solid' || state.st.type === 'transparent') {
371
+ let bgColor = state.st.bg;
372
+ if (state.st.type === 'transparent') {
373
+ if(bgColor.startsWith('#') && bgColor.length === 7) {
374
+ let r = parseInt(bgColor.substring(1, 3), 16); let g = parseInt(bgColor.substring(3, 5), 16); let b = parseInt(bgColor.substring(5, 7), 16);
375
+ bgColor = `rgba(${r},${g},${b},0.6)`;
376
+ } else if(bgColor.startsWith('rgba')) {
377
+ bgColor = bgColor.replace(/[\d.]+\)$/, '0.6)');
378
+ }
379
+ }
380
+ tEl.style.backgroundColor = bgColor;
381
+ tEl.style.webkitTextStroke = '0px'; tEl.style.textShadow = 'none';
382
+ } else if (state.st.type === 'outline') {
383
+ tEl.style.backgroundColor = 'transparent';
384
+ const s = Math.max(3, state.st.fz / 4.5); tEl.style.webkitTextStroke = `${s}px ${state.st.bg}`;
385
+ tEl.style.paintOrder = 'stroke fill'; tEl.style.webkitPaintOrder = 'stroke fill';
386
+ tEl.style.textShadow = 'none';
387
+ } else {
388
+ tEl.style.backgroundColor = 'transparent'; tEl.style.webkitTextStroke = '0px'; tEl.style.textShadow = 'none';
389
+ }
390
+ }
391
+
392
+ const boxControlsPanel = document.getElementById('boxControlsPanel');
393
+ const currentConfig = STYLE_CONFIGS[state.st.name];
394
+
395
+ if (boxControlsPanel && currentConfig) {
396
+ const hasRadius = currentConfig.panels.includes('radius');
397
+ boxControlsPanel.style.display = hasRadius ? 'block' : 'none';
398
+
399
+ const boxPreview = document.getElementById('boxPreview');
400
+ if (boxPreview && hasRadius) {
401
+ boxPreview.style.borderRadius = `${state.st.radius || 16}px`;
402
+ boxPreview.style.padding = `${state.st.paddingY || 10}px ${state.st.paddingX || 20}px`;
403
+
404
+ let previewBg = '#000';
405
+ if(state.st.name === 'instagram_box') previewBg = '#ffffff';
406
+ else if(state.st.name === 'alpha_gradient') previewBg = 'linear-gradient(135deg, #7c4dff, #b388ff)';
407
+ else if(state.st.bg) previewBg = state.st.bg;
408
+
409
+ boxPreview.style.background = previewBg;
410
+ boxPreview.style.color = (state.st.name === 'instagram_box') ? '#000' : '#fff';
411
+ }
412
+ }
413
+ }
static/js/main.js ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // راه‌اندازی اولیه و هندلرهای رویداد اصلی
2
+
3
+ window.onload = function() {
4
+ startAnim('#animPreviewStatic');
5
+ initDB().then(() => {
6
+ loadHome();
7
+ });
8
+ syncModeButtons();
9
+ initLanguagesList();
10
+ applyDemoLanguage('fa');
11
+
12
+ const timeline = document.getElementById('timelineScroll');
13
+ timeline.addEventListener('touchstart', () => { v.pause(); togglePlayIcon(false); }, {passive: true});
14
+ timeline.addEventListener('mousedown', () => { v.pause(); togglePlayIcon(false); });
15
+
16
+ // هماهنگ‌سازی حرکت انیمیشن ویژوالایزرها با وضعیت پخش و توقف ویدیو
17
+ v.addEventListener('play', () => {
18
+ const vBars = tEl.querySelectorAll('.v-bar');
19
+ vBars.forEach(b => b.style.animationPlayState = 'running');
20
+ });
21
+ v.addEventListener('pause', () => {
22
+ const vBars = tEl.querySelectorAll('.v-bar');
23
+ vBars.forEach(b => b.style.animationPlayState = 'paused');
24
+ });
25
+ };
26
+
27
+ window.onresize = fit;
28
+
29
+ // --- موتور زمان‌سنج هوشمند (High-Precision Sync) جایگزین v.ontimeupdate ---
30
+ function runFastSync() {
31
+ // فقط وقتی ویدیو در حال پخش است یا دستی جابجا می‌شود اجرا شود
32
+ if (!v.paused || manualOverride) {
33
+ const cur = v.currentTime;
34
+
35
+ const timeDisplay = document.getElementById('globalTimeDisplay');
36
+ if (timeDisplay) {
37
+ timeDisplay.innerText = `${formatTimeSimple(cur)} / ${formatTimeSimple(v.duration || 0)}`;
38
+ }
39
+
40
+ if (!v.paused) manualOverride = false;
41
+
42
+ let foundWord = false;
43
+ // اضافه کردن 0.1 ثانیه تلورانس برای دیده شدن کلمات آخر جمله
44
+ for (let s = 0; s < state.segs.length; s++) {
45
+ const seg = state.segs[s];
46
+ if (cur >= seg.start && cur < (seg.end + 0.1)) {
47
+ if (seg.words) {
48
+ for (let w = 0; w < seg.words.length; w++) {
49
+ const word = seg.words[w];
50
+ // بررسی فعال بودن کلمه با دقت بالا و تلورانس شروع و پایان
51
+ if (cur >= (word.start - 0.02) && cur < (word.end + 0.05)) {
52
+ const uid = `${s}-${w}`;
53
+ if (activeWordId !== uid) {
54
+ activeWordId = uid;
55
+ document.querySelectorAll('.word-chip').forEach(c => c.classList.remove('active'));
56
+ const el = document.getElementById(`w-${uid}`);
57
+ if(el) {
58
+ el.classList.add('active');
59
+ if(!manualOverride) el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
60
+ }
61
+ updateSplitButton();
62
+ }
63
+ foundWord = true;
64
+ break;
65
+ }
66
+ }
67
+ }
68
+ }
69
+ if (foundWord) break;
70
+ }
71
+
72
+ if (!foundWord && !manualOverride) {
73
+ document.querySelectorAll('.word-chip').forEach(c => c.classList.remove('active'));
74
+ activeWordId = null;
75
+ const toolbar = document.getElementById('toolbar');
76
+ if (toolbar) toolbar.classList.remove('show');
77
+ }
78
+
79
+ updateOverlayContent(cur);
80
+ }
81
+ // تکرار مداوم برای دقت ۶۰ فریم در ثانیه
82
+ requestAnimationFrame(runFastSync);
83
+ }
84
+
85
+ // شروع به کار موتور همزمان با لود شدن صفحه
86
+ requestAnimationFrame(runFastSync);
87
+
88
+ // --- سیستم لمسی ---
89
+
90
+ // ۱. ساخت کادر نامرئی بزرگ برای راحت‌تر گرفتن متن و کادر سفید هنگام لمس
91
+ const touchAreaStyle = document.createElement('style');
92
+ touchAreaStyle.innerHTML = `
93
+ /* ایجاد یک حاشیه نامرئی ۱۰۰ پیکسلی دور متن تا کاربر راحت‌تر آن را بگیرد */
94
+ #activeText::before {
95
+ content: '';
96
+ position: absolute;
97
+ top: -100px; bottom: -100px; left: -100px; right: -100px;
98
+ z-index: 999;
99
+ }
100
+ /* مستطیل سفید که هنگام جابجایی ظاهر می‌شود */
101
+ #activeText.is-dragging {
102
+ outline: 2px solid #ffffff;
103
+ outline-offset: 20px;
104
+ border-radius: 8px;
105
+ transition: outline 0.1s;
106
+ }
107
+ `;
108
+ document.head.appendChild(touchAreaStyle);
109
+
110
+ // قفل کردن رفتار پیش‌فرض مرورگر روی این المان تا صفحه اسکرول نشود
111
+ tEl.style.touchAction = 'none';
112
+
113
+ tEl.addEventListener('touchstart', (e) => {
114
+ // متوقف کردن ویدیو در صورت پخش بودن به محض لمس کادر
115
+ if (v && !v.paused) {
116
+ v.pause();
117
+ if (typeof togglePlayIcon === 'function') togglePlayIcon(false);
118
+ }
119
+
120
+ // غیرفعال کردن اکشن‌های بومی مرورگر از ریشه لمس برای فعالسازی زوم جاوااسکریپتی
121
+ e.preventDefault();
122
+ e.stopPropagation();
123
+
124
+ if(e.touches.length === 1) {
125
+ touchMode = 'drag';
126
+ initialY = e.touches[0].clientY;
127
+ initialX = e.touches[0].clientX;
128
+ initialBottom = state.st.y;
129
+ initialXState = state.st.x || 0;
130
+
131
+ tEl.classList.add('is-dragging');
132
+
133
+ } else if (e.touches.length === 2) {
134
+ touchMode = 'pinch';
135
+ initialDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
136
+ initialFontSize = state.st.fz;
137
+ tEl.classList.add('is-dragging');
138
+ }
139
+ }, {passive: false});
140
+
141
+ tEl.addEventListener('touchmove', (e) => {
142
+ if (!touchMode) return;
143
+ e.preventDefault();
144
+ e.stopPropagation();
145
+
146
+ // تشخیص هوشمند قرار گرفتن انگشت دوم در هر جای صفحه و سوییچ آنی به زوم
147
+ if (e.touches.length === 2) {
148
+ if (touchMode !== 'pinch') {
149
+ touchMode = 'pinch';
150
+ initialDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
151
+ initialFontSize = state.st.fz;
152
+ }
153
+
154
+ let dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
155
+ if (initialDist > 0) {
156
+ let newSize = initialFontSize * (dist / initialDist);
157
+ state.st.fz = Math.round(Math.max(10, Math.min(150, newSize)));
158
+ tEl.style.fontSize = state.st.fz + 'px';
159
+ syncUIWithState();
160
+ }
161
+
162
+ } else if (touchMode === 'drag' && e.touches.length === 1) {
163
+ let diffY = (initialY - e.touches[0].clientY) * 1.8;
164
+ let newBottom = initialBottom + diffY;
165
+ state.st.y = Math.round(newBottom);
166
+
167
+ let diffX = (e.touches[0].clientX - initialX) * 1.5;
168
+ let newX = initialXState + diffX;
169
+ state.st.x = Math.round(newX);
170
+
171
+ // اعمال تغییرات به صورت مستقیم و فوق‌روان
172
+ tEl.style.bottom = state.st.y + 'px';
173
+ tEl.style.transform = `translateX(calc(-50% + ${state.st.x || 0}px))`;
174
+ syncUIWithState();
175
+ }
176
+ }, {passive: false});
177
+
178
+ tEl.addEventListener('touchend', () => {
179
+ touchMode = null;
180
+ // مخفی کردن کادر سفید
181
+ tEl.classList.remove('is-dragging');
182
+ // ذخیره نهایی
183
+ upd();
184
+ });
static/js/ui.js ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // مدیریت رابط کاربری (مدال‌ها، شیت‌ها، تغییر صفحات)
2
+
3
+ async function loadHome() {
4
+ document.getElementById('homeScreen').style.display = 'flex';
5
+ document.getElementById('editorScreen').style.display = 'none';
6
+
7
+ if (!db) {
8
+ try { await initDB(); } catch(e) { console.error("DB Init Failed inside loadHome"); return; }
9
+ }
10
+
11
+ const tx = db.transaction("projects", "readonly");
12
+ const store = tx.objectStore("projects");
13
+ const req = store.getAll();
14
+
15
+ req.onsuccess = (e) => {
16
+ let projects = e.target.result;
17
+ projects.sort((a, b) => b.id - a.id);
18
+
19
+ const grid = document.getElementById('projectsGrid');
20
+ const placeholder = document.getElementById('noProjectsPlaceholder');
21
+
22
+ if (projects.length === 0) {
23
+ grid.innerHTML = '';
24
+ placeholder.style.display = 'flex';
25
+ } else {
26
+ placeholder.style.display = 'none';
27
+ grid.innerHTML = '';
28
+
29
+ projects.forEach((p, index) => {
30
+ const card = document.createElement('div');
31
+ card.className = 'project-card';
32
+ card.onclick = () => openProject(p.id);
33
+
34
+ const thumbUrl = p.thumbnail ? `url(${p.thumbnail})` : 'none';
35
+
36
+ card.innerHTML = `
37
+ <div class="card-thumb" style="background-image: ${thumbUrl}">
38
+ <div class="card-overlay"></div>
39
+ <div class="card-menu" onclick="openActionSheet(${p.id}, '${p.name}', event)"><i class="fa-solid fa-ellipsis"></i></div>
40
+ <div class="card-duration">${p.duration || '00:00'}</div>
41
+ </div>
42
+ <div class="card-details">
43
+ <div class="card-title">${p.name}</div>
44
+ <div class="card-date">${p.dateStr}</div>
45
+ </div>
46
+ `;
47
+ grid.appendChild(card);
48
+ });
49
+ }
50
+ };
51
+ }
52
+
53
+ function openActionSheet(id, name, event) {
54
+ event.stopPropagation();
55
+ currentActionProjectId = id;
56
+ currentActionProjectName = name;
57
+ const sheet = document.getElementById('projectActionOverlay');
58
+ sheet.style.display = 'flex';
59
+ }
60
+
61
+ function closeActionSheet() {
62
+ const sheet = document.getElementById('projectActionOverlay');
63
+ sheet.style.display = 'none';
64
+ }
65
+
66
+ function openDeleteConfirmation() {
67
+ closeActionSheet();
68
+ showConfirmationModal({
69
+ iconClass: 'fa-solid fa-trash-can',
70
+ title: 'حذف پروژه',
71
+ description: `آیا از حذف کامل «${currentActionProjectName}» مطمئن هستید؟`,
72
+ confirmText: 'بله، حذف کن',
73
+ confirmClass: 'delete',
74
+ onConfirm: () => {
75
+ const tx = db.transaction("projects", "readwrite");
76
+ const store = tx.objectStore("projects");
77
+
78
+ // ابتدا دریافت پروژه از دیتابیس مرورگر برای پیدا کردن شناسه سرور (file_id)
79
+ store.get(currentActionProjectId).onsuccess = (e) => {
80
+ const project = e.target.result;
81
+ if (project && project.state && project.state.id) {
82
+ const fileId = project.state.id;
83
+ // ارسال درخواست حذف به سرور در پس‌زمینه (بدون معطل کردن کاربر)
84
+ fetch(`/api/delete-project/${fileId}`, { method: 'DELETE' })
85
+ .catch(err => console.error("Failed to delete project files on server:", err));
86
+ }
87
+
88
+ // حذف پروژه از دیتابیس محلی مرورگر
89
+ store.delete(currentActionProjectId);
90
+ };
91
+
92
+ tx.oncomplete = () => loadHome();
93
+ }
94
+ });
95
+ }
96
+
97
+ function openRenameModal() {
98
+ closeActionSheet();
99
+ const modal = document.getElementById('renameModal');
100
+ const input = document.getElementById('renameInput');
101
+ input.value = currentActionProjectName;
102
+ modal.style.display = 'flex';
103
+ setTimeout(() => modal.classList.add('show'), 10);
104
+ }
105
+
106
+ function closeRenameModal() {
107
+ const modal = document.getElementById('renameModal');
108
+ modal.classList.remove('show');
109
+ setTimeout(() => modal.style.display = 'none', 300);
110
+ }
111
+
112
+ function saveRename() {
113
+ const newName = document.getElementById('renameInput').value.trim();
114
+ if (newName && currentActionProjectId) {
115
+ const tx = db.transaction("projects", "readwrite");
116
+ const store = tx.objectStore("projects");
117
+ store.get(currentActionProjectId).onsuccess = (e) => {
118
+ const project = e.target.result;
119
+ if (project) {
120
+ project.name = newName;
121
+ store.put(project);
122
+ }
123
+ };
124
+ tx.oncomplete = () => {
125
+ closeRenameModal();
126
+ loadHome();
127
+ };
128
+ } else {
129
+ closeRenameModal();
130
+ }
131
+ }
132
+
133
+ function toggleTool(toolName) {
134
+ document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active-tool'));
135
+ const btnId = 'btn-' + toolName;
136
+ const btn = document.getElementById(btnId);
137
+
138
+ const sectionId = 'section-' + toolName;
139
+ const section = document.getElementById(sectionId);
140
+
141
+ const isAlreadyOpen = toolsContainer.classList.contains('open') && section.style.display === 'block';
142
+
143
+ if (isAlreadyOpen) {
144
+ toolsContainer.classList.remove('open');
145
+ setTimeout(() => { section.style.display = 'none'; }, 200);
146
+ } else {
147
+ document.querySelectorAll('.tool-section').forEach(s => s.style.display = 'none');
148
+ if(btn) btn.classList.add('active-tool');
149
+ section.style.display = 'block';
150
+ section.classList.add('active-section');
151
+ toolsContainer.classList.add('open');
152
+
153
+ if(toolName === 'text') {
154
+ toolsContainer.style.maxHeight = 'none';
155
+ renderSegList();
156
+
157
+ if (state.segs && state.segs.length > 0) {
158
+ let sIdxToSelect = 0;
159
+ const curTime = v.currentTime;
160
+ const currentSegIdx = state.segs.findIndex(s => curTime >= s.start && curTime <= s.end);
161
+
162
+ if (currentSegIdx !== -1) sIdxToSelect = currentSegIdx;
163
+ else {
164
+ const nextSegIdx = state.segs.findIndex(s => s.start > curTime);
165
+ if (nextSegIdx !== -1) sIdxToSelect = nextSegIdx;
166
+ }
167
+
168
+ setTimeout(() => {
169
+ if (state.segs[sIdxToSelect] && state.segs[sIdxToSelect].words.length > 0) {
170
+ highlightWord(sIdxToSelect, 0, true);
171
+ }
172
+ }, 50);
173
+ }
174
+ } else {
175
+ toolsContainer.style.maxHeight = '';
176
+ }
177
+ }
178
+ }
179
+
180
+ function toggleCustomAccordion() { document.getElementById('customAccordion').classList.toggle('open'); }
181
+
182
+ function closeAllSheets() {
183
+ const overlay = document.getElementById('sheetOverlay');
184
+ overlay.classList.remove('show');
185
+ document.querySelectorAll('.bottom-sheet').forEach(s => {
186
+ s.classList.remove('active');
187
+ });
188
+ document.getElementById('textInput').blur();
189
+
190
+ if(previewInterval) clearInterval(previewInterval);
191
+ v.pause();
192
+
193
+ const icon1 = document.getElementById('btnPreviewPlay')?.querySelector('i');
194
+ if(icon1) icon1.className = "fa-solid fa-play";
195
+
196
+ const icon2 = document.getElementById('btnTimeSheetPlayIcon');
197
+ if(icon2) icon2.className = "fa-solid fa-play";
198
+ }
199
+
200
+ function openSheet(type) {
201
+ if (!activeWordId && type !== 'regenerate' && type !== 'wordcount') return;
202
+
203
+ closeAllSheets();
204
+ const targetSheet = document.getElementById(`sheet-${type}`);
205
+ const overlay = document.getElementById('sheetOverlay');
206
+
207
+ if(type === 'text') {
208
+ const [sIdx, wIdx] = activeWordId.split('-').map(Number);
209
+ const seg = state.segs[sIdx];
210
+ const word = seg.words[wIdx];
211
+ document.getElementById('textInput').value = word.word;
212
+ document.getElementById('textTimeDisplay').innerText = `${fmt(word.start)} -> ${fmt(word.end)}`;
213
+ } else if(type === 'time') {
214
+ const [sIdx, wIdx] = activeWordId.split('-').map(Number);
215
+ const seg = state.segs[sIdx];
216
+ const word = seg.words[wIdx];
217
+
218
+ tempStartTime = word.start;
219
+ tempEndTime = word.end;
220
+
221
+ document.getElementById('timeSheetWord').innerText = word.word;
222
+
223
+ const prevBox = document.getElementById('prevWordPreview');
224
+ const nextBox = document.getElementById('nextWordPreview');
225
+
226
+ // محاسبه محدوده مجاز (Boundary)
227
+ let minAllowed = 0;
228
+ let maxAllowed = v.duration || 1000;
229
+
230
+ // محاسبه کلمه قبل و حد پایین
231
+ if (wIdx > 0) {
232
+ prevBox.innerText = seg.words[wIdx - 1].word;
233
+ minAllowed = seg.words[wIdx - 1].end;
234
+ } else if (sIdx > 0 && state.segs[sIdx-1].words.length > 0) {
235
+ const prevSeg = state.segs[sIdx-1];
236
+ prevBox.innerText = prevSeg.words[prevSeg.words.length - 1].word;
237
+ minAllowed = prevSeg.words[prevSeg.words.length - 1].end;
238
+ } else {
239
+ prevBox.innerText = "-";
240
+ minAllowed = 0;
241
+ }
242
+
243
+ // محاسبه کلمه بعد و حد بالا
244
+ if (wIdx < seg.words.length - 1) {
245
+ nextBox.innerText = seg.words[wIdx + 1].word;
246
+ maxAllowed = seg.words[wIdx + 1].start;
247
+ } else if (sIdx < state.segs.length - 1 && state.segs[sIdx+1].words.length > 0) {
248
+ nextBox.innerText = state.segs[sIdx+1].words[0].word;
249
+ maxAllowed = state.segs[sIdx+1].words[0].start;
250
+ } else {
251
+ nextBox.innerText = "-";
252
+ maxAllowed = v.duration || tempEndTime + 5;
253
+ }
254
+
255
+ // راه‌اندازی Trimmer با محدوده‌های دقیق
256
+ if(typeof initTrimmerUI === 'function') {
257
+ initTrimmerUI(tempStartTime, tempEndTime, minAllowed, maxAllowed);
258
+ }
259
+
260
+ } else if(type === 'delete') {
261
+ const [sIdx, wIdx] = activeWordId.split('-').map(Number);
262
+ const seg = state.segs[sIdx];
263
+ const word = seg.words[wIdx];
264
+ document.getElementById('deleteMsgText').innerHTML = `آیا کلمه <span class="highlight-word">«${word.word}»</span> حذف شود؟`;
265
+ } else if (type === 'regenerate') {
266
+ if (typeof isRegenerating !== 'undefined' && isRegenerating) {
267
+ document.getElementById('regen-step-start').style.display = 'none';
268
+ document.getElementById('regen-step-loading').style.display = 'flex';
269
+ document.getElementById('regen-step-success').style.display = 'none';
270
+ } else {
271
+ document.getElementById('regen-step-start').style.display = 'flex';
272
+ document.getElementById('regen-step-loading').style.display = 'none';
273
+ document.getElementById('regen-step-success').style.display = 'none';
274
+ }
275
+ }
276
+
277
+ setTimeout(() => {
278
+ overlay.classList.add('show');
279
+ targetSheet.classList.add('active');
280
+ }, 50);
281
+ }
282
+
283
+ function preConfirmRegenerate() {
284
+ showConfirmationModal({
285
+ iconClass: 'fa-solid fa-rotate',
286
+ title: 'بازنویسی هوشمند',
287
+ description: 'آیا می‌خواهید هوش مصنوعی دوباره ویدیو را بررسی کرده و زیرنویس‌ها را از نو بنویسد؟ (زیرنویس‌های فعلی حذف می‌شوند)',
288
+ confirmText: 'بله، بازنویسی کن',
289
+ confirmClass: 'split',
290
+ onConfirm: () => {
291
+ regenerateProjectSubtitles();
292
+ }
293
+ });
294
+ }
295
+
296
+ function showConfirmationModal(config) {
297
+ const modal = document.getElementById('confirmationModal');
298
+ document.getElementById('confirmIcon').querySelector('i').className = config.iconClass;
299
+ document.getElementById('confirmTitle').innerText = config.title;
300
+ document.getElementById('confirmDesc').innerText = config.description;
301
+ const btn = document.getElementById('confirmBtn');
302
+ btn.innerText = config.confirmText; btn.className = 'modal-btn btn-confirm-action ' + config.confirmClass;
303
+ btn.onclick = () => { config.onConfirm(); closeConfirmationModal(); };
304
+ modal.style.display = 'flex'; setTimeout(() => modal.classList.add('show'), 10);
305
+ }
306
+ function closeConfirmationModal() { const m = document.getElementById('confirmationModal'); m.classList.remove('show'); setTimeout(() => m.style.display = 'none', 300); }
static/js/utils.js ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // توابع کمکی فرمت‌دهی زمان و تاریخ
2
+ function getPersianDate() {
3
+ const d = new Date();
4
+ const option = { month: 'long', day: 'numeric', year: 'numeric' };
5
+ return d.toLocaleDateString('fa-IR', option);
6
+ }
7
+
8
+ function fmt(s) {
9
+ let sec = Math.floor(s);
10
+ let ms = Math.round((s - sec) * 100);
11
+ return `${sec < 10 ? '0'+sec : sec}.${ms < 10 ? '0'+ms : ms}`;
12
+ }
13
+
14
+ function formatTimeSimple(sec) {
15
+ let m = Math.floor(sec / 60);
16
+ let s = Math.floor(sec % 60);
17
+ return `${m}:${s < 10 ? '0'+s : s}`;
18
+ }
19
+
20
+ // انیمیشن ساده
21
+ function startAnim(selector) {
22
+ const container = document.querySelector(selector);
23
+ if(!container) return;
24
+ const words = container.querySelectorAll('.anim-word');
25
+ let i = 0;
26
+ setInterval(() => {
27
+ words.forEach(w => w.classList.remove('active'));
28
+ words[i].classList.add('active');
29
+ i = (i + 1) % words.length;
30
+ }, 600);
31
+ }
32
+
33
+ // همگام‌سازی دکمه‌های استایل
34
+ function syncModeButtons() {
35
+ document.querySelectorAll('.mode-btn').forEach(b => {
36
+ b.classList.remove('active');
37
+ if(b.getAttribute('onclick').includes(`'${state.st.type}'`)) b.classList.add('active');
38
+ });
39
+ }
styles/ai_dynamic.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont, ImageFilter
2
+ import math
3
+
4
+ # --- استایل «طراحی هوشمند» (AI Dynamic) ---
5
+ # هوش مصنوعی (Gemini) هرگز کد اجرا نمی‌کند؛ فقط یک شیء طراحی محدود (ai_design) با
6
+ # چند فیلد از پیش تعریف‌شده برمی‌گرداند (entry, exit, timing, stagger, glow, رنگ‌ها).
7
+ # این استایل دیگر هیچ کادر/باکس/پس‌زمینه‌ای دور متن یا دور کلمات نمی‌سازد؛ تمرکز فقط
8
+ # روی حرکت ورود/خروج کلمات و رنگ متن است (و در صورت درخواست، درخشش نور اطراف متن).
9
+ # مقادیر آن قبلاً در app.py (_clamp_ai_design) کاملاً اعتبارسنجی و clamp شده‌اند.
10
+ # این فایل صرفاً همان پارامترها را می‌خواند و واقعاً فریم را رسم می‌کند؛ به همین دلیل
11
+ # خروجی همیشه امن، پایدار و بدون امکان کرش است.
12
+
13
+ config = {
14
+ "ids": ["ai_dynamic"],
15
+ "name": "طراحی هوشمند (AI)",
16
+ "panels": ["font", "size", "position"],
17
+ "default_font": "vazir"
18
+ }
19
+
20
+ frontend_template = """
21
+ <span class="ai-dynamic-word" style="
22
+ display: inline-block; vertical-align: middle; margin: 0 4px;
23
+ color: {{COLOR}} !important; opacity: {{OPACITY}}; font-family: inherit;
24
+ ">{{WORD}}</span>
25
+ """
26
+
27
+ DEFAULT_DESIGN = {
28
+ "entry": "fall",
29
+ "exit": "fade",
30
+ "timing": "smooth",
31
+ "stagger": 0.04,
32
+ "glow": "none",
33
+ "primaryColor": "#FFFFFF",
34
+ "activeColor": "#FFD700",
35
+ "glowColor": "#7C4DFF",
36
+ "font": "vazir",
37
+ "fontSize": 60,
38
+ }
39
+
40
+
41
+ def _get_design(style_config):
42
+ raw = getattr(style_config, 'ai_design', None) or {}
43
+ if not isinstance(raw, dict):
44
+ raw = {}
45
+ d = dict(DEFAULT_DESIGN)
46
+ for k in DEFAULT_DESIGN:
47
+ if k in raw and raw[k] is not None:
48
+ d[k] = raw[k]
49
+ # سازگاری با طراحی‌های قدیمی‌تر که ممکن است هنوز shapeColor داشته باشند
50
+ if "glowColor" not in raw and "shapeColor" in raw:
51
+ d["glowColor"] = raw["shapeColor"]
52
+ return d
53
+
54
+
55
+ def _ease(name, p):
56
+ p = max(0.0, min(1.0, p))
57
+ if name == "linear":
58
+ return p
59
+ if name == "punch":
60
+ # شروع تند و توقف نرم با کمی سرریز (overshoot) خفیف
61
+ return 1 - pow(1 - p, 3)
62
+ if name == "elastic":
63
+ if p <= 0 or p >= 1:
64
+ return p
65
+ return pow(2, -10 * p) * math.sin((p * 10 - 0.75) * (2 * math.pi) / 3) + 1
66
+ # smooth (پیش‌فرض)
67
+ return 1 - (1 - p) * (1 - p)
68
+
69
+
70
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
71
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
72
+ design = _get_design(style_config)
73
+
74
+ primary_color = color_parser(design["primaryColor"], (255, 255, 255, 255))
75
+ active_color = color_parser(design["activeColor"], (255, 215, 0, 255))
76
+ glow_color = color_parser(design["glowColor"], (124, 77, 255, 255))
77
+
78
+ line_height_px = int(style_config.fontSize * 1.5)
79
+ total_block_height = len(lines) * line_height_px
80
+ bottom_reference = height - style_config.marginV
81
+ start_y_of_block = bottom_reference - total_block_height
82
+
83
+ current_render_time = getattr(style_config, 'current_render_time', 0.0)
84
+
85
+ text_start_y = start_y_of_block
86
+ vertical_corr = int(style_config.fontSize * 0.1)
87
+ current_line_y = text_start_y - vertical_corr
88
+ space_w = draw.textlength(" ", font=font)
89
+ global_word_counter = 0
90
+
91
+ entry = design["entry"]
92
+ exit_type = design["exit"]
93
+ timing = design["timing"]
94
+ stagger = max(0.0, min(0.12, float(design["stagger"]) if isinstance(design["stagger"], (int, float)) else 0.04))
95
+ glow = design["glow"]
96
+ ANIM_DUR = 0.3
97
+ EXIT_DUR = 0.3
98
+
99
+ glow_blur_radius = {"none": 0, "soft": 4, "strong": 8}.get(glow, 0)
100
+ glow_intensity = {"none": 0, "soft": 160, "strong": 230}.get(glow, 0)
101
+
102
+ for line_idx, metrics in enumerate(line_metrics):
103
+ cursor_x = (width + metrics["width"]) / 2 + (style_config.x or 0) if is_rtl else (width - metrics["width"]) / 2 + (style_config.x or 0)
104
+ text_y_pos = current_line_y
105
+
106
+ for w_i, word in enumerate(metrics["words"]):
107
+ w_len = metrics["word_widths"][w_i]
108
+ word_x = (cursor_x - w_len) if is_rtl else cursor_x
109
+
110
+ should_draw = False
111
+ dx, dy, alpha_factor = 0.0, 0.0, 1.0
112
+ is_active_word = False
113
+ w_info = None
114
+ extra_delay = stagger * w_i # تاخیر آبشاری بین کلمات پشت‌سرهم داخل یک خط
115
+
116
+ if word_infos and global_word_counter < len(word_infos):
117
+ w_info = word_infos[global_word_counter]
118
+ if current_render_time >= w_info.start:
119
+ should_draw = True
120
+ time_passed = max(0.0, (current_render_time - w_info.start) - extra_delay)
121
+ progress = min(1.0, time_passed / ANIM_DUR)
122
+ eased = _ease(timing, progress)
123
+
124
+ if entry == "fall":
125
+ dy = -70 * (1 - eased)
126
+ elif entry == "rise":
127
+ dy = 65 * (1 - eased)
128
+ elif entry == "pop":
129
+ dy = -18 * (1 - eased)
130
+ elif entry == "slide_left":
131
+ dx = 85 * (1 - eased)
132
+ elif entry == "slide_right":
133
+ dx = -85 * (1 - eased)
134
+ # "fade" فقط آلفا دارد
135
+ alpha_factor = max(0.0, min(1.0, eased))
136
+
137
+ is_active_word = current_render_time >= w_info.start and current_render_time < w_info.end
138
+
139
+ # --- افکت خروج بعد از پایان گفتار این کلمه ---
140
+ if current_render_time >= w_info.end and exit_type != "none":
141
+ time_after = current_render_time - w_info.end
142
+ exit_progress = min(1.0, time_after / EXIT_DUR)
143
+ exit_eased = _ease("smooth", exit_progress)
144
+ if exit_type == "fade":
145
+ alpha_factor = max(0.4, 1.0 - exit_eased * 0.6)
146
+ elif exit_type == "shrink":
147
+ alpha_factor = max(0.35, 1.0 - exit_eased * 0.65)
148
+ dy += 6 * exit_eased
149
+ elif exit_type == "fall":
150
+ dy += 25 * exit_eased
151
+ alpha_factor = max(0.0, 1.0 - exit_eased)
152
+ elif exit_type == "rise":
153
+ dy -= 25 * exit_eased
154
+ alpha_factor = max(0.0, 1.0 - exit_eased)
155
+ else:
156
+ should_draw = False
157
+ else:
158
+ if active_idx != -1:
159
+ should_draw = (global_word_counter <= active_idx)
160
+ is_active_word = (global_word_counter == active_idx)
161
+ else:
162
+ should_draw = False
163
+
164
+ if should_draw and alpha_factor > 0.01:
165
+ this_color = primary_color
166
+ if getattr(style_config, 'useActiveColor', True) and is_active_word:
167
+ this_color = active_color
168
+ if word_infos and global_word_counter < len(word_infos):
169
+ w_obj = word_infos[global_word_counter]
170
+ if hasattr(w_obj, 'color') and w_obj.color:
171
+ this_color = color_parser(w_obj.color, this_color)
172
+
173
+ final_alpha = int(this_color[3] * alpha_factor)
174
+ final_color = (this_color[0], this_color[1], this_color[2], final_alpha)
175
+ draw_x = word_x + dx
176
+ draw_y = text_y_pos + dy
177
+
178
+ # --- درخشش نور نرم اطراف متن (بلور واقعی)؛ دیگر هیچ کادر/باکسی رسم نمی‌شود ---
179
+ if glow_blur_radius > 0:
180
+ pad = glow_blur_radius * 3 + 8
181
+ layer_w = int(w_len) + pad * 2
182
+ layer_h = int(style_config.fontSize * 1.3) + pad * 2
183
+ if layer_w > 0 and layer_h > 0:
184
+ glow_layer = Image.new('RGBA', (layer_w, layer_h), (0, 0, 0, 0))
185
+ glow_draw = ImageDraw.Draw(glow_layer)
186
+ glow_a = int(glow_intensity * alpha_factor)
187
+ glow_draw.text((pad, pad), word, font=font, fill=(*glow_color[:3], glow_a),
188
+ direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
189
+ blurred = glow_layer.filter(ImageFilter.GaussianBlur(glow_blur_radius))
190
+ img.paste(blurred, (int(draw_x - pad), int(draw_y - pad)), blurred)
191
+
192
+ draw.text((draw_x, draw_y), word, font=font, fill=final_color,
193
+ direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
194
+
195
+ cursor_x += (-(w_len + space_w) if is_rtl else (w_len + space_w))
196
+ global_word_counter += 1
197
+
198
+ current_line_y += line_height_px
styles/blue_cloud.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont, ImageFilter
2
+
3
+ config = {
4
+ "ids": ["blue_cloud"],
5
+ "name": "Blue Cloud",
6
+ "panels": ["font", "size", "position"],
7
+ "default_font": "lalezar"
8
+ }
9
+
10
+ frontend_template = ""
11
+
12
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
13
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
14
+ # --- محاسبات جایگاه متن ---
15
+ line_height_px = int(style_config.fontSize * 1.5)
16
+ total_text_height = len(lines) * line_height_px
17
+ center_x = width / 2 + (style_config.x or 0)
18
+ bottom_margin = style_config.marginV
19
+ start_y = height - bottom_margin - total_text_height
20
+
21
+ vertical_correction = int(style_config.fontSize * 0.1)
22
+ current_text_y = start_y - vertical_correction
23
+
24
+ # --- تنظیمات رنگ دقیق (داینامیک شده) ---
25
+ # دریافت رنگ متن از پنل کاربر (پیش‌فرض آبی روشن اگر چیزی انتخاب نکرده باشد)
26
+ custom_hex = style_config.styleColors.get('blue_cloud', '#00b4ff')
27
+ text_fill = color_parser(custom_hex, (0, 180, 255, 255))
28
+
29
+ stroke_color = (255, 255, 255, 255) # سفید خالص
30
+
31
+ # --- اصلاح ضخامت‌ها و فاصله‌ها ---
32
+ # ضخامت حاشیه سفید (کاهش یافته برای ظرافت)
33
+ stroke_w = max(2, int(style_config.fontSize * 0.072))
34
+
35
+ # ضخامت هاله
36
+ cloud_stroke_w = max(5, int(style_config.fontSize * 0.275))
37
+ cloud_blur = 5
38
+
39
+ # استانداردسازی دقیق فاصله کلمات (دقیقاً مشابه استایل‌های ساده)
40
+ space_w = draw.textlength(" ", font=font)
41
+ actual_space_w = space_w
42
+
43
+ # --- مرحله ۱: جمع‌آوری مختصات دقیق تمام کلمات ---
44
+ words_to_draw = []
45
+ global_word_counter = 0
46
+
47
+ for line_idx, metrics in enumerate(line_metrics):
48
+ # عرض خط دقیقاً همان عرض محاسبه شده پیش‌فرض است بدون هیچ فاصله اضافی
49
+ total_extra_spacing = 0
50
+ adjusted_line_width = metrics["width"] + total_extra_spacing
51
+
52
+ cursor_x = (center_x + (adjusted_line_width / 2)) if is_rtl else (center_x - (adjusted_line_width / 2))
53
+
54
+ for w_i, word in enumerate(metrics["words"]):
55
+ w_len = metrics["word_widths"][w_i]
56
+ word_x = int(cursor_x - w_len) if is_rtl else int(cursor_x)
57
+ word_y = int(current_text_y)
58
+
59
+ # تعیین رنگ کلمه فعال (پیش‌فرض همان رنگ متن)
60
+ is_active = (global_word_counter == active_idx) and getattr(style_config, 'useActiveColor', True)
61
+ active_hex = style_config.styleActiveColors.get('blue_cloud', custom_hex)
62
+ current_word_color = color_parser(active_hex, text_fill) if is_active else text_fill
63
+
64
+ # اگر کاربر از ابزار رنگ کلمات برای رنگ کردن یک کلمه خاص استفاده کرده باشد، اینجا اعمال می‌شود
65
+ if word_infos and global_word_counter < len(word_infos):
66
+ w_obj = word_infos[global_word_counter]
67
+ if hasattr(w_obj, 'color') and w_obj.color:
68
+ current_word_color = color_parser(w_obj.color, current_word_color)
69
+
70
+ # --- منطق انیمیشن تایپ ---
71
+ display_word = word
72
+ draw_x = word_x
73
+ should_draw = True
74
+
75
+ is_typewriter = getattr(style_config, 'typewriter', False)
76
+ if is_typewriter and word_infos and global_word_counter < len(word_infos):
77
+ w_info = word_infos[global_word_counter]
78
+ current_t = getattr(style_config, 'current_render_time', 0.0)
79
+ if current_t < w_info.start:
80
+ should_draw = False
81
+ elif current_t >= w_info.start and current_t < w_info.end:
82
+ char_len = len(word)
83
+ progress = (current_t - w_info.start) / max(0.001, w_info.end - w_info.start)
84
+ visible_chars = min(char_len, int(progress * char_len) + 1)
85
+ display_word = word[:visible_chars]
86
+ try: sub_w = draw.textlength(display_word, font=font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
87
+ except: sub_w = font.getlength(display_word)
88
+ draw_x = int(cursor_x - sub_w) if is_rtl else draw_x
89
+
90
+ if should_draw and display_word:
91
+ words_to_draw.append({
92
+ "text": display_word,
93
+ "x": draw_x,
94
+ "y": word_y,
95
+ "color": current_word_color
96
+ })
97
+
98
+ cursor_x += (-(w_len + actual_space_w) if is_rtl else (w_len + actual_space_w))
99
+ global_word_counter += 1
100
+
101
+ current_text_y += line_height_px
102
+
103
+ # --- مرحله ۲: رسم هاله (Cloud) با رنگ داینامیک و تراز هوشمند ---
104
+ glow_layer = Image.new('RGBA', (width, height), (0, 0, 0, 0))
105
+ g_draw = ImageDraw.Draw(glow_layer)
106
+
107
+ # اصلاح تراز عمودی هاله (ابر) اختصاصاً برای فونت‌های خاص
108
+ glow_y_offset = 0
109
+ if style_config.font == 'pinar':
110
+ glow_y_offset = -int(cloud_stroke_w * 0.6) # هدایت به سمت بالا
111
+ elif style_config.font in ['dastnevis', 'entazar', 'kamran']:
112
+ glow_y_offset = int(cloud_stroke_w * 0.6) # هدایت به سمت پایین
113
+
114
+ for item in words_to_draw:
115
+ # استخراج رنگ خودِ این کلمه و اختصاص آن به ابرش (با کمی شفافیت: 230)
116
+ r, g, b, _ = item["color"]
117
+ current_cloud_color = (r, g, b, 230)
118
+
119
+ g_draw.text((item["x"], item["y"] + glow_y_offset), item["text"], font=font, fill=current_cloud_color,
120
+ stroke_width=cloud_stroke_w, stroke_fill=current_cloud_color,
121
+ direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
122
+
123
+ # محو کردن کل لایه هاله
124
+ glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(cloud_blur))
125
+ # چسباندن لایه هاله در پس‌زمینه
126
+ img.paste(glow_layer, (0, 0), glow_layer)
127
+
128
+ # --- مرحله ۳: رسم حاشیه سفید ---
129
+ for item in words_to_draw:
130
+ draw.text((item["x"], item["y"]), item["text"], font=font, fill=stroke_color,
131
+ stroke_width=stroke_w, stroke_fill=stroke_color,
132
+ direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
133
+
134
+ # --- مرحله ۴: رسم رنگ اصلی متن روی لایه‌های قبلی ---
135
+ for item in words_to_draw:
136
+ draw.text((item["x"], item["y"]), item["text"], font=font, fill=item["color"],
137
+ direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
styles/blue_outline.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont
2
+
3
+ config = {
4
+ "ids": ["blue_outline"],
5
+ "name": "Blue Outline",
6
+ "panels": ["font", "size", "position"],
7
+ "default_font": "lalezar"
8
+ }
9
+
10
+ frontend_template = ""
11
+
12
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
13
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
14
+ # --- محاسبات جایگاه متن ---
15
+ line_height_px = int(style_config.fontSize * 1.5)
16
+ total_text_height = len(lines) * line_height_px
17
+ center_x = width / 2 + (style_config.x or 0)
18
+ bottom_margin = style_config.marginV
19
+ start_y = height - bottom_margin - total_text_height
20
+
21
+ vertical_correction = int(style_config.fontSize * 0.1)
22
+ current_text_y = start_y - vertical_correction
23
+
24
+ # --- تنظیمات رنگ دقیق (داینامیک شده) ---
25
+ custom_hex = style_config.styleColors.get('blue_outline', '#0000FF')
26
+ text_fill = color_parser(custom_hex, (0, 180, 255, 255))
27
+
28
+ custom_stroke_hex = style_config.styleBgColors.get('blue_outline_stroke', '#ffffff')
29
+ stroke_color = color_parser(custom_stroke_hex, (255, 255, 255, 255))
30
+
31
+ stroke_w = max(2, int(style_config.fontSize * 0.072))
32
+
33
+ space_w = draw.textlength(" ", font=font)
34
+ actual_space_w = space_w
35
+
36
+ # --- مرحله ۱: جمع‌آوری مختصات دقیق تمام کلمات ---
37
+ words_to_draw = []
38
+ global_word_counter = 0
39
+
40
+ for line_idx, metrics in enumerate(line_metrics):
41
+ adjusted_line_width = metrics["width"]
42
+ cursor_x = (center_x + (adjusted_line_width / 2)) if is_rtl else (center_x - (adjusted_line_width / 2))
43
+
44
+ for w_i, word in enumerate(metrics["words"]):
45
+ w_len = metrics["word_widths"][w_i]
46
+ word_x = int(cursor_x - w_len) if is_rtl else int(cursor_x)
47
+ word_y = int(current_text_y)
48
+
49
+ is_active = (global_word_counter == active_idx) and getattr(style_config, 'useActiveColor', True)
50
+ active_hex = style_config.styleActiveColors.get('blue_outline', custom_hex)
51
+ current_word_color = color_parser(active_hex, text_fill) if is_active else text_fill
52
+
53
+ # اگر کاربر از ابزار رنگ کلمات برای رنگ کردن یک کلمه خاص استفاده کرده باشد، اینجا اعمال می‌شود
54
+ if word_infos and global_word_counter < len(word_infos):
55
+ w_obj = word_infos[global_word_counter]
56
+ if hasattr(w_obj, 'color') and w_obj.color:
57
+ current_word_color = color_parser(w_obj.color, current_word_color)
58
+
59
+ # --- منطق انیمیشن تایپ ---
60
+ display_word = word
61
+ draw_x = word_x
62
+ should_draw = True
63
+
64
+ is_typewriter = getattr(style_config, 'typewriter', False)
65
+ if is_typewriter and word_infos and global_word_counter < len(word_infos):
66
+ w_info = word_infos[global_word_counter]
67
+ current_t = getattr(style_config, 'current_render_time', 0.0)
68
+ if current_t < w_info.start:
69
+ should_draw = False
70
+ elif current_t >= w_info.start and current_t < w_info.end:
71
+ char_len = len(word)
72
+ progress = (current_t - w_info.start) / max(0.001, w_info.end - w_info.start)
73
+ visible_chars = min(char_len, int(progress * char_len) + 1)
74
+ display_word = word[:visible_chars]
75
+ try: sub_w = draw.textlength(display_word, font=font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
76
+ except: sub_w = font.getlength(display_word)
77
+ draw_x = int(cursor_x - sub_w) if is_rtl else draw_x
78
+
79
+ if should_draw and display_word:
80
+ words_to_draw.append({
81
+ "text": display_word,
82
+ "x": draw_x,
83
+ "y": word_y,
84
+ "color": current_word_color
85
+ })
86
+
87
+ cursor_x += (-(w_len + actual_space_w) if is_rtl else (w_len + actual_space_w))
88
+ global_word_counter += 1
89
+
90
+ current_text_y += line_height_px
91
+
92
+ # --- مرحله ۲ و ۳: رسم حاشیه و رنگ اصلی متن با مدیریت آلفا ---
93
+ for item in words_to_draw:
94
+ # همگام‌سازی شفافیت حاشیه سفید با میزان شفافیت اختصاصی هر کلمه
95
+ alpha_val = item["color"][3] if len(item["color"]) > 3 else 255
96
+
97
+ if alpha_val < 255:
98
+ # حل باگ انباشت آلفا در Pillow (که حاشیه شفاف را کاملاً توپر و سفید می‌کرد)
99
+ # ایجاد یک بوم موقت کاملاً شفاف
100
+ temp_word_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
101
+ temp_draw = ImageDraw.Draw(temp_word_img)
102
+
103
+ # ۱. رسم حاشیه سفید به صورت کاملاً توپر (Opaque) روی بوم موقت
104
+ temp_draw.text((item["x"], item["y"]), item["text"], font=font, fill=(255, 255, 255, 255),
105
+ stroke_width=stroke_w, stroke_fill=(255, 255, 255, 255),
106
+ direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
107
+
108
+ # ۲. رسم رنگ اصلی متن به صورت کاملاً توپر روی بوم موقت
109
+ opaque_color = (item["color"][0], item["color"][1], item["color"][2], 255)
110
+ temp_draw.text((item["x"], item["y"]), item["text"], font=font, fill=opaque_color,
111
+ direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
112
+
113
+ # ۳. کاهش شفافیت کل بوم موقت به میزان آلفای هدف با جدول جستجو (LUT) جهت پایداری بالا
114
+ lut = [int(p * (alpha_val / 255.0)) for p in range(256)]
115
+ r, g, b, a = temp_word_img.split()
116
+ a = a.point(lut)
117
+ temp_word_img = Image.merge('RGBA', (r, g, b, a))
118
+
119
+ # ۴. ترکیب کانال‌های آلفا به صورت استاندارد (جلوگیری از باگ ضرب دابل و محو شدن کلمات)
120
+ img.alpha_composite(temp_word_img)
121
+ else:
122
+ # رسم عادی و سریع برای کلمات کاملاً توپر
123
+ draw.text((item["x"], item["y"]), item["text"], font=font, fill=stroke_color,
124
+ stroke_width=stroke_w, stroke_fill=stroke_color,
125
+ direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
126
+
127
+ draw.text((item["x"], item["y"]), item["text"], font=font, fill=item["color"],
128
+ direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
styles/box.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont, ImageFilter
2
+
3
+ config = {
4
+ "ids": ["instagram_box", "alpha_gradient"],
5
+ "name": "Box Styles",
6
+ "panels": ["font", "size", "radius", "padding"],
7
+ "default_font": "vazir"
8
+ }
9
+
10
+ frontend_template = """
11
+ <span style="display: inline-block; vertical-align: middle; margin: -{{PAD_Y}}px calc(5px - {{PAD_X}}px); padding: {{PAD_Y}}px {{PAD_X}}px; background: {{BG_COLOR}}; color: {{TEXT_COLOR}} !important; border-radius: {{RADIUS}}px; position: relative; z-index: {{Z_INDEX}}; white-space: nowrap; transition: all 0.1s;">{{WORD}}</span>
12
+ """
13
+
14
+ # تابع کمکی برای رسم کادرهای با گوشه گرد با کیفیت بسیار بالا و لبه‌های کاملاً نرم (Anti-aliased)
15
+ def draw_aa_rounded_rectangle(img, xy, radius, fill):
16
+ x1, y1, x2, y2 = xy
17
+ w = int(x2 - x1)
18
+ h = int(y2 - y1)
19
+ if w <= 0 or h <= 0:
20
+ return
21
+
22
+ # رسم شکل با رزولوشن ۴ برابر برای انجام آنتی‌الیاسینگ (Supersampling)
23
+ scale = 4
24
+ s_img = Image.new('RGBA', (w * scale, h * scale), fill)
25
+ mask_large = Image.new('L', (w * scale, h * scale), 0)
26
+ m_draw = ImageDraw.Draw(mask_large)
27
+ m_draw.rounded_rectangle([0, 0, w * scale, h * scale], radius=radius * scale, fill=255)
28
+
29
+ # سازگاری با نسخه‌های مختلف کتابخانه Pillow برای فیلتر تصویری LANCZOS
30
+ try:
31
+ resampling_filter = Image.Resampling.LANCZOS
32
+ except AttributeError:
33
+ resampling_filter = Image.LANCZOS
34
+
35
+ mask_aa = mask_large.resize((w, h), resampling_filter)
36
+ aa_img = s_img.resize((w, h), resampling_filter)
37
+
38
+ # چسباندن تصویر هموار شده روی بوم اصلی
39
+ img.paste(aa_img, (int(x1), int(y1)), mask_aa)
40
+
41
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
42
+ style_name = style_config.name
43
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
44
+
45
+ line_height_px = int(style_config.fontSize * 1.1) + int(style_config.paddingY * 2.2)
46
+ total_block_height = len(lines) * line_height_px
47
+ bottom_reference = height - style_config.marginV
48
+ start_y_of_block = bottom_reference - total_block_height
49
+
50
+ pad_x = style_config.paddingX
51
+ pad_y = style_config.paddingY
52
+ radius = style_config.radius
53
+
54
+ if style_name == "instagram_box":
55
+ pad_y = max(5, pad_y)
56
+
57
+ current_line_y = start_y_of_block
58
+ space_w = draw.textlength(" ", font=font)
59
+
60
+ VERTICAL_CORRECTION = int(style_config.fontSize * 0.22)
61
+ INSTA_CENTERING_OFFSET = 8 if style_name == "instagram_box" else 0
62
+
63
+ # محاسبه متریک‌های استاندارد فونت بر اساس کلمه مرجع جهت تراز یکدست عمودی تمام کلمات
64
+ ref_word = "سلام"
65
+ try:
66
+ ref_bbox = draw.textbbox((0, 0), ref_word, font=font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
67
+ standard_text_visual_center_y = (ref_bbox[1] + ref_bbox[3]) / 2.0
68
+ standard_text_offset_y = ref_bbox[1]
69
+ standard_text_h = ref_bbox[3] - ref_bbox[1]
70
+ except:
71
+ standard_text_visual_center_y = style_config.fontSize / 2.0
72
+ standard_text_offset_y = 0
73
+ standard_text_h = style_config.fontSize
74
+
75
+ global_word_counter = 0
76
+ words_to_draw = []
77
+
78
+ temp_y = current_line_y
79
+ for line_idx, metrics in enumerate(line_metrics):
80
+ line_start_idx = sum(len(m["words"]) for m in line_metrics[:line_idx])
81
+ is_active_on_line = (active_idx >= line_start_idx and active_idx < line_start_idx + len(metrics["words"]))
82
+
83
+ dynamic_line_width = metrics["width"] + (pad_x * 2 if is_active_on_line else 0)
84
+ if is_rtl:
85
+ cursor_x = (width + dynamic_line_width) / 2 + style_config.x
86
+ else:
87
+ cursor_x = (width - dynamic_line_width) / 2 + style_config.x
88
+
89
+ # مرکز عمودی دقیق و هندسی این خط فعلی
90
+ line_center_y = temp_y + (line_height_px / 2.0)
91
+
92
+ for w_i, word in enumerate(metrics["words"]):
93
+ g_idx = line_start_idx + w_i
94
+ w_len = metrics["word_widths"][w_i]
95
+
96
+ # استفاده از مقدار ثابت برای جلوگیری از نوسان عمودی کلمات خط
97
+ text_y_pos = line_center_y - standard_text_visual_center_y
98
+
99
+ current_word_padding = pad_x if g_idx == active_idx else 0
100
+ if is_rtl:
101
+ word_x = cursor_x - w_len - current_word_padding
102
+ else:
103
+ word_x = cursor_x + current_word_padding
104
+
105
+ words_to_draw.append({
106
+ "text": word,
107
+ "x": word_x,
108
+ "y": text_y_pos,
109
+ "width": w_len,
110
+ "global_idx": global_word_counter,
111
+ "line_center_y": line_center_y
112
+ })
113
+
114
+ if is_rtl:
115
+ cursor_x -= (w_len + space_w + (current_word_padding * 2))
116
+ else:
117
+ cursor_x += (w_len + space_w + (current_word_padding * 2))
118
+ global_word_counter += 1
119
+
120
+ temp_y += line_height_px
121
+
122
+ # --- رسم کادر اصلی سفید پشت کل متن برای استایل اینستاگرام ---
123
+ if style_name == "instagram_box":
124
+ max_w = 0
125
+ for m in line_metrics:
126
+ if m["width"] > max_w: max_w = m["width"]
127
+
128
+ cx = width / 2 + style_config.x
129
+
130
+ # محاسبه مختصات باکس کلی
131
+ main_box_x1 = cx - (max_w / 2) - pad_x - 10 # کمی فضای بیشتر (10px)
132
+ main_box_x2 = cx + (max_w / 2) + pad_x + 10
133
+
134
+ # محاسبه دقیق سقف و کف بر اساس آرایه واقعی کلمات ذخیره شده برای تقارن مطلق پیکسلی
135
+ first_line_y = words_to_draw[0]["y"] if words_to_draw else (start_y_of_block + pad_y)
136
+ last_line_y = words_to_draw[-1]["y"] if words_to_draw else (start_y_of_block + pad_y)
137
+
138
+ main_box_y1 = first_line_y - int(pad_y * 1.3) + VERTICAL_CORRECTION + INSTA_CENTERING_OFFSET - 8
139
+ main_box_y2 = last_line_y + style_config.fontSize + int(pad_y * 1.3) + VERTICAL_CORRECTION + INSTA_CENTERING_OFFSET + 8
140
+
141
+ main_box_hex = style_config.styleBgColors.get('instagram_box_main_box', '#ffffff')
142
+ main_box_col = color_parser(main_box_hex, (255, 255, 255, 255))
143
+
144
+ # رسم کادر اصلی اینستاگرام با کیفیت بالا و لبه‌های نرم
145
+ draw_aa_rounded_rectangle(img, [main_box_x1, main_box_y1, main_box_x2, main_box_y2], radius, main_box_col)
146
+
147
+ # --- رسم کادرها (Highlight) ---
148
+ for item in words_to_draw:
149
+ is_active = (item["global_idx"] == active_idx)
150
+
151
+ if is_active:
152
+ box_color = (0,0,0,0)
153
+ if style_name == "instagram_box":
154
+ bg_hex = style_config.styleBgColors.get('instagram_box', '#1a1a1a')
155
+ box_color = color_parser(bg_hex, (26, 26, 26, 255))
156
+ elif style_name == "alpha_gradient":
157
+ box_color = (124, 77, 255, 255)
158
+
159
+ rect_y1 = item["y"] - int(pad_y * 1.3) + VERTICAL_CORRECTION + INSTA_CENTERING_OFFSET
160
+ rect_y2 = item["y"] + style_config.fontSize + int(pad_y * 1.3) + VERTICAL_CORRECTION + INSTA_CENTERING_OFFSET
161
+
162
+ bx1, by1 = int(item["x"] - pad_x), int(rect_y1)
163
+ bx2, by2 = int(item["x"] + item["width"] + pad_x), int(rect_y2)
164
+
165
+ # اگر استایل آلفا بود، گرادینت بساز
166
+ if style_name == "alpha_gradient":
167
+ rw, rh = bx2 - bx1, by2 - by1
168
+ grad_img = Image.new('RGBA', (rw, rh), (0,0,0,0))
169
+ g_draw = ImageDraw.Draw(grad_img)
170
+ base_hex = style_config.styleBgColors.get('alpha_gradient', '#7c4dff')
171
+ r1, g1, b1, a1 = color_parser(base_hex, (124, 77, 255, 255))
172
+
173
+ factor = 0.35
174
+ r2 = int(r1 + (255 - r1) * factor)
175
+ g2 = int(g1 + (255 - g1) * factor)
176
+ b2 = int(b1 + (255 - b1) * factor)
177
+
178
+ c1 = (r1, g1, b1)
179
+ c2 = (r2, g2, b2)
180
+
181
+ # رسم سایه (Glow) نرم پشت کادر
182
+ shadow_pad = 20
183
+ shadow_layer = Image.new('RGBA', (rw + shadow_pad*2, rh + shadow_pad*2), (0,0,0,0))
184
+ s_draw = ImageDraw.Draw(shadow_layer)
185
+ s_draw.rounded_rectangle([shadow_pad, shadow_pad, shadow_pad+rw, shadow_pad+rh], radius=radius, fill=(r1, g1, b1, 100))
186
+ shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(4))
187
+
188
+ img.paste(shadow_layer, (bx1 - shadow_pad, by1 - shadow_pad + 4), shadow_layer)
189
+
190
+ # پر کردن گرادینت روی بوم موقت
191
+ for y in range(rh):
192
+ for x in range(rw):
193
+ ratio = (x + y) / (rw + rh)
194
+ r = int(c1[0] * (1-ratio) + c2[0] * ratio)
195
+ g = int(c1[1] * (1-ratio) + c2[1] * ratio)
196
+ b = int(c1[2] * (1-ratio) + c2[2] * ratio)
197
+ g_draw.point((x,y), fill=(r,g,b,255))
198
+
199
+ # ساخت ماسک با لبه‌های بسیار هموار و بدون پله‌پله شدن گوشه‌ها (Anti-aliased Mask)
200
+ scale = 4
201
+ mask_large = Image.new('L', (rw * scale, rh * scale), 0)
202
+ ImageDraw.Draw(mask_large).rounded_rectangle([0, 0, rw * scale, rh * scale], radius=radius * scale, fill=255)
203
+ try:
204
+ resampling_filter = Image.Resampling.LANCZOS
205
+ except AttributeError:
206
+ resampling_filter = Image.LANCZOS
207
+ mask = mask_large.resize((rw, rh), resampling_filter)
208
+
209
+ img.paste(grad_img, (bx1, by1), mask)
210
+ else:
211
+ # رسم کادر فعال استایل اینستاگرام با کیفیت بالا و لبه‌های نرم
212
+ draw_aa_rounded_rectangle(img, [bx1, by1, bx2, by2], radius, box_color)
213
+
214
+ # --- رسم متن ---
215
+ for item in words_to_draw:
216
+ is_active = (item["global_idx"] == active_idx) and getattr(style_config, 'useActiveColor', True)
217
+
218
+ text_color = (255, 255, 255, 255)
219
+
220
+ if style_name == "instagram_box":
221
+ custom_hex = style_config.styleColors.get('instagram_box', '#000000')
222
+ u_r, u_g, u_b, u_a = color_parser(custom_hex, (0, 0, 0, 255))
223
+
224
+ if is_active:
225
+ active_hex = style_config.styleActiveColors.get('instagram_box', '#ffffff')
226
+ text_color = color_parser(active_hex, (255, 255, 255, 255))
227
+ else:
228
+ is_past = item["global_idx"] < active_idx
229
+ fade_surr = getattr(style_config, 'fadeSurrounding', False)
230
+ if is_past and fade_surr:
231
+ factor = (u_a / 255.0 * 0.35)
232
+ else:
233
+ factor = (u_a / 255.0) if is_past else (u_a / 255.0 * 0.35)
234
+
235
+ f_r = int((u_r * factor) + (255 * (1 - factor)))
236
+ f_g = int((u_g * factor) + (255 * (1 - factor)))
237
+ f_b = int((u_b * factor) + (255 * (1 - factor)))
238
+ text_color = (f_r, f_g, f_b, 255)
239
+
240
+ elif style_name == "alpha_gradient":
241
+ base_alpha_color = (255, 255, 255, 255)
242
+ custom_color = style_config.styleColors.get('alpha_gradient')
243
+ if custom_color:
244
+ base_alpha_color = color_parser(custom_color, base_alpha_color)
245
+
246
+ r, g, b, a = base_alpha_color
247
+
248
+ if is_active:
249
+ active_hex = style_config.styleActiveColors.get('alpha_gradient', '#ffffff')
250
+ text_color = color_parser(active_hex, (255, 255, 255, 255))
251
+ elif item["global_idx"] < active_idx:
252
+ if getattr(style_config, 'fadeSurrounding', False):
253
+ text_color = (r, g, b, int(a * 0.35))
254
+ else:
255
+ text_color = (r, g, b, a)
256
+ else:
257
+ future_alpha = int(a * 0.35)
258
+ text_color = (r, g, b, future_alpha)
259
+
260
+ if word_infos and item["global_idx"] < len(word_infos):
261
+ w_obj = word_infos[item["global_idx"]]
262
+ if hasattr(w_obj, 'color') and w_obj.color:
263
+ text_color = color_parser(w_obj.color, text_color)
264
+
265
+ # محاسبه مرکز هندسی کادر رسم شده برای این کلمه جهت تراز بی‌نقص
266
+ rect_y1 = item["y"] - int(pad_y * 1.3) + VERTICAL_CORRECTION + INSTA_CENTERING_OFFSET
267
+ rect_y2 = item["y"] + style_config.fontSize + int(pad_y * 1.3) + VERTICAL_CORRECTION + INSTA_CENTERING_OFFSET
268
+ rect_center_y = (rect_y1 + rect_y2) / 2.0
269
+
270
+ # تراز عمودی کاملاً هم‌سطح بر اساس مقادیر ثابت مرجع به جای مقادیر پویای کلمه‌ای
271
+ final_text_y = rect_center_y - standard_text_offset_y - (standard_text_h / 2.0)
272
+
273
+ draw.text((item["x"], final_text_y), item["text"], font=font, fill=text_color, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
styles/classic.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont
2
+
3
+ config = {
4
+ "ids": ["classic", "plain_white", "white_outline", "progressive_write"],
5
+ "name": "Classic Styles",
6
+ "panels": ["color", "font", "size", "position", "radius", "padding"],
7
+ "default_font": "vazir"
8
+ }
9
+
10
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
11
+ style_name = style_config.name
12
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
13
+
14
+ base_text_color = color_parser(style_config.primaryColor, (255, 255, 255, 255))
15
+ stroke_color = color_parser(style_config.outlineColor, (0, 0, 0, 255))
16
+ stroke_width = 0
17
+
18
+ if style_name == "plain_white":
19
+ c_hex = style_config.styleColors.get('plain_white', '#ffffff')
20
+ base_text_color = color_parser(c_hex, (255, 255, 255, 255))
21
+ elif style_name == "white_outline":
22
+ c_hex = style_config.styleColors.get('white_outline', '#ffffff')
23
+ base_text_color = color_parser(c_hex, (255, 255, 255, 255))
24
+
25
+ custom_stroke_hex = style_config.styleBgColors.get('white_outline_stroke', '#000000')
26
+ stroke_color = color_parser(custom_stroke_hex, (0, 0, 0, 255))
27
+
28
+ stroke_width = max(2, int(style_config.fontSize / 12))
29
+ elif style_config.backType == 'outline':
30
+ stroke_width = max(2, int(style_config.fontSize / 12))
31
+
32
+ line_height_px = int(style_config.fontSize * 1.5)
33
+ total_block_height = len(lines) * line_height_px
34
+ bottom_reference = height - style_config.marginV
35
+ start_y_of_block = bottom_reference - total_block_height
36
+
37
+ space_w = draw.textlength(" ", font=font)
38
+ max_line_width = 0
39
+ for m in line_metrics:
40
+ # محاسبه واقعی عرض خط با جمع زدن تک تک کلمات برای دقت صد در صد
41
+ actual_w = sum(m["word_widths"]) + max(0, len(m["words"]) - 1) * space_w
42
+ if actual_w > max_line_width: max_line_width = actual_w
43
+
44
+ # --- رسم کادر کلی (Container) ---
45
+ should_draw_box = False
46
+ box_fill_color = (0,0,0,0)
47
+
48
+ if style_name in ["classic", "progressive_write"] and style_config.backType in ['solid', 'transparent']:
49
+ should_draw_box = True
50
+ fill_color_tuple = color_parser(style_config.outlineColor, (0, 0, 0, 255))
51
+ if style_config.backType == 'transparent' and fill_color_tuple[3] == 255:
52
+ fill_color_tuple = (fill_color_tuple[0], fill_color_tuple[1], fill_color_tuple[2], 160)
53
+ box_fill_color = fill_color_tuple
54
+
55
+ # استخراج محاسبات کادر به بیرون از شرط برای استفاده به عنوان مبنای رسم متن
56
+ ratio = height / width
57
+ box_center_y_adjustment = 0
58
+ if ratio > 1.6: box_center_y_adjustment = 5
59
+ elif ratio > 1.1: box_center_y_adjustment = 10
60
+ else: box_center_y_adjustment = 15
61
+
62
+ box_center_x = width / 2 + style_config.x
63
+ # کاهش 16 پیکسل از عرض کادر برای جمع‌تر شدن در سمت چپ و راست
64
+ box_width = max_line_width + (style_config.paddingX * 2) - 16
65
+
66
+ box_x1 = box_center_x - (box_width / 2)
67
+ box_x2 = box_center_x + (box_width / 2)
68
+ visual_top_correction = int(style_config.fontSize * 0.12)
69
+
70
+ # نقطه مبنای رسم کادر (برای حفظ جایگاه دقیق متن)
71
+ base_y1 = start_y_of_block - style_config.paddingY + box_center_y_adjustment + visual_top_correction
72
+
73
+ # تنظیم دقیق ارتفاع کادر: کاهش تنها ۱۵ درصدی (برای متعادل شدن بالا و پایین کادر)
74
+ box_y1 = base_y1 + (style_config.paddingY * 0.15)
75
+ box_y2 = start_y_of_block + total_block_height + (style_config.paddingY * 0.85) + box_center_y_adjustment - (line_height_px * 0.3)
76
+
77
+ if should_draw_box:
78
+ draw.rounded_rectangle([box_x1, box_y1, box_x2, box_y2], radius=style_config.radius, fill=box_fill_color)
79
+
80
+ # --- رسم متن ---
81
+ # تراز کردن متن نسبت به نقطه مبنا (base_y1) تا با کوچک شدن کادر، متن جابجا نشود
82
+ text_start_y = base_y1 + (style_config.paddingY * 0.35)
83
+ vertical_corr = int(style_config.fontSize * 0.1)
84
+ current_line_y = text_start_y - vertical_corr
85
+
86
+ global_word_counter = 0
87
+
88
+ for line_idx, metrics in enumerate(line_metrics):
89
+ # محاسبه عرض دقیق و واقعی خط برای وسط‌چین شدن کامل درون کادر
90
+ actual_line_width = sum(metrics["word_widths"]) + max(0, len(metrics["words"]) - 1) * space_w
91
+ if is_rtl:
92
+ start_x = (width + actual_line_width) / 2 + style_config.x
93
+ else:
94
+ start_x = (width - actual_line_width) / 2 + style_config.x
95
+ cursor_x = start_x
96
+ text_y_pos = current_line_y
97
+
98
+ for w_i, word in enumerate(metrics["words"]):
99
+ w_len = metrics["word_widths"][w_i]
100
+ word_x = (cursor_x - w_len) if is_rtl else cursor_x
101
+
102
+ # تنظیم رنگ پیش‌فرض
103
+ current_word_color = base_text_color
104
+
105
+ # --- تغییر جدید: اعمال رنگ کلمه فعال برای تمامی استایل‌های کلاسیک و تایپی ---
106
+ if style_name in ["white_outline", "plain_white", "classic", "progressive_write"] and active_idx != -1 and global_word_counter == active_idx and getattr(style_config, 'useActiveColor', True):
107
+ active_hex = style_config.styleActiveColors.get(style_name, '#ffffff')
108
+ current_word_color = color_parser(active_hex, base_text_color)
109
+ # ------------------------------------------------------------
110
+
111
+ current_stroke_fill = stroke_color
112
+ current_stroke_width = stroke_width
113
+
114
+ if word_infos and global_word_counter < len(word_infos):
115
+ w_obj = word_infos[global_word_counter]
116
+ if hasattr(w_obj, 'color') and w_obj.color:
117
+ current_word_color = color_parser(w_obj.color, current_word_color)
118
+
119
+ display_word = word
120
+ draw_x = word_x
121
+ should_draw_this_word = True
122
+
123
+ is_typewriter = getattr(style_config, 'typewriter', False)
124
+
125
+ if style_name == "progressive_write":
126
+ if active_idx != -1 and global_word_counter > active_idx:
127
+ should_draw_this_word = False
128
+ elif is_typewriter and word_infos and global_word_counter < len(word_infos):
129
+ w_info = word_infos[global_word_counter]
130
+ current_t = getattr(style_config, 'current_render_time', 0.0)
131
+ if current_t < w_info.start:
132
+ should_draw_this_word = False
133
+ elif current_t >= w_info.start and current_t < w_info.end:
134
+ char_len = len(word)
135
+ progress = (current_t - w_info.start) / max(0.001, w_info.end - w_info.start)
136
+ visible_chars = min(char_len, int(progress * char_len) + 1)
137
+ display_word = word[:visible_chars]
138
+ try: sub_w = draw.textlength(display_word, font=font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
139
+ except: sub_w = font.getlength(display_word)
140
+ draw_x = (cursor_x - sub_w) if is_rtl else word_x
141
+
142
+ if should_draw_this_word and display_word:
143
+ draw.text((draw_x, text_y_pos), display_word, font=font, fill=current_word_color,
144
+ stroke_width=current_stroke_width, stroke_fill=current_stroke_fill,
145
+ direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
146
+
147
+ # مکان نما باید جلو برود حتی اگر کلمه رسم نشده باشد (تا جای خالی‌اش حفظ شود)
148
+ if is_rtl:
149
+ cursor_x -= (w_len + space_w)
150
+ else:
151
+ cursor_x += (w_len + space_w)
152
+ global_word_counter += 1
153
+
154
+ current_line_y += line_height_px
styles/dark_edges.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont, ImageFilter
2
+
3
+ config = {
4
+ "ids": ["dark_edges"],
5
+ "name": "Dark Edges",
6
+ "panels": ["font", "size", "position", "radius", "padding"],
7
+ "default_font": "vazir"
8
+ }
9
+
10
+ # قالب نمایش زنده در ادیتور (دقیقاً مشابه CSS درخواستی)
11
+ frontend_template = """
12
+ <div style="
13
+ display: inline-block;
14
+ color: #ffffff;
15
+ font-weight: 900;
16
+ padding: calc({{PAD_Y}}px * 0.9) {{PAD_X}}px;
17
+ border-radius: {{RADIUS}}px;
18
+
19
+ /* رنگ پایه آبی */
20
+ background-color: #AA00FF;
21
+
22
+ /* گرادینت لبه‌های تیره */
23
+ background-image: linear-gradient(
24
+ to left,
25
+ rgba(0, 0, 0, 0.90) 0%,
26
+ rgba(255, 255, 255, 0.1) 50%,
27
+ rgba(0, 0, 0, 0.90) 100%
28
+ );
29
+
30
+ border: 1px solid rgba(255, 255, 255, 0.15);
31
+
32
+ /* سایه متن ترکیبی (سخت + نرم) - چهار برابر شده */
33
+ text-shadow:
34
+ 5px 5px 0px rgba(0, 0, 0, 0.9), /* <--- این همان حاشیه سخت در ورودی است */
35
+ 20px 20px 39px rgba(0, 0, 0, 0.8);
36
+
37
+ /* --- اصلاحات برای فاصله خطوط (مثل نوار ساده) --- */
38
+ white-space: normal; /* اجازه دادن به شکستن خط */
39
+ line-height: 1.4; /* ایجاد فاصله مناسب بین خط اول و دوم */
40
+ min-width: max-content; /* حفظ عرض کادر */
41
+
42
+ font-family: inherit;
43
+ transition: all 0.1s;
44
+ text-align: center;
45
+ ">{{WORD}}</div>
46
+ """
47
+
48
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
49
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
50
+ # تنظیمات هندسی از پنل کاربری
51
+ padding_x = style_config.paddingX
52
+ padding_y = style_config.paddingY
53
+ radius = style_config.radius
54
+
55
+ # محاسبه ارتفاع خط و کل بلوک متن
56
+ line_height_px = int(style_config.fontSize * 1.5)
57
+ total_text_height = len(lines) * line_height_px
58
+
59
+ # پیدا کردن عریض‌ترین خط برای ابعاد کادر
60
+ max_text_width = 0
61
+ for m in line_metrics:
62
+ if m["width"] > max_text_width:
63
+ max_text_width = m["width"]
64
+
65
+ # ابعاد نهایی کادر
66
+ box_w = int(max_text_width + (padding_x * 2))
67
+ box_h = int(total_text_height + (padding_y * 1.5)) # ضریب از 2 به 1.5 کاهش یافت تا کادر عمودی جمع‌تر شود
68
+
69
+ # مرکز کادر
70
+ center_x = width / 2 + (style_config.x or 0)
71
+ # موقعیت عمودی (از پایین)
72
+ bottom_margin = style_config.marginV
73
+ box_y2 = height - bottom_margin
74
+ box_y1 = box_y2 - box_h
75
+ box_x1 = int(center_x - (box_w / 2))
76
+ box_x2 = int(center_x + (box_w / 2))
77
+
78
+ # ---------------------------------------------------------
79
+ # 1. ساخت کادر با گرادینت اختصاصی (Back-end rendering)
80
+ # ---------------------------------------------------------
81
+
82
+ # دریافت رنگ کادر کاملاً مستقل از بخش حاشیه با پیش‌فرض بنفش نئونی
83
+ base_color_hex = style_config.styleBgColors.get('dark_edges', '#AA00FF')
84
+
85
+ base_bg_color = color_parser(base_color_hex, (0, 0, 255, 255))
86
+ # اجبار به توپر بودن (Alpha=255) برای جلوگیری از حالت شیشه‌ای
87
+ base_bg_color = (base_bg_color[0], base_bg_color[1], base_bg_color[2], 255)
88
+
89
+ # ب) ساخت گرادینت (سیاه در لبه‌ها، شفاف/سفید در وسط)
90
+ # سایز گرادینت دقیقاً اندازه کادر است
91
+ gradient_img = Image.new('RGBA', (box_w, box_h), (0,0,0,0))
92
+ g_draw = ImageDraw.Draw(gradient_img)
93
+
94
+ # رنگ لبه‌ها: سیاهی کامل برای عمق بیشتر
95
+ edge_color = (0, 0, 0, 250)
96
+ # رنگ وسط: کاملاً شفاف (سفیدی حذف شد تا رنگ بنفش، روغنی و غلیظ بماند)
97
+ center_color = (0, 0, 0, 0)
98
+
99
+ # رسم گرادینت خطی افقی (پیکسل به پیکسل برای دقت بالا)
100
+ # چون گرادینت افقی است، یک خط را محاسبه می‌کنیم و بسط می‌دهیم (برای سرعت)
101
+ line_gradient = Image.new('RGBA', (box_w, 1))
102
+ ld = line_gradient.load()
103
+
104
+ for x in range(box_w):
105
+ # موقعیت نسبی (0 تا 1)
106
+ ratio = x / box_w
107
+
108
+ # محاسبه فاصله از لبه‌ها (0 در لبه‌ها، 1 در وسط)
109
+ # اما گرادینت CSS می‌گوید: چپ تیره، وسط روشن، راست تیره
110
+ # اگر ratio < 0.5 (نیمه چپ): از تیره به روشن
111
+ if ratio <= 0.5:
112
+ p = ratio * 2 # 0 -> 1
113
+ r = int(edge_color[0] + (center_color[0] - edge_color[0]) * p)
114
+ g = int(edge_color[1] + (center_color[1] - edge_color[1]) * p)
115
+ b = int(edge_color[2] + (center_color[2] - edge_color[2]) * p)
116
+ a = int(edge_color[3] + (center_color[3] - edge_color[3]) * p)
117
+ else:
118
+ # نیمه راست: از روشن به تیره
119
+ p = (ratio - 0.5) * 2 # 0 -> 1
120
+ r = int(center_color[0] + (edge_color[0] - center_color[0]) * p)
121
+ g = int(center_color[1] + (edge_color[1] - center_color[1]) * p)
122
+ b = int(center_color[2] + (edge_color[2] - center_color[2]) * p)
123
+ a = int(center_color[3] + (edge_color[3] - center_color[3]) * p)
124
+
125
+ ld[x, 0] = (r, g, b, a) # <--- شفافیت اصلی (a) را برمی‌گردانیم
126
+
127
+ # بسط دادن گرادینت به کل ارتفاع کادر
128
+ gradient_img = line_gradient.resize((box_w, box_h))
129
+
130
+ # ج) ترکیب رنگ آبی با گرادینت (اصلاح شده برای حذف کامل شفافیت)
131
+ # 1. استخراج مقادیر رنگ RGB (حذف هرگونه آلفای قبلی)
132
+ bg_r = int(base_bg_color[0])
133
+ bg_g = int(base_bg_color[1])
134
+ bg_b = int(base_bg_color[2])
135
+
136
+ # 2. ساخت بوم اصلی
137
+ box_canvas = Image.new('RGBA', (box_w, box_h), (bg_r, bg_g, bg_b, 255))
138
+
139
+ # 3. چسباندن گرادینت
140
+ box_canvas.paste(gradient_img, (0, 0), gradient_img)
141
+
142
+ # 4. *** تضمین قطعی: حذف هرگونه شفافیت از وسط کادر ***
143
+ # کانال‌های رنگ را جدا می‌کنیم و کانال آلفا را با سفید خالص (255) جایگزین می‌کنیم
144
+ r, g, b, _ = box_canvas.split()
145
+ solid_alpha = Image.new('L', (box_w, box_h), 255)
146
+ box_canvas = Image.merge('RGBA', (r, g, b, solid_alpha))
147
+
148
+ # د) رسم حاشیه (Border) 1px solid rgba(255, 255, 255, 0.15)
149
+ border_color = (255, 255, 255, 38) # ~0.15 * 255
150
+ b_draw = ImageDraw.Draw(box_canvas)
151
+ b_draw.rounded_rectangle([0, 0, box_w-1, box_h-1], radius=radius, outline=border_color, width=1)
152
+
153
+ # ه) اعمال گوشه‌های گرد (Masking)
154
+ mask = Image.new('L', (box_w, box_h), 0)
155
+ ImageDraw.Draw(mask).rounded_rectangle([0, 0, box_w, box_h], radius=radius, fill=255)
156
+
157
+ # چسباندن کادر نهایی روی تصویر اصلی
158
+ img.paste(box_canvas, (box_x1, int(box_y1)), mask)
159
+
160
+ # ---------------------------------------------------------
161
+ # 2. رسم متن با سایه‌های خاص (Shadows)
162
+ # ---------------------------------------------------------
163
+
164
+ # محاسبه نقطه شروع متن
165
+ text_start_y = box_y1 + (padding_y * 0.75) # برای هماهنگی با کادر کوچک شده، نقطه شروع متن بالا برده شد
166
+ # اصلاح جزئی برای وسط‌چین عمودی دقیق‌تر فونت وزیر
167
+ vertical_correction = int(style_config.fontSize * 0.1)
168
+
169
+ # رنگ متن (سفید)
170
+ # دریافت رنگ متن اختصاصی از تنظیمات (پیش‌فرض سفید)
171
+ custom_text_hex = style_config.styleColors.get('dark_edges', '#ffffff')
172
+ text_fill = color_parser(custom_text_hex, (255, 255, 255, 255))
173
+
174
+ current_text_y = text_start_y - vertical_correction
175
+ space_w = draw.textlength(" ", font=font)
176
+ global_word_counter = 0
177
+
178
+ for line_idx, metrics in enumerate(line_metrics):
179
+ # وسط‌چین کردن افقی
180
+ start_x = center_x
181
+ # حرکت به سمت راست به اندازه نصف متن (چون راست‌چین می‌نویسیم و cursor کم می‌شود)
182
+ # اما منطق برنامه شما: cursor از وسط + نصف عرض شروع میکند و کم میکند
183
+ cursor_x = (start_x + (metrics["width"] / 2)) if is_rtl else (start_x - (metrics["width"] / 2))
184
+
185
+ for w_i, word in enumerate(metrics["words"]):
186
+ w_len = metrics["word_widths"][w_i]
187
+ word_x = int(cursor_x - w_len) if is_rtl else int(cursor_x)
188
+ word_y = int(current_text_y)
189
+
190
+ # ۱. ابتدا محاسبه رنگ کلمه (فعال یا اختصاصی)
191
+ is_active = (global_word_counter == active_idx) and getattr(style_config, 'useActiveColor', True)
192
+ active_hex = style_config.styleActiveColors.get('dark_edges', '#FFEB3B')
193
+ current_word_color = color_parser(active_hex, (255, 255, 255, 255)) if is_active else text_fill
194
+
195
+ if word_infos and global_word_counter < len(word_infos):
196
+ w_obj = word_infos[global_word_counter]
197
+ if hasattr(w_obj, 'color') and w_obj.color:
198
+ current_word_color = color_parser(w_obj.color, text_fill)
199
+
200
+ # منطق تایپ حرف به حرف برای بک‌اند
201
+ display_word = word
202
+ draw_x = word_x
203
+ should_draw = True
204
+
205
+ is_typewriter = getattr(style_config, 'typewriter', False)
206
+ if is_typewriter and word_infos and global_word_counter < len(word_infos):
207
+ w_info = word_infos[global_word_counter]
208
+ current_t = getattr(style_config, 'current_render_time', 0.0)
209
+ if current_t < w_info.start:
210
+ should_draw = False
211
+ elif current_t >= w_info.start and current_t < w_info.end:
212
+ char_len = len(word)
213
+ progress = (current_t - w_info.start) / max(0.001, w_info.end - w_info.start)
214
+ visible_chars = min(char_len, int(progress * char_len) + 1)
215
+ display_word = word[:visible_chars]
216
+ try: sub_w = draw.textlength(display_word, font=font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
217
+ except: sub_w = font.getlength(display_word)
218
+ draw_x = int(cursor_x - sub_w) if is_rtl else draw_x
219
+
220
+ # ۲. فقط اگر شفافیت بیشتر از صفر بود، سایه‌ها و متن را رسم کن
221
+ if current_word_color[3] > 0 and should_draw and display_word:
222
+ # --- رسم سایه‌ها ---
223
+ blur_margin = 30
224
+ shadow_layer = Image.new('RGBA', (int(w_len) + blur_margin*2, int(line_height_px) + blur_margin*2), (0,0,0,0))
225
+ sl_draw = ImageDraw.Draw(shadow_layer)
226
+ sl_draw.text((blur_margin + (draw_x - word_x), blur_margin), display_word, font=font, fill=(0, 0, 0, 204), direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
227
+ shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(3))
228
+ img.paste(shadow_layer, (word_x + 3 - blur_margin, word_y + 3 - blur_margin), shadow_layer)
229
+
230
+ draw.text((draw_x + 1, word_y + 1), display_word, font=font, fill=(0, 0, 0, 230), direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
231
+
232
+ # --- رسم متن اصلی ---
233
+ tr, tg, tb, ta = current_word_color
234
+ br, bg, bb, _ = base_bg_color
235
+ ratio = ta / 255.0
236
+ blended_color = (
237
+ int(tr * ratio + br * (1 - ratio)),
238
+ int(tg * ratio + bg * (1 - ratio)),
239
+ int(tb * ratio + bb * (1 - ratio)),
240
+ 255
241
+ )
242
+
243
+ draw.text((draw_x, word_y), display_word, font=font, fill=blended_color, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
244
+
245
+ cursor_x += (-(w_len + space_w) if is_rtl else (w_len + space_w))
246
+ global_word_counter += 1
247
+
248
+ current_text_y += line_height_px
styles/falling_karaoke.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont
2
+
3
+ config = {
4
+ "ids": ["falling_karaoke"],
5
+ "name": "Falling Karaoke",
6
+ "panels": ["color", "font", "size", "position", "radius", "padding"],
7
+ "default_font": "vazir"
8
+ }
9
+
10
+ frontend_template = "" # فرانت‌اند از طریق JS هندل می‌شود
11
+
12
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
13
+ style_name = style_config.name
14
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
15
+
16
+ # رنگ کادر ثابت (پیش‌فرض: #F77D57)
17
+ custom_bg = style_config.styleBgColors.get('falling_karaoke', '#F77D57')
18
+ box_color = color_parser(custom_bg, (247, 125, 87, 255))
19
+
20
+ # رنگ متن گذشته (خاموش)
21
+ custom_text = style_config.styleColors.get('falling_karaoke', '#FFFFFF')
22
+ past_text_color = color_parser(custom_text, (255, 255, 255, 255))
23
+
24
+ # رنگ متن فعال (روشن)
25
+ active_hex = style_config.styleActiveColors.get('falling_karaoke', '#FFFFFF')
26
+ active_text_color = color_parser(active_hex, (255, 255, 255, 255))
27
+
28
+ line_height_px = int(style_config.fontSize * 1.1) + int(style_config.paddingY * 2.2)
29
+ total_block_height = len(lines) * line_height_px
30
+ bottom_reference = height - style_config.marginV
31
+ start_y_of_block = bottom_reference - total_block_height
32
+
33
+ pad_x = style_config.paddingX
34
+ pad_y = style_config.paddingY
35
+ space_w = draw.textlength(" ", font=font)
36
+
37
+ try:
38
+ ref_bbox = draw.textbbox((0, 0), "سلام", font=font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
39
+ standard_text_visual_center_y = (ref_bbox[1] + ref_bbox[3]) / 2.0
40
+ standard_text_offset_y = ref_bbox[1]
41
+ standard_text_h = ref_bbox[3] - ref_bbox[1]
42
+ except:
43
+ standard_text_visual_center_y = style_config.fontSize / 2.0
44
+ standard_text_offset_y = 0
45
+ standard_text_h = style_config.fontSize
46
+
47
+ words_to_draw = []
48
+ global_word_counter = 0
49
+ temp_y = start_y_of_block
50
+
51
+ # ۱. محاسبه موقعیت دقیق هندسی تک‌تک کلمات در تصویر
52
+ for line_idx, metrics in enumerate(line_metrics):
53
+ line_start_idx = sum(len(m["words"]) for m in line_metrics[:line_idx])
54
+ is_active_on_line = (active_idx >= line_start_idx and active_idx < line_start_idx + len(metrics["words"]))
55
+
56
+ dynamic_line_width = metrics["width"] + (pad_x * 2 if is_active_on_line else 0)
57
+ if is_rtl:
58
+ cursor_x = (width + dynamic_line_width) / 2 + (style_config.x or 0)
59
+ else:
60
+ cursor_x = (width - dynamic_line_width) / 2 + (style_config.x or 0)
61
+ line_center_y = temp_y + (line_height_px / 2.0)
62
+
63
+ for w_i, word in enumerate(metrics["words"]):
64
+ g_idx = line_start_idx + w_i
65
+ w_len = metrics["word_widths"][w_i]
66
+ text_y_pos = line_center_y - standard_text_visual_center_y
67
+ current_word_padding = pad_x if g_idx == active_idx else 0
68
+ if is_rtl:
69
+ word_x = cursor_x - w_len - current_word_padding
70
+ else:
71
+ word_x = cursor_x + current_word_padding
72
+
73
+ words_to_draw.append({
74
+ "text": word, "x": word_x, "y": text_y_pos, "width": w_len,
75
+ "global_idx": global_word_counter
76
+ })
77
+
78
+ if is_rtl:
79
+ cursor_x -= (w_len + space_w + (current_word_padding * 2))
80
+ else:
81
+ cursor_x += (w_len + space_w + (current_word_padding * 2))
82
+ global_word_counter += 1
83
+ temp_y += line_height_px
84
+
85
+ VERTICAL_CORRECTION = int(style_config.fontSize * 0.22)
86
+ current_render_time = getattr(style_config, 'current_render_time', 0.0)
87
+
88
+ # ۲. رسم کادرهای پس‌زمینه با افکت آمپول (رنگ تدریجی فلوئید بین کلمه قبلی و جدید در هر فریم)
89
+ for item in words_to_draw:
90
+ if word_infos and item["global_idx"] < len(word_infos):
91
+ w_info = word_infos[item["global_idx"]]
92
+
93
+ # کاهش زمان ترنزیشن به 0.15 برای روانی و تیزی بیشتر هنگام پرش روی کلمات
94
+ bg_opacity = 0.0
95
+ TRANSITION_DUR = 0.15
96
+
97
+ if current_render_time >= w_info.start:
98
+ if current_render_time < w_info.end:
99
+ time_since_start = current_render_time - w_info.start
100
+ if time_since_start < TRANSITION_DUR:
101
+ bg_opacity = time_since_start / TRANSITION_DUR # افزایش تدریجی شفافیت کادر
102
+ else:
103
+ bg_opacity = 1.0
104
+ else:
105
+ time_since_end = current_render_time - w_info.end
106
+ if time_since_end < TRANSITION_DUR:
107
+ bg_opacity = 1.0 - (time_since_end / TRANSITION_DUR) # کاهش تدریجی شفافیت کادر قبلی
108
+ else:
109
+ bg_opacity = 0.0
110
+
111
+ if bg_opacity > 0.0:
112
+ rect_y1 = item["y"] - int(pad_y * 1.3) + VERTICAL_CORRECTION
113
+ rect_y2 = item["y"] + style_config.fontSize + int(pad_y * 1.3) + VERTICAL_CORRECTION
114
+ bx1, by1 = int(item["x"] - pad_x), int(rect_y1)
115
+ bx2, by2 = int(item["x"] + item["width"] + pad_x), int(rect_y2)
116
+
117
+ # اعمال شفافیت داینامیک فریم‌به‌فریم روی کادرها
118
+ final_box_color = (box_color[0], box_color[1], box_color[2], int(box_color[3] * bg_opacity))
119
+ draw.rounded_rectangle([bx1, by1, bx2, by2], radius=style_config.radius, fill=final_box_color)
120
+
121
+ # ۳. رسم حروف و کلمات با انیمیشن فرود از ارتفاع -45px
122
+ for item in words_to_draw:
123
+ should_draw = False
124
+ y_offset = 0
125
+ alpha_factor = 1.0
126
+ is_active = False
127
+
128
+ if word_infos and item["global_idx"] < len(word_infos):
129
+ w_info = word_infos[item["global_idx"]]
130
+ if current_render_time >= w_info.start:
131
+ should_draw = True
132
+ is_active = current_render_time < w_info.end
133
+ if is_active:
134
+ time_passed = current_render_time - w_info.start
135
+ anim_dur = 0.25 # سرعت فرود کمی تندتر شد تا با فرم ریت جدید هماهنگ باشد
136
+ if time_passed < anim_dur:
137
+ progress = time_passed / anim_dur
138
+ ease = 1 - (1 - progress) * (1 - progress)
139
+ y_offset = -45 * (1 - ease) # انیمیشن فرود کلمه از بالای کادر
140
+ alpha_factor = ease
141
+ else:
142
+ y_offset = 0
143
+ alpha_factor = 1.0
144
+ else:
145
+ should_draw = False # مخفی ماندن کلمات آینده
146
+ elif active_idx != -1:
147
+ if item["global_idx"] <= active_idx:
148
+ should_draw = True
149
+ is_active = (item["global_idx"] == active_idx)
150
+
151
+ if should_draw:
152
+ fill_color = active_text_color if (is_active and getattr(style_config, 'useActiveColor', True)) else past_text_color
153
+ if word_infos and item["global_idx"] < len(word_infos):
154
+ w_obj = word_infos[item["global_idx"]]
155
+ if hasattr(w_obj, 'color') and w_obj.color:
156
+ fill_color = color_parser(w_obj.color, fill_color)
157
+
158
+ final_alpha = int(fill_color[3] * alpha_factor)
159
+ final_color = (fill_color[0], fill_color[1], fill_color[2], final_alpha)
160
+
161
+ rect_y1 = item["y"] - int(pad_y * 1.3) + VERTICAL_CORRECTION
162
+ rect_y2 = item["y"] + style_config.fontSize + int(pad_y * 1.3) + VERTICAL_CORRECTION
163
+ rect_center_y = (rect_y1 + rect_y2) / 2.0
164
+ final_text_y = rect_center_y - standard_text_offset_y - (standard_text_h / 2.0)
165
+
166
+ # رسم متن روی کادرها
167
+ draw.text((item["x"], final_text_y + y_offset), item["text"], font=font, fill=final_color, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
styles/falling_plain.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont
2
+
3
+ config = {
4
+ "ids": ["falling_plain"],
5
+ "name": "Falling Plain",
6
+ "panels": ["color", "font", "size", "position"],
7
+ "default_font": "vazir"
8
+ }
9
+
10
+ frontend_template = ""
11
+
12
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
13
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
14
+ # رنگ متن پایه (خاموش)
15
+ custom_text = style_config.styleColors.get('falling_plain', '#FFFFFF')
16
+ base_text_color = color_parser(custom_text, (255, 255, 255, 255))
17
+
18
+ # رنگ کلمه فعال (روشن)
19
+ active_hex = style_config.styleActiveColors.get('falling_plain', '#FFEB3B')
20
+ active_text_color = color_parser(active_hex, (255, 235, 59, 255))
21
+
22
+ line_height_px = int(style_config.fontSize * 1.5)
23
+ total_block_height = len(lines) * line_height_px
24
+ bottom_reference = height - style_config.marginV
25
+ start_y_of_block = bottom_reference - total_block_height
26
+
27
+ space_w = draw.textlength(" ", font=font)
28
+
29
+ try:
30
+ ref_bbox = draw.textbbox((0, 0), "سلام", font=font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
31
+ standard_text_visual_center_y = (ref_bbox[1] + ref_bbox[3]) / 2.0
32
+ standard_text_offset_y = ref_bbox[1]
33
+ standard_text_h = ref_bbox[3] - ref_bbox[1]
34
+ except:
35
+ standard_text_visual_center_y = style_config.fontSize / 2.0
36
+ standard_text_offset_y = 0
37
+ standard_text_h = style_config.fontSize
38
+
39
+ words_to_draw = []
40
+ global_word_counter = 0
41
+ temp_y = start_y_of_block
42
+
43
+ # محاسبه موقعیت هندسی تک‌تک کلمات در تصویر
44
+ for line_idx, metrics in enumerate(line_metrics):
45
+ line_start_idx = sum(len(m["words"]) for m in line_metrics[:line_idx])
46
+ cursor_x = (width + metrics["width"]) / 2 + (style_config.x or 0) if is_rtl else (width - metrics["width"]) / 2 + (style_config.x or 0)
47
+ line_center_y = temp_y + (line_height_px / 2.0)
48
+
49
+ for w_i, word in enumerate(metrics["words"]):
50
+ g_idx = line_start_idx + w_i
51
+ w_len = metrics["word_widths"][w_i]
52
+ text_y_pos = line_center_y - standard_text_visual_center_y
53
+ word_x = (cursor_x - w_len) if is_rtl else cursor_x
54
+
55
+ words_to_draw.append({
56
+ "text": word, "x": word_x, "y": text_y_pos, "width": w_len,
57
+ "global_idx": global_word_counter
58
+ })
59
+
60
+ cursor_x += (-(w_len + space_w) if is_rtl else (w_len + space_w))
61
+ global_word_counter += 1
62
+ temp_y += line_height_px
63
+
64
+ VERTICAL_CORRECTION = int(style_config.fontSize * 0.1)
65
+ current_render_time = getattr(style_config, 'current_render_time', 0.0)
66
+
67
+ # رسم کلمات با انیمیشن فرود از ارتفاع -45px
68
+ for item in words_to_draw:
69
+ should_draw = False
70
+ y_offset = 0
71
+ alpha_factor = 1.0
72
+ is_active = False
73
+
74
+ if word_infos and item["global_idx"] < len(word_infos):
75
+ w_info = word_infos[item["global_idx"]]
76
+ if current_render_time >= w_info.start:
77
+ should_draw = True
78
+ is_active = current_render_time < w_info.end
79
+
80
+ # اعمال انیمیشن سقوط برای کلمه فعال
81
+ if is_active:
82
+ time_passed = current_render_time - w_info.start
83
+ anim_dur = 0.25
84
+ if time_passed < anim_dur:
85
+ progress = time_passed / anim_dur
86
+ ease = 1 - (1 - progress) * (1 - progress)
87
+ y_offset = -45 * (1 - ease)
88
+ alpha_factor = ease
89
+ else:
90
+ y_offset = 0
91
+ alpha_factor = 1.0
92
+ else:
93
+ y_offset = 0
94
+ alpha_factor = 1.0
95
+ else:
96
+ # کلمات جلو (آینده) به هیچ عنوان ترسیم نمی‌شوند
97
+ should_draw = False
98
+ elif active_idx != -1:
99
+ if item["global_idx"] <= active_idx:
100
+ should_draw = True
101
+ is_active = (item["global_idx"] == active_idx)
102
+ else:
103
+ # کلمات آینده در حالت بدون فریم‌به‌فریم هم ترسیم نمی‌شوند
104
+ should_draw = False
105
+
106
+ if should_draw:
107
+ # اعمال کم‌رنگ شدن کلمات قبل (گذشته) در صورت فعال بودن دکمه
108
+ if not is_active and word_infos and item["global_idx"] < len(word_infos):
109
+ w_info = word_infos[item["global_idx"]]
110
+ if current_render_time >= w_info.end and getattr(style_config, 'fadeSurrounding', False):
111
+ alpha_factor = 0.35
112
+
113
+ fill_color = active_text_color if (is_active and getattr(style_config, 'useActiveColor', True)) else base_text_color
114
+ if word_infos and item["global_idx"] < len(word_infos):
115
+ w_obj = word_infos[item["global_idx"]]
116
+ if hasattr(w_obj, 'color') and w_obj.color:
117
+ fill_color = color_parser(w_obj.color, fill_color)
118
+
119
+ final_alpha = int(fill_color[3] * alpha_factor)
120
+ final_color = (fill_color[0], fill_color[1], fill_color[2], final_alpha)
121
+
122
+ final_text_y = item["y"] + VERTICAL_CORRECTION
123
+
124
+ draw.text((item["x"], final_text_y + y_offset), item["text"], font=font, fill=final_color, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
styles/falling_words.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont
2
+
3
+ config = {
4
+ "ids": ["falling_words"],
5
+ "name": "Falling Words",
6
+ "panels": ["color", "font", "size", "position", "radius", "padding"],
7
+ "default_font": "vazir"
8
+ }
9
+
10
+ # قالب نمایش زنده در مرورگر (با انیمیشن CSS)
11
+ frontend_template = """
12
+ <span style="
13
+ display: inline-block;
14
+ vertical-align: middle;
15
+ margin: 0 4px;
16
+ animation: dropIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
17
+ opacity: 0;
18
+ transform: translateY(-20px);
19
+ font-family: inherit;
20
+ ">
21
+ {{WORD}}
22
+ </span>
23
+ <style>
24
+ @keyframes dropIn {
25
+ 0% { opacity: 0; transform: translateY(-30px); }
26
+ 100% { opacity: 1; transform: translateY(0); }
27
+ }
28
+ </style>
29
+ """
30
+
31
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
32
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
33
+ # دریافت رنگ متن فقط از تنظیمات اختصاصی (پیش‌فرض سفید مطلق) - قطع ارتباط با استایل‌های دیگر
34
+ text_hex = style_config.styleColors.get('falling_words', '#FFFFFF')
35
+ base_text_color = color_parser(text_hex, (255, 255, 255, 255))
36
+
37
+ # خواندن رنگ کادر از استایل (پیش‌فرض نارنجی F77D57)
38
+ custom_bg_hex = style_config.styleBgColors.get('falling_words', '#F77D57')
39
+ box_color = color_parser(custom_bg_hex, (247, 125, 87, 255))
40
+
41
+ line_height_px = int(style_config.fontSize * 1.5)
42
+ total_block_height = len(lines) * line_height_px
43
+ bottom_reference = height - style_config.marginV
44
+ start_y_of_block = bottom_reference - total_block_height
45
+
46
+ # محاسبه عرض کادر
47
+ max_line_width = 0
48
+ for m in line_metrics:
49
+ if m["width"] > max_line_width: max_line_width = m["width"]
50
+
51
+ box_center_x = width / 2 + (style_config.x or 0)
52
+ box_width = max_line_width + (style_config.paddingX * 2)
53
+
54
+ # ۱. محاسبه نسبت ابعاد ویدیو
55
+ ratio = height / width
56
+
57
+ # ۲. تنظیم موقعیت عمودی (اگر عدد مثبت باشد پایین می‌رود و اگر منفی باشد بالا)
58
+ if ratio > 1.6:
59
+ # مخصوص ویدیوهای عمودی (طبق تست شما: ۱۲)
60
+ box_center_y_adjustment = 12
61
+ elif ratio > 1.1:
62
+ # مخصوص ویدیوهای مربعی
63
+ box_center_y_adjustment = 8
64
+ else:
65
+ # مخصوص ویدیوهای افقی
66
+ box_center_y_adjustment = 5
67
+
68
+ # ۳. اصلاحیه بصری سقف کادر (تراز عمودی متن)
69
+ visual_top_correction = int(style_config.fontSize * 0.12)
70
+
71
+ # ۳. لبه‌های چپ و راست
72
+ box_x1 = box_center_x - (box_width / 2)
73
+ box_x2 = box_center_x + (box_width / 2)
74
+
75
+ # ۴. مختصات سقف و کف (فرمول دقیق کلاسیک)
76
+ box_y1 = start_y_of_block - style_config.paddingY + box_center_y_adjustment + visual_top_correction
77
+ box_y2 = start_y_of_block + total_block_height + style_config.paddingY + box_center_y_adjustment - (line_height_px * 0.3)
78
+
79
+ # --- رفع باگ: جلوگیری از منفی شدن ارتفاع کادر در پدینگ‌های کم ---
80
+ if box_y2 <= box_y1:
81
+ box_y2 = box_y1 + 1 # حداقل ۱ پیکسل ارتفاع داشته باشد تا ارور ندهد
82
+
83
+ # رسم کادر (شرط len(lines) اضافه شد تا در سکوت اصلا کادر نکشد)
84
+ if len(lines) > 0 and style_config.backType in ['solid', 'transparent']:
85
+ if style_config.backType == 'transparent' and box_color[3] == 255:
86
+ box_color = (box_color[0], box_color[1], box_color[2], 160)
87
+ draw.rounded_rectangle([box_x1, box_y1, box_x2, box_y2], radius=style_config.radius, fill=box_color)
88
+
89
+ # --- رسم کلمات با منطق الگوبرداری شده از آبی نئون ---
90
+ text_start_y = box_y1 + (style_config.paddingY * 0.35)
91
+ vertical_corr = int(style_config.fontSize * 0.1)
92
+ current_line_y = text_start_y - vertical_corr
93
+
94
+ space_w = draw.textlength(" ", font=font)
95
+ global_word_counter = 0
96
+ current_render_time = getattr(style_config, 'current_render_time', 0.0)
97
+
98
+ for line_idx, enumerate_metrics in enumerate(line_metrics):
99
+ metrics = enumerate_metrics
100
+ start_x = (width + metrics["width"]) / 2 + (style_config.x or 0) if is_rtl else (width - metrics["width"]) / 2 + (style_config.x or 0)
101
+ cursor_x = start_x
102
+ text_y_pos = current_line_y
103
+
104
+ for w_i, word in enumerate(metrics["words"]):
105
+ w_len = metrics["word_widths"][w_i]
106
+ word_x = (cursor_x - w_len) if is_rtl else cursor_x
107
+
108
+ should_draw = False
109
+ y_offset = 0
110
+ alpha_factor = 1.0
111
+
112
+ # منطق اصلی نمایش کلمه
113
+ if word_infos and global_word_counter < len(word_infos):
114
+ w_info = word_infos[global_word_counter]
115
+
116
+ if current_render_time >= w_info.start:
117
+ should_draw = True
118
+ # انیمیشن سقوط فقط برای کلمه‌ای که تازه شروع شده یا در حال اجراست
119
+ time_passed = current_render_time - w_info.start
120
+ anim_dur = 0.3 # زمان سقوط (ثانیه)
121
+
122
+ if time_passed < anim_dur:
123
+ progress = time_passed / anim_dur
124
+ # انیمیشن نرم Ease-Out
125
+ ease = 1 - (1 - progress) * (1 - progress)
126
+ y_offset = -40 * (1 - ease)
127
+ alpha_factor = ease
128
+ else:
129
+ y_offset = 0
130
+ alpha_factor = 1.0
131
+ else:
132
+ # کلماتی که هنوز زمانشان نرسیده نباید رسم شوند
133
+ should_draw = False
134
+ else:
135
+ # بک‌آپ برای حالتی که اطلاعات کلمه نیست (در ادیتور)
136
+ if active_idx != -1:
137
+ should_draw = (global_word_counter <= active_idx)
138
+ else:
139
+ should_draw = False # اصلاح مشکل نمایش یکباره: در گپ‌ها چیزی نشان نده
140
+
141
+ if should_draw:
142
+ # تعیین رنگ پایه
143
+ this_color = base_text_color
144
+
145
+ # اگر کلمه فعال است (زمان رندر بین شروع و پایان کلمه است)
146
+ if getattr(style_config, 'useActiveColor', True) and w_info and current_render_time >= w_info.start and current_render_time < w_info.end:
147
+ active_hex = style_config.styleActiveColors.get('falling_words', '#FFEB3B')
148
+ this_color = color_parser(active_hex, (255, 235, 59, 255))
149
+ if word_infos and global_word_counter < len(word_infos):
150
+ w_obj = word_infos[global_word_counter]
151
+ if hasattr(w_obj, 'color') and w_obj.color:
152
+ this_color = color_parser(w_obj.color, this_color)
153
+
154
+ # ترکیب شفافیت انیمیشن با شفافیت اصلی رنگ
155
+ final_alpha = int(this_color[3] * alpha_factor)
156
+ final_color = (this_color[0], this_color[1], this_color[2], final_alpha)
157
+
158
+ # حذف آفست‌های مخرب و هاردکد شده (متن کاملاً تابع کادر است)
159
+ draw.text((word_x, text_y_pos + y_offset), word, font=font, fill=final_color, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
160
+
161
+ cursor_x += (-(w_len + space_w) if is_rtl else (w_len + space_w))
162
+ global_word_counter += 1
163
+
164
+ current_line_y += line_height_px
styles/karaoke.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont
2
+
3
+ config = {
4
+ # تغییر: حذف "progressive_write" از اینجا، چون جایش اینجا نیست
5
+ "ids": ["karaoke_static", "auto_director"],
6
+ "name": "Dynamic Karaoke",
7
+ "panels": ["color", "font", "size", "position", "radius", "padding"],
8
+ "default_font": "lalezar"
9
+ }
10
+
11
+ frontend_template = """
12
+ <span style="display: inline-block; vertical-align: middle; margin: 0 5px; color: {{COLOR}} !important; opacity: {{OPACITY}}; transition: all 0.1s; font-family: inherit;">{{WORD}}</span>
13
+ """
14
+
15
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
16
+ style_name = style_config.name
17
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
18
+
19
+ # خواندن رنگ کادر از دیکشنری رنگ‌های پس‌زمینه
20
+ custom_bg = style_config.styleBgColors.get('karaoke_static', '#A020F0')
21
+ base_color = color_parser(custom_bg, (160, 32, 240, 255))
22
+
23
+ line_height_px = int(style_config.fontSize * 1.1) + int(style_config.paddingY * 2.2)
24
+ total_block_height = len(lines) * line_height_px
25
+ bottom_reference = height - style_config.marginV
26
+ start_y_of_block = bottom_reference - total_block_height
27
+
28
+ pad_x = style_config.paddingX
29
+ pad_y = style_config.paddingY
30
+
31
+ space_w = draw.textlength(" ", font=font)
32
+
33
+ # محاسبه متریک‌های استاندارد فونت بر اساس کلمه مرجع جهت تراز یکدست عمودی تمام کلمات
34
+ ref_word = "سلام"
35
+ try:
36
+ ref_bbox = draw.textbbox((0, 0), ref_word, font=font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
37
+ standard_text_visual_center_y = (ref_bbox[1] + ref_bbox[3]) / 2.0
38
+ standard_text_offset_y = ref_bbox[1]
39
+ standard_text_h = ref_bbox[3] - ref_bbox[1]
40
+ except:
41
+ standard_text_visual_center_y = style_config.fontSize / 2.0
42
+ standard_text_offset_y = 0
43
+ standard_text_h = style_config.fontSize
44
+
45
+ # 1. محاسبه مختصات همه کلمات با تراز دقیق
46
+ words_to_draw = []
47
+ global_word_counter = 0
48
+ temp_y = start_y_of_block
49
+
50
+ for line_idx, metrics in enumerate(line_metrics):
51
+ line_start_idx = sum(len(m["words"]) for m in line_metrics[:line_idx])
52
+ is_active_on_line = (active_idx >= line_start_idx and active_idx < line_start_idx + len(metrics["words"]))
53
+
54
+ dynamic_line_width = metrics["width"] + (pad_x * 2 if is_active_on_line else 0)
55
+ if is_rtl:
56
+ cursor_x = (width + dynamic_line_width) / 2 + style_config.x
57
+ else:
58
+ cursor_x = (width - dynamic_line_width) / 2 + style_config.x
59
+
60
+ # مرکز عمودی دقیق و هندسی این خط فعلی
61
+ line_center_y = temp_y + (line_height_px / 2.0)
62
+
63
+ for w_i, word in enumerate(metrics["words"]):
64
+ g_idx = line_start_idx + w_i
65
+ w_len = metrics["word_widths"][w_i]
66
+
67
+ # استفاده از مقدار ثابت برای جلوگیری از نوسان عمودی کلمات خط
68
+ text_y_pos = line_center_y - standard_text_visual_center_y
69
+
70
+ current_word_padding = pad_x if g_idx == active_idx else 0
71
+ if is_rtl:
72
+ word_x = cursor_x - w_len - current_word_padding
73
+ else:
74
+ word_x = cursor_x + current_word_padding
75
+
76
+ words_to_draw.append({
77
+ "text": word, "x": word_x, "y": text_y_pos, "width": w_len,
78
+ "global_idx": global_word_counter,
79
+ "line_center_y": line_center_y
80
+ })
81
+
82
+ if is_rtl:
83
+ cursor_x -= (w_len + space_w + (current_word_padding * 2))
84
+ else:
85
+ cursor_x += (w_len + space_w + (current_word_padding * 2))
86
+ global_word_counter += 1
87
+
88
+ temp_y += line_height_px
89
+
90
+ VERTICAL_CORRECTION = int(style_config.fontSize * 0.22)
91
+
92
+ # 2. رسم کادرها (برای اینکه زیر متن باشند)
93
+ for item in words_to_draw:
94
+ is_active = (item["global_idx"] == active_idx)
95
+
96
+ should_draw_box = False
97
+ box_color = (0,0,0,0)
98
+
99
+ if style_name == "auto_director" and is_active:
100
+ should_draw_box = True
101
+ box_color = (0, 215, 255, 255) if item["global_idx"] % 2 == 0 else (255, 0, 128, 255)
102
+
103
+ elif style_name == "karaoke_static" and is_active:
104
+ should_draw_box = True
105
+ box_color = base_color
106
+
107
+ if should_draw_box:
108
+ rect_y1 = item["y"] - int(pad_y * 1.3) + VERTICAL_CORRECTION
109
+ rect_y2 = item["y"] + style_config.fontSize + int(pad_y * 1.3) + VERTICAL_CORRECTION
110
+
111
+ bx1, by1 = int(item["x"] - pad_x), int(rect_y1)
112
+ bx2, by2 = int(item["x"] + item["width"] + pad_x), int(rect_y2)
113
+
114
+ draw.rounded_rectangle([bx1, by1, bx2, by2], radius=style_config.radius, fill=box_color)
115
+
116
+ # 3. رسم متن با تراز عمودی کاملاً منطبق بر مرکز هندسی کادر
117
+ for item in words_to_draw:
118
+ is_active = (item["global_idx"] == active_idx) and getattr(style_config, 'useActiveColor', True)
119
+
120
+ # رنگ پیش‌فرض
121
+ fill_color = base_color
122
+
123
+ if style_name == "progressive_write":
124
+ if active_idx != -1 and item["global_idx"] > active_idx:
125
+ continue # کلمه را نکش
126
+
127
+ elif style_name == "auto_director":
128
+ if is_active:
129
+ active_hex = style_config.styleActiveColors.get('auto_director', '#ffffff')
130
+ fill_color = color_parser(active_hex, (255, 255, 255, 255))
131
+ else:
132
+ custom_text_hex = style_config.styleColors.get('auto_director', '#ffffff')
133
+ fill_color = color_parser(custom_text_hex, (255, 255, 255, 255))
134
+
135
+ elif style_name == "karaoke_static":
136
+ if is_active:
137
+ active_hex = style_config.styleActiveColors.get('karaoke_static', '#ffffff')
138
+ fill_color = color_parser(active_hex, (255, 255, 255, 255))
139
+ else:
140
+ custom_text_hex = style_config.styleColors.get('karaoke_static', '#ffffff')
141
+ fill_color = color_parser(custom_text_hex, (255, 255, 255, 255))
142
+
143
+ # اعمال رنگ دستی کاربر
144
+ if word_infos and item["global_idx"] < len(word_infos):
145
+ w_obj = word_infos[item["global_idx"]]
146
+ if hasattr(w_obj, 'color') and w_obj.color:
147
+ fill_color = color_parser(w_obj.color, fill_color)
148
+
149
+ # محاسبه مرکز هندسی کادر رسم شده برای قرارگیری دقیق متن در مرکز کادر
150
+ rect_y1 = item["y"] - int(pad_y * 1.3) + VERTICAL_CORRECTION
151
+ rect_y2 = item["y"] + style_config.fontSize + int(pad_y * 1.3) + VERTICAL_CORRECTION
152
+ rect_center_y = (rect_y1 + rect_y2) / 2.0
153
+
154
+ final_text_y = rect_center_y - standard_text_offset_y - (standard_text_h / 2.0)
155
+
156
+ draw.text((item["x"], final_text_y), item["text"], font=font, fill=fill_color, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
styles/music.py ADDED
@@ -0,0 +1,588 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ from PIL import Image, ImageDraw, ImageFont
3
+
4
+ # 1. تنظیمات و متا‌دیتا
5
+ config = {
6
+ "ids": ["music_player"],
7
+ "name": "Music Player",
8
+ "panels": ["font", "size", "position"], # پنل‌های رنگ را غیرفعال کردیم چون رنگ‌ها ثابت هستند
9
+ "default_font": "vazir"
10
+ }
11
+
12
+ frontend_template = """
13
+ <!-- ترفند نهایی: پاکسازی با تاخیر برای غلبه بر رفرش جاوااسکریپت -->
14
+ <img src="x" onerror="
15
+ var p = this.parentElement;
16
+ if(p) {
17
+ /* تایمر باعث می‌شود این دستورات دقیقا بعد از کدهای مزاحم اجرا شوند */
18
+ setTimeout(function(){
19
+ p.style.setProperty('background', 'none', 'important');
20
+ p.style.setProperty('background-color', 'transparent', 'important');
21
+ p.style.setProperty('box-shadow', 'none', 'important');
22
+ p.style.setProperty('border', 'none', 'important');
23
+ p.style.setProperty('outline', 'none', 'important');
24
+ /* حذف سایه متن مزاحم */
25
+ p.style.setProperty('text-shadow', 'none', 'important');
26
+ }, 10);
27
+ }
28
+ this.style.display='none';
29
+ ">
30
+
31
+ <div class="music-style-preview" style="display: flex; justify-content: center; align-items: center; width: 100%;">
32
+
33
+ <!-- کادر اصلی: عرض پویا با محدودیت حداقل و حداکثر -->
34
+ <div class="music-wrapper" style="
35
+ position: relative;
36
+ z-index: 10;
37
+ display: inline-flex;
38
+ justify-content: center;
39
+ align-items: center;
40
+ min-width: 11em;
41
+ max-width: 22em;
42
+ height: 3.4em;
43
+ ">
44
+
45
+ <!-- لایه 1: ویژوالایزر (۷۰ خط متراکم) -->
46
+ <div class="hanging-visualizer" style="
47
+ position: absolute;
48
+ top: 72%;
49
+ width: 80%;
50
+ left: 50%;
51
+ transform: translateX(-50%);
52
+ height: 1.5em;
53
+ display: flex;
54
+ justify-content: space-between;
55
+ align-items: flex-start;
56
+ z-index: 1;
57
+ pointer-events: none;
58
+ ">
59
+ <div class="v-bar" style="animation-duration: 1.1s; animation-delay: -0.1s;"></div>
60
+ <div class="v-bar" style="animation-duration: 1.5s; animation-delay: -0.8s;"></div>
61
+ <div class="v-bar" style="animation-duration: 0.9s; animation-delay: -0.3s;"></div>
62
+ <div class="v-bar" style="animation-duration: 1.3s; animation-delay: -0.5s;"></div>
63
+ <div class="v-bar" style="animation-duration: 1.7s; animation-delay: -0.2s;"></div>
64
+ <div class="v-bar" style="animation-duration: 1.0s; animation-delay: -0.9s;"></div>
65
+ <div class="v-bar" style="animation-duration: 1.4s; animation-delay: -0.4s;"></div>
66
+ <div class="v-bar" style="animation-duration: 1.8s; animation-delay: -0.7s;"></div>
67
+ <div class="v-bar" style="animation-duration: 1.2s; animation-delay: -0.1s;"></div>
68
+ <div class="v-bar" style="animation-duration: 1.6s; animation-delay: -0.6s;"></div>
69
+ <div class="v-bar" style="animation-duration: 0.8s; animation-delay: -0.3s;"></div>
70
+ <div class="v-bar" style="animation-duration: 1.9s; animation-delay: -0.5s;"></div>
71
+ <div class="v-bar" style="animation-duration: 1.3s; animation-delay: -0.2s;"></div>
72
+ <div class="v-bar" style="animation-duration: 1.5s; animation-delay: -0.8s;"></div>
73
+ <div class="v-bar" style="animation-duration: 1.0s; animation-delay: -0.4s;"></div>
74
+ <div class="v-bar" style="animation-duration: 1.7s; animation-delay: -0.1s;"></div>
75
+ <div class="v-bar" style="animation-duration: 1.1s; animation-delay: -0.9s;"></div>
76
+ <div class="v-bar" style="animation-duration: 1.4s; animation-delay: -0.3s;"></div>
77
+ <div class="v-bar" style="animation-duration: 1.8s; animation-delay: -0.6s;"></div>
78
+ <div class="v-bar" style="animation-duration: 0.9s; animation-delay: -0.2s;"></div>
79
+ <div class="v-bar" style="animation-duration: 1.6s; animation-delay: -0.5s;"></div>
80
+ <div class="v-bar" style="animation-duration: 1.2s; animation-delay: -0.8s;"></div>
81
+ <div class="v-bar" style="animation-duration: 1.5s; animation-delay: -0.1s;"></div>
82
+ <div class="v-bar" style="animation-duration: 1.0s; animation-delay: -0.7s;"></div>
83
+ <div class="v-bar" style="animation-duration: 1.9s; animation-delay: -0.4s;"></div>
84
+ <div class="v-bar" style="animation-duration: 1.3s; animation-delay: -0.9s;"></div>
85
+ <div class="v-bar" style="animation-duration: 1.7s; animation-delay: -0.3s;"></div>
86
+ <div class="v-bar" style="animation-duration: 1.1s; animation-delay: -0.6s;"></div>
87
+ <div class="v-bar" style="animation-duration: 1.4s; animation-delay: -0.2s;"></div>
88
+ <div class="v-bar" style="animation-duration: 0.8s; animation-delay: -0.5s;"></div>
89
+ <div class="v-bar" style="animation-duration: 1.6s; animation-delay: -0.1s;"></div>
90
+ <div class="v-bar" style="animation-duration: 1.2s; animation-delay: -0.8s;"></div>
91
+ <div class="v-bar" style="animation-duration: 1.8s; animation-delay: -0.4s;"></div>
92
+ <div class="v-bar" style="animation-duration: 1.0s; animation-delay: -0.7s;"></div>
93
+ <div class="v-bar" style="animation-duration: 1.5s; animation-delay: -0.3s;"></div>
94
+ <div class="v-bar" style="animation-duration: 1.3s; animation-delay: -0.9s;"></div>
95
+ <div class="v-bar" style="animation-duration: 1.7s; animation-delay: -0.2s;"></div>
96
+ <div class="v-bar" style="animation-duration: 1.1s; animation-delay: -0.6s;"></div>
97
+ <div class="v-bar" style="animation-duration: 1.9s; animation-delay: -0.5s;"></div>
98
+ <div class="v-bar" style="animation-duration: 1.4s; animation-delay: -0.1s;"></div>
99
+ <div class="v-bar" style="animation-duration: 0.9s; animation-delay: -0.8s;"></div>
100
+ <div class="v-bar" style="animation-duration: 1.6s; animation-delay: -0.3s;"></div>
101
+ <div class="v-bar" style="animation-duration: 1.2s; animation-delay: -0.7s;"></div>
102
+ <div class="v-bar" style="animation-duration: 1.8s; animation-delay: -0.4s;"></div>
103
+ <div class="v-bar" style="animation-duration: 1.0s; animation-delay: -0.2s;"></div>
104
+ <div class="v-bar" style="animation-duration: 1.5s; animation-delay: -0.9s;"></div>
105
+ <div class="v-bar" style="animation-duration: 1.3s; animation-delay: -0.5s;"></div>
106
+ <div class="v-bar" style="animation-duration: 1.7s; animation-delay: -0.1s;"></div>
107
+ <div class="v-bar" style="animation-duration: 1.1s; animation-delay: -0.6s;"></div>
108
+ <div class="v-bar" style="animation-duration: 1.4s; animation-delay: -0.3s;"></div>
109
+ <div class="v-bar" style="animation-duration: 1.9s; animation-delay: -0.8s;"></div>
110
+ <div class="v-bar" style="animation-duration: 1.2s; animation-delay: -0.4s;"></div>
111
+ <div class="v-bar" style="animation-duration: 1.6s; animation-delay: -0.2s;"></div>
112
+ <div class="v-bar" style="animation-duration: 0.8s; animation-delay: -0.7s;"></div>
113
+ <div class="v-bar" style="animation-duration: 1.5s; animation-delay: -0.1s;"></div>
114
+ <div class="v-bar" style="animation-duration: 1.0s; animation-delay: -0.9s;"></div>
115
+ <div class="v-bar" style="animation-duration: 1.8s; animation-delay: -0.5s;"></div>
116
+ <div class="v-bar" style="animation-duration: 1.3s; animation-delay: -0.3s;"></div>
117
+ <div class="v-bar" style="animation-duration: 1.7s; animation-delay: -0.6s;"></div>
118
+ <div class="v-bar" style="animation-duration: 1.1s; animation-delay: -0.2s;"></div>
119
+ <div class="v-bar" style="animation-duration: 1.4s; animation-delay: -0.8s;"></div>
120
+ <div class="v-bar" style="animation-duration: 1.9s; animation-delay: -0.4s;"></div>
121
+ <div class="v-bar" style="animation-duration: 1.2s; animation-delay: -0.1s;"></div>
122
+ <div class="v-bar" style="animation-duration: 1.6s; animation-delay: -0.7s;"></div>
123
+ <div class="v-bar" style="animation-duration: 1.0s; animation-delay: -0.3s;"></div>
124
+ <div class="v-bar" style="animation-duration: 1.5s; animation-delay: -0.9s;"></div>
125
+ <div class="v-bar" style="animation-duration: 1.3s; animation-delay: -0.5s;"></div>
126
+ <div class="v-bar" style="animation-duration: 1.8s; animation-delay: -0.2s;"></div>
127
+ <div class="v-bar" style="animation-duration: 1.1s; animation-delay: -0.6s;"></div>
128
+ <div class="v-bar" style="animation-duration: 1.4s; animation-delay: -0.1s;"></div>
129
+ </div>
130
+
131
+ <!-- لایه 2: نوار قرمز پیشرفت -->
132
+ <div class="progress-border-background" style="
133
+ position: absolute;
134
+ top: 0; left: 0; right: 0; bottom: 0;
135
+ background-color: #d32f2f;
136
+ border-radius: var(--outer-radius);
137
+ z-index: 5;
138
+ clip-path: inset(0 0 0 var(--progress, 100%));
139
+ transition: clip-path 0.25s linear;
140
+ box-shadow: 0 0.1em 0.3em rgba(211, 47, 47, 0.3);
141
+ /* کد جدید برای توخالی کردن وسط نوار قرمز */
142
+ padding: var(--border-thickness);
143
+ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
144
+ -webkit-mask-composite: xor;
145
+ mask-composite: exclude;
146
+ "></div>
147
+
148
+ <!-- لایه 3: کادر سفید متن -->
149
+ <div class="music-text-box" style="
150
+ position: relative;
151
+ z-index: 3;
152
+ background-color: #ffffff;
153
+ color: #000000;
154
+ border-radius: 1.5em;
155
+ font-family: inherit;
156
+ margin: calc(var(--border-thickness) - 4.5px);
157
+ flex-grow: 1;
158
+ height: calc(100% - 2 * var(--border-thickness));
159
+ display: flex;
160
+ align-items: center;
161
+ justify-content: center;
162
+ padding: 0 1em;
163
+ overflow: hidden;
164
+ ">
165
+ <span class="music-text-inner" style="
166
+ white-space: nowrap;
167
+ font-weight: 900;
168
+ font-size: 1.1em;
169
+ transform-origin: center;
170
+ display: inline-block;
171
+ ">{{WORD}}</span>
172
+ </div>
173
+
174
+ </div>
175
+
176
+ <style>
177
+ :root {
178
+ --border-thickness: 0.35em;
179
+ --outer-radius: 1.5em;
180
+ }
181
+ .v-bar {
182
+ width: 0.12em;
183
+ background-color: #fff;
184
+ border-radius: 1em;
185
+ flex-shrink: 0;
186
+ animation-name: naturalWave;
187
+ animation-timing-function: ease-in-out;
188
+ animation-iteration-count: infinite;
189
+ animation-direction: alternate;
190
+ }
191
+ @keyframes naturalWave {
192
+ 0% { height: 0.5em; opacity: 0.7; }
193
+ 100% { height: 1.5em; opacity: 1; }
194
+ }
195
+ </style>
196
+ </div>
197
+ """
198
+
199
+ # 3. منطق رسم فریم به فریم (Backend Logic)
200
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
201
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
202
+ # دریافت رنگ پس‌زمینه انتخابی کاربر برای باکس و ویژوالایزر
203
+ box_bg_color = (255, 255, 255, 255) # پیش‌فرض سفید
204
+ if hasattr(style_config, 'styleBgColors'):
205
+ bg_hex = style_config.styleBgColors.get('music_player')
206
+ if bg_hex:
207
+ box_bg_color = color_parser(bg_hex, (255, 255, 255, 255))
208
+
209
+ visualizer_color = box_bg_color # ویژوالایزر دقیقاً همرنگ باکس می‌شود
210
+ text_color = (0, 0, 0, 255) # سیاه پیش‌فرض
211
+
212
+ # خواندن رنگ از دیکشنری با کلید music_player
213
+ custom_color = style_config.styleColors.get('music_player')
214
+ if custom_color:
215
+ u_r, u_g, u_b, u_a = color_parser(custom_color, (0, 0, 0, 255))
216
+
217
+ # محاسبه ترکیب رنگ با سفید (برای جلوگیری از سوراخ شدن باکس)
218
+ # فرمول: (رنگ متن * درصد شفافیت) + (سفید * درصد باقی‌مانده)
219
+ alpha_factor = u_a / 255.0
220
+ final_r = int((u_r * alpha_factor) + (255 * (1 - alpha_factor)))
221
+ final_g = int((u_g * alpha_factor) + (255 * (1 - alpha_factor)))
222
+ final_b = int((u_b * alpha_factor) + (255 * (1 - alpha_factor)))
223
+
224
+ # رنگ نهایی کاملاً توپر (255) است تا باکس سفید را خراب نکند
225
+ text_color = (final_r, final_g, final_b, 255)
226
+ # خواندن رنگ نوار پیشرفت از تنظیمات (پیش‌فرض قرمز)
227
+ prog_hex = style_config.styleBgColors.get('music_player_progress', '#d32f2f')
228
+ border_color = color_parser(prog_hex, (211, 47, 47, 255))
229
+
230
+ padding_x = 30
231
+ padding_y = 6
232
+ border_thickness = 5
233
+ radius = 50 # گردی زیاد (Pill Shape)
234
+
235
+ # --- محاسبه ابعاد باکس متن (تک خط + تغییر سایز خودکار) ---
236
+ # 1. تبدیل تمام کلمات به یک خط واحد
237
+ all_words = []
238
+ for line in lines:
239
+ all_words.extend(line)
240
+ full_text = " ".join(all_words)
241
+
242
+ # 2. تعیین حداکثر عرض مجاز (مثلاً 85 درصد عرض کل ویدیو)
243
+ max_allowed_width = width * 0.85
244
+ available_text_width = max_allowed_width - (padding_x * 2)
245
+
246
+ # 3. محاسبه عرض متن با فونت فعلی
247
+ try: text_w = draw.textlength(full_text, font=font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
248
+ except: text_w = font.getlength(full_text)
249
+
250
+ # 4. تغییر سایز فونت اگر متن بزرگتر از کادر بود (Shrink to fit)
251
+ effective_font = font
252
+
253
+ if text_w > available_text_width:
254
+ # محاسبه ضریب کوچک‌نمایی
255
+ scale_factor = available_text_width / text_w
256
+ new_size = int(style_config.fontSize * scale_factor)
257
+
258
+ # نگاشت دقیق نام فونت به فایل (باید دقیقاً مشابه فایل‌های موجود در پوشه font باشد)
259
+ font_files = {
260
+ "vazir": "Vazirmatn.ttf", "lalezar": "Lalezar.ttf",
261
+ "bangers": "Bangers.ttf", "roboto": "Roboto.ttf"
262
+ }
263
+ # مسیر نسبی از ریشه پروژه
264
+ font_path = f"font/{font_files.get(style_config.font, 'Vazirmatn.ttf')}"
265
+
266
+ try:
267
+ # تلاش برای لود فونت با سایز جدید
268
+ effective_font = ImageFont.truetype(font_path, new_size)
269
+ # اگر موفق ��د، عرض باکس را روی ماکزیمم قفل می‌کنیم
270
+ box_width = max_allowed_width
271
+ except:
272
+ # اگر فونت لود نشد (مثلاً فایل پیدا نشد)، چاره‌ای نیست جز اینکه بزرگ بماند
273
+ # اما لااقل باکس را به اندازه متن می‌گیریم تا کادر ناقص نشود
274
+ box_width = text_w + (padding_x * 2)
275
+ else:
276
+ # اگر متن کوچک بود و جا می‌شد
277
+ # محاسبه حداقل عرض (12em) تا کادر مثل ورودی برای کلمات کوتاه پهن بماند
278
+ min_w = style_config.fontSize * 11
279
+ box_width = max(text_w + (padding_x * 2), min_w)
280
+
281
+ # محاسبه ارتفاع بر اساس فونت جدید
282
+ ascent, descent = effective_font.getmetrics()
283
+ line_height_px = ascent + descent
284
+ box_height = line_height_px + (padding_y * 2)
285
+
286
+ # مرکز باکس
287
+ center_x = width / 2 + style_config.x
288
+
289
+ # موقعیت عمودی
290
+ bottom_margin = style_config.marginV
291
+
292
+ # اصلاحیه ارتفاع: این عدد (100) را زیاد کنید تا کادر بالاتر برود
293
+ height_correction = 22
294
+
295
+ box_y2 = height - bottom_margin - height_correction
296
+ box_y1 = box_y2 - box_height
297
+ box_x1 = center_x - (box_width / 2)
298
+ box_x2 = center_x + (box_width / 2)
299
+
300
+ # --- محاسبه درصد پیشرفت (Progress) بر اساس زمان کل ویدیو و زمان دقیق فریم ---
301
+ total_video_duration = getattr(style_config, 'total_video_duration', 1.0)
302
+
303
+ # دریافت زمان دقیق فریم که از app.py فرستاده شده است
304
+ current_time = getattr(style_config, 'current_render_time', 0.0)
305
+
306
+ # محاسبه نسبت پیشرفت کل ویدیو (این باعث حرکت نرم و پیوسته می‌شود)
307
+ if total_video_duration > 0:
308
+ progress_ratio = current_time / total_video_duration
309
+ else:
310
+ progress_ratio = 0.0
311
+
312
+ progress_ratio = max(0.0, min(1.0, progress_ratio))
313
+
314
+ # --- 1. رسم ویژوالایزر (روان و چسبیده) ---
315
+ vis_y_start = box_y2 # چسبیده کامل به کادر (فاصله 0)
316
+
317
+ # تنظیمات ابعاد و منطق جدید (تعداد ثابت، فاصله متغیر)
318
+ # اگر ویدیو مربع/افقی است (width >= height) تنظیمات درشت‌تر شود
319
+ is_square_mode = width >= height
320
+ vis_bar_width = 4.0 if is_square_mode else 2.5
321
+ vis_height_scale = 35 if is_square_mode else 20
322
+
323
+ fixed_bar_count = 55 # تعداد ثابت میله‌ها (مشابه ورودی)
324
+
325
+ # عرض کل ویژوالایزر (80 درصد عرض باکس، دقیقاً مثل کد CSS ورودی)
326
+ total_vis_width = box_width * 0.80
327
+
328
+ # محاسبه فاصله بین میله‌ها (فاصله دینامیک)
329
+ # فرمول: (کل فضا - عرض یک میله) تقسیم بر (تعداد - 1)
330
+ if fixed_bar_count > 1:
331
+ step_x = (total_vis_width - vis_bar_width) / (fixed_bar_count - 1)
332
+ else:
333
+ step_x = 0
334
+
335
+ # نقطه شروع (وسط چین کردن کل بلوک ویژوالایزر)
336
+ vis_start_x = center_x - (total_vis_width / 2)
337
+
338
+ # دریافت زمان دقیق برای حرکت سینوسی و روان
339
+ current_t = getattr(style_config, 'current_render_time', 0.0)
340
+ import math # نیاز به math برای حرکت سینوسی
341
+
342
+ for i in range(fixed_bar_count):
343
+ # الگوی موجی متحرک (ترکیب دو موج سینوسی با سرعت‌های مختلف)
344
+ # i * 0.5: تفاوت فاز بین میله‌ها (شکل موج)
345
+ # current_t * 10: سرعت حرکت موج
346
+ wave1 = math.sin(i * 13.7 + current_t * 5)
347
+ wave2 = math.sin(i * 29.3 - current_t * 7)
348
+
349
+ normalized_h = (wave1 + wave2 + 2) / 4 # مقدار بین 0 تا 1
350
+ h = 5 + (normalized_h * vis_height_scale)
351
+
352
+ bx = vis_start_x + (i * step_x)
353
+
354
+ # رسم میله کپسولی (با شعاع گردی)
355
+ draw.rounded_rectangle([bx, vis_y_start, bx + vis_bar_width, vis_y_start + h], radius=5, fill=visualizer_color)
356
+
357
+ # --- 2. رسم باکس سفید ---
358
+ draw.rounded_rectangle([box_x1, box_y1, box_x2, box_y2], radius=radius, fill=box_bg_color)
359
+
360
+ # --- 3. رسم متن (تک خط) ---
361
+ # محاسبه موقعیت Y متن برای قرارگیری دقیق در مرکز عمودی باکس
362
+ text_y = box_y1 + (box_height - line_height_px) / 2 # حذف descent/2
363
+
364
+ # محاسبه مجدد عرض متن نهایی با فونت جدید برای سنتر کردن افقی
365
+ try: final_text_w = draw.textlength(full_text, font=effective_font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
366
+ except: final_text_w = effective_font.getlength(full_text)
367
+
368
+ space_w = draw.textlength(" ", font=effective_font)
369
+ if is_rtl:
370
+ cursor_x = center_x + (final_text_w / 2) # شروع از راست
371
+ else:
372
+ cursor_x = center_x - (final_text_w / 2) # شروع از چپ
373
+
374
+ for word in all_words:
375
+ try: w_len = draw.textlength(word, font=effective_font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
376
+ except: w_len = effective_font.getlength(word)
377
+
378
+ word_x = (cursor_x - w_len) if is_rtl else cursor_x
379
+ draw.text((word_x, text_y), word, font=effective_font, fill=text_color, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
380
+
381
+ cursor_x += (-(w_len + space_w) if is_rtl else (w_len + space_w))
382
+
383
+ # الف) ساخت لایه قرمز کامل (فقط حاشیه)
384
+ # محدوده حاشیه کمی بزرگتر از باکس سفید است
385
+ # اگر مربع بود 15، اگر عمودی بود 10
386
+ border_offset = 15 if width >= height else 10
387
+ bx1_b, by1_b = box_x1 - border_offset, box_y1 - border_offset
388
+ bx2_b, by2_b = box_x2 + border_offset, box_y2 + border_offset
389
+
390
+ # یک تصویر موقت برای حاشیه قرمز
391
+ red_layer = Image.new('RGBA', img.size, (0,0,0,0))
392
+ red_draw = ImageDraw.Draw(red_layer)
393
+
394
+ # رسم حاشیه قرمز توخالی (Stroke)
395
+ # پیلو stroke برای rounded_rectangle ندارد، پس دو تا می‌کشیم
396
+ # بیرونی (قرمز)
397
+ red_draw.rounded_rectangle([bx1_b, by1_b, bx2_b, by2_b], radius=radius+2, fill=border_color)
398
+ # داخلی (حذف کردن وسطش برای توخالی شدن - با حالت Clear)
399
+ # از کامپوزیت استفاده می‌کنیم یا ساده‌تر:
400
+ # چون زیرش ترنسپیرنت است، نمی‌توانیم وسطش را پاک کنیم به راحتی.
401
+ # روش ساده‌تر: فقط حاشیه را روی ماسک اعمال می‌کنیم.
402
+
403
+ # روش بهتر برای پیلو: ساخت ماسک پیشرفت (Progress Mask)
404
+ # ماسک تعیین می‌کند کجای تصویر قرمز نمایش داده شود.
405
+ mask = Image.new('L', img.size, 0) # سیاه کامل
406
+ mask_draw = ImageDraw.Draw(mask)
407
+
408
+ # محاسبه ناحیه پر شده (از راست به چپ)
409
+ # کل عرض حاشیه
410
+ full_border_width = bx2_b - bx1_b
411
+ filled_width = full_border_width * progress_ratio
412
+
413
+ # ناحیه سفید ماسک (جایی که قرمز دیده می‌شود)
414
+ # از سمت راست (bx2_b) شروع می‌شود و به اندازه filled_width به چپ می‌آید
415
+ fill_rect = [bx2_b - filled_width, by1_b, bx2_b, by2_b]
416
+
417
+ if filled_width > 0:
418
+ mask_draw.rectangle(fill_rect, fill=255)
419
+
420
+ # حالا حاشیه قرمز را می‌سازیم (فقط یک کادر قرمز کامل)
421
+ full_red_border = Image.new('RGBA', img.size, (0,0,0,0))
422
+ frb_draw = ImageDraw.Draw(full_red_border)
423
+
424
+ # رسم کادر قرمز کامل
425
+ frb_draw.rounded_rectangle([bx1_b, by1_b, bx2_b, by2_b], radius=radius+2, fill=border_color)
426
+
427
+ # وسط کادر قرمز را پاک می‌کنیم تا فقط "Stroke" بماند (تا روی متن نیاید)
428
+ # یک مستطیل پاک‌کننده وسطش می‌کشیم
429
+ # از روش Composite استفاده می‌کنیم
430
+
431
+ # لایه نهایی قرمز که وسطش خالی است
432
+ stroked_red = Image.new('RGBA', img.size, (0,0,0,0))
433
+ sr_draw = ImageDraw.Draw(stroked_red)
434
+
435
+ # رسم مستطیل توپر قرمز
436
+ sr_draw.rounded_rectangle([bx1_b, by1_b, bx2_b, by2_b], radius=radius+2, fill=border_color)
437
+
438
+ # ساختن حفره وسط (جایی که باکس سفید هست)
439
+ # این کار را با paste کردن لایه سفید انجام نمی‌دهیم چون ممکن است لبه‌ها تمیز نشوند.
440
+ # اما چون ما باکس سفید را قبلاً روی `img` اصلی کشیدیم،
441
+ # کافیست کادر قرمز را "پشت" باکس سفید نکشیم، بلکه دورش بکشیم.
442
+ # اما ساده‌ترین راه: کادر قرمز را با ماسک Progress روی img پیست می‌کنیم،
443
+ # و چون کادر قرمز بزرگتر از سفید است و زیرش کشیده می‌شود... صبر کنید.
444
+ # ترتیب لایه‌ها:
445
+ # 1. ویژوالایزر (کشیده شد)
446
+ # 2. نوار قرمز (باید زیر سفید باشد؟ یا دور سفید؟)
447
+ # طبق CSS کاربر: `border: 5px solid`. بردر روی باکس می‌افتد یا بیرون؟
448
+ # معمولاً border روی باکس است. اما ما اینجا stroke بیرون زدیم.
449
+ # بیایید قرمز را بکشیم، سپس دوباره باکس سفید را روی آن بکشیم تا وسط قرمز پاک شود؟
450
+ # نه، باکس سفید ترن��پیرنت نیست.
451
+
452
+ # راه حل نهایی تمیز:
453
+ # 1. ایمیج `final_red_stroke` را می‌سازیم که یک کادر قرمز توخالی است.
454
+ # 2. این ایمیج را با ماسک (چپ/راست) روی تصویر اصلی `img` می‌چسبانیم.
455
+
456
+ # ساخت کادر قرمز توخالی:
457
+ stroke_layer = Image.new('RGBA', img.size, (0,0,0,0))
458
+ sl_draw = ImageDraw.Draw(stroke_layer)
459
+ # مستطیل بزرگ قرمز
460
+ sl_draw.rounded_rectangle([bx1_b, by1_b, bx2_b, by2_b], radius=radius+2, fill=border_color)
461
+ # پاک کردن وسطش (با استفاده از مود Clear یا رسم مستطیل شفاف - که در RGBA مستطیل شفاف رنگ قبلی را پاک نمی‌کند)
462
+ # ترفند: ما فقط کادر قرمز را با ماسک روی تصویر نهایی می‌اندازیم.
463
+ # اما چون تصویر نهایی الان باکس سفید دارد، اگر کادر قرمز را رویش بیندازیم، روی باکس سفید را می‌گیرد.
464
+ # پس باید وسط کادر قرمز را حذف کنیم.
465
+
466
+ # ماسک برش وسط (Hole Mask)
467
+ hole_mask = Image.new('L', img.size, 255) # سفید (نگه داشتن)
468
+ hm_draw = ImageDraw.Draw(hole_mask)
469
+ # ناحیه باکس سفید را سیاه می‌کنیم (حذف کردن)
470
+ hm_draw.rounded_rectangle([box_x1, box_y1, box_x2, box_y2], radius=radius, fill=0)
471
+
472
+ # ترکیب ماسک سوراخ و ماسک پیشرفت
473
+ # ماسک نهایی = (Progress Mask) AND (Hole Mask)
474
+ # یعنی جایی که هم "باید پر باشد" و هم "وسط نیست".
475
+
476
+ final_mask = Image.new('L', img.size, 0)
477
+ # پیکسل به پیکسل کند است. از `ImageChops` استفاده کنیم یا منطق ساده‌تر.
478
+ # چون وسطش با باکس سفید پوشانده شده (که قبلا کشیدیم)،
479
+ # اگر لایه قرمز را "پشت" باکس سفید می‌کشیدیم راحت بود.
480
+ # اما باکس سفید کشیده شده.
481
+
482
+ # بیایید استراتژی را عوض کنیم برای سادگی و سرعت:
483
+ # 1. ویژوالایزر (انجام شد)
484
+ # 2. کادر قرمز توپر (با ماسک پیشرفت برش خورده) را روی صفحه می‌کشیم.
485
+ # 3. باکس سفید را *مجدداً* روی آن می‌کشیم (این باعث می‌شود وسط قرمز پوشانده شود و فقط حاشیه بماند).
486
+ # 4. متن را *مجدداً* می‌کشیم.
487
+
488
+ # اجرای استراتژی جدید (بازنویسی روی تصویر):
489
+
490
+ # الف) رسم کادر قرمز (توپر) با ماسک پیشرفت
491
+ full_red_rect = Image.new('RGBA', img.size, (0,0,0,0))
492
+ fr_draw = ImageDraw.Draw(full_red_rect)
493
+ fr_draw.rounded_rectangle([bx1_b, by1_b, bx2_b, by2_b], radius=radius+2, fill=border_color)
494
+
495
+ img.paste(full_red_rect, (0,0), mask) # ماسک پیشرفت که قبلا ساختیم
496
+
497
+ # ب) رسم مجدد باکس سفید (برای پوشاندن وسط قرمز و ایجاد افکت بردر)
498
+ draw.rounded_rectangle([box_x1, box_y1, box_x2, box_y2], radius=radius, fill=box_bg_color)
499
+
500
+ # ج) رسم مجدد متن (چون زیر باکس سفید رفت)
501
+ # دقیقا از همان محاسبات تک‌خطی و فونت جدید که در مرحله 3 انجام دادیم استفاده می‌کنیم
502
+ # انیمیشن ورود نرم (Slide Up)
503
+ anim_p = getattr(style_config, 'entry_anim_progress', 1.0)
504
+ ease_p = 1 - (1 - anim_p) * (1 - anim_p) # Ease-out
505
+ y_offset = (1 - ease_p) * 30
506
+
507
+ text_y = (box_y1 + (box_height - line_height_px) / 2) + y_offset
508
+ if is_rtl:
509
+ cursor_x = center_x + (final_text_w / 2)
510
+ else:
511
+ cursor_x = center_x - (final_text_w / 2)
512
+
513
+ # تعریف شمارنده برای شمارش کلمات در حلقه (بسیار مهم)
514
+ word_idx_counter = 0
515
+
516
+ for word in all_words:
517
+ try: w_len = draw.textlength(word, font=effective_font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
518
+ except: w_len = effective_font.getlength(word)
519
+
520
+ # مقدار پیش‌فرض کلمه و موقعیت ترسیم
521
+ display_word = word
522
+ word_x = (cursor_x - w_len) if is_rtl else cursor_x
523
+ should_draw = True
524
+
525
+ # رنگ پیش‌فرض همان رنگ کلی استایل است
526
+ current_word_fill = text_color
527
+
528
+ # --- منطق تایپ شدن حروف (نوع نمایش تایپی) ---
529
+ is_typewriter = getattr(style_config, 'typewriter', False)
530
+ if is_typewriter and word_infos and word_idx_counter < len(word_infos):
531
+ w_info = word_infos[word_idx_counter]
532
+ current_t = getattr(style_config, 'current_render_time', 0.0)
533
+ if current_t < w_info.start:
534
+ should_draw = False
535
+ elif current_t >= w_info.start and current_t < w_info.end:
536
+ char_len = len(word)
537
+ progress = (current_t - w_info.start) / max(0.001, w_info.end - w_info.start)
538
+ visible_chars = min(char_len, int(progress * char_len) + 1)
539
+ display_word = word[:visible_chars]
540
+ try: sub_w = draw.textlength(display_word, font=effective_font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
541
+ except: sub_w = effective_font.getlength(display_word)
542
+ word_x = (cursor_x - sub_w) if is_rtl else word_x
543
+
544
+ # --- منطق کمرنگ کردن کلمات آینده (اصلاح شده با زمان دقیق برای جلوگیری از چشمک زدن) ---
545
+ is_future = False
546
+ curr_t = getattr(style_config, 'current_render_time', 0.0)
547
+
548
+ # روش دقیق: مقایسه زمان فعلی با زمان شروع کلمه
549
+ if word_infos and word_idx_counter < len(word_infos):
550
+ if curr_t < word_infos[word_idx_counter].start:
551
+ is_future = True
552
+ # روش جایگزین (برای پیش‌نمایش): مقایسه ایندکس
553
+ elif active_idx != -1 and word_idx_counter > active_idx:
554
+ is_future = True
555
+
556
+ should_fade = getattr(style_config, 'fadeUnread', True)
557
+ should_fade_surr = getattr(style_config, 'fadeSurrounding', False)
558
+
559
+ is_past = False
560
+ if word_infos and word_idx_counter < len(word_infos):
561
+ if curr_t >= word_infos[word_idx_counter].end:
562
+ is_past = True
563
+ elif active_idx != -1 and word_idx_counter < active_idx:
564
+ is_past = True
565
+
566
+ if (is_future and (should_fade or should_fade_surr)) or (is_past and should_fade_surr):
567
+ # رنگ متن و رنگ زمینه باکس را می‌گیریم
568
+ tr, tg, tb = text_color[0], text_color[1], text_color[2]
569
+ br, bg, bb = box_bg_color[0], box_bg_color[1], box_bg_color[2]
570
+
571
+ # ترکیب رنگ متن با رنگ زمینه (۳۵ درصد متن + ۶۵ درصد زمینه)
572
+ # این کار باعث می‌شود متن کمرنگ به نظر برسد اما کاملاً توپر (Solid) باشد
573
+ nr = int(tr * 0.35 + br * 0.65)
574
+ ng = int(tg * 0.35 + bg * 0.65)
575
+ nb = int(tb * 0.35 + bb * 0.65)
576
+ current_word_fill = (nr, ng, nb, 255) # آلفا ۲۵۵ یعنی بدون هیچ شفافیتی
577
+
578
+ # بررسی می‌کنیم آیا کاربر رنگ خاصی برای این کلمه تنظیم کرده است؟ (اولویت با رنگ کاربر)
579
+ if word_infos and word_idx_counter < len(word_infos):
580
+ w_info = word_infos[word_idx_counter]
581
+ if hasattr(w_info, 'color') and w_info.color:
582
+ current_word_fill = color_parser(w_info.color, text_color)
583
+
584
+ if should_draw and display_word:
585
+ draw.text((word_x, text_y), display_word, font=effective_font, fill=current_word_fill, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
586
+
587
+ cursor_x += (-(w_len + space_w) if is_rtl else (w_len + space_w))
588
+ word_idx_counter += 1
styles/preset_bold_pack.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont
2
+ import math
3
+
4
+ # --- بسته استایل‌های آماده: پررنگ و ضربه‌ای ---
5
+ config = {
6
+ "ids": ["bold_caps_impact", "sport_lower_third", "retro_vhs_glitch"],
7
+ "name": "بسته پررنگ و ضربه‌ای",
8
+ "panels": ["color", "font", "size", "position", "radius", "padding"],
9
+ "default_font": "vazir"
10
+ }
11
+
12
+ SPORT_PALETTE = ["#FF3B30", "#007AFF", "#34C759", "#FF9500", "#AF52DE"]
13
+
14
+
15
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
16
+ style_name = style_config.name
17
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
18
+ direction = getattr(style_config, 'text_direction', 'rtl')
19
+ lang_code = getattr(style_config, 'lang_code', 'fa')
20
+
21
+ base_color = color_parser(style_config.primaryColor, (255, 255, 255, 255))
22
+ active_hex = style_config.styleActiveColors.get(style_name) if style_config.styleActiveColors else None
23
+ active_color = color_parser(active_hex, (255, 59, 48, 255)) if active_hex else color_parser("#FF3B30", (255, 59, 48, 255))
24
+ outline_color = color_parser(style_config.outlineColor, (0, 0, 0, 255))
25
+
26
+ line_height_px = int(style_config.fontSize * 1.6)
27
+ total_block_height = len(lines) * line_height_px
28
+ bottom_reference = height - style_config.marginV
29
+ start_y_of_block = bottom_reference - total_block_height
30
+ vertical_corr = int(style_config.fontSize * 0.1)
31
+ current_line_y = start_y_of_block - vertical_corr
32
+
33
+ space_w = draw.textlength(" ", font=font)
34
+ global_word_counter = 0
35
+ current_render_time = getattr(style_config, 'current_render_time', 0.0)
36
+
37
+ for metrics in line_metrics:
38
+ cursor_x = (width + metrics["width"]) / 2 + (style_config.x or 0) if is_rtl else (width - metrics["width"]) / 2 + (style_config.x or 0)
39
+ text_y_pos = current_line_y
40
+
41
+ for w_i, word in enumerate(metrics["words"]):
42
+ w_len = metrics["word_widths"][w_i]
43
+ word_x = (cursor_x - w_len) if is_rtl else cursor_x
44
+
45
+ is_active = False
46
+ should_draw = True
47
+ time_since_start = 0.0
48
+ if word_infos and global_word_counter < len(word_infos):
49
+ w_info = word_infos[global_word_counter]
50
+ is_active = current_render_time >= w_info.start and current_render_time < w_info.end
51
+ should_draw = current_render_time >= w_info.start
52
+ time_since_start = max(0.0, current_render_time - w_info.start)
53
+ elif active_idx != -1:
54
+ is_active = (global_word_counter == active_idx)
55
+ should_draw = (global_word_counter <= active_idx)
56
+
57
+ this_color = active_color if (is_active and getattr(style_config, 'useActiveColor', True)) else base_color
58
+ if word_infos and global_word_counter < len(word_infos):
59
+ w_obj = word_infos[global_word_counter]
60
+ if hasattr(w_obj, 'color') and w_obj.color:
61
+ this_color = color_parser(w_obj.color, this_color)
62
+
63
+ if should_draw:
64
+ if style_name == 'bold_caps_impact':
65
+ # افکت ضربه‌ای: کمی بزرگ‌تر و پرش کوتاه به بالا هنگام شروع کلمه فعال
66
+ punch_progress = min(1.0, time_since_start / 0.15)
67
+ punch_dy = -10 * (1 - punch_progress) if is_active else 0
68
+ ox = int(max(2, style_config.fontSize * 0.04))
69
+ for dx_o, dy_o in ((ox, 0), (-ox, 0), (0, ox), (0, -ox), (ox, ox), (-ox, -ox), (ox, -ox), (-ox, ox)):
70
+ draw.text((word_x + dx_o, text_y_pos + punch_dy + dy_o), word, font=font, fill=outline_color, direction=direction, language=lang_code)
71
+ draw.text((word_x, text_y_pos + punch_dy), word, font=font, fill=this_color, direction=direction, language=lang_code)
72
+
73
+ elif style_name == 'sport_lower_third':
74
+ pad_x = int(style_config.fontSize * 0.25)
75
+ pad_y = int(style_config.fontSize * 0.16)
76
+ sx1, sy1 = word_x - pad_x, text_y_pos - pad_y
77
+ sx2, sy2 = word_x + w_len + pad_x, text_y_pos + int(style_config.fontSize * 1.05) + pad_y
78
+ block_color_hex = SPORT_PALETTE[global_word_counter % len(SPORT_PALETTE)]
79
+ block_color = color_parser(block_color_hex, (255, 59, 48, 255))
80
+ if not is_active:
81
+ block_color = (30, 30, 34, 235)
82
+ draw.rectangle([sx1, sy1, sx2, sy2], fill=block_color)
83
+ text_col = (255, 255, 255, 255)
84
+ draw.text((word_x, text_y_pos), word, font=font, fill=text_col, direction=direction, language=lang_code)
85
+
86
+ elif style_name == 'retro_vhs_glitch':
87
+ glitch_amt = 4 if is_active else 2
88
+ draw.text((word_x - glitch_amt, text_y_pos), word, font=font, fill=(255, 0, 60, 160), direction=direction, language=lang_code)
89
+ draw.text((word_x + glitch_amt, text_y_pos), word, font=font, fill=(0, 220, 255, 160), direction=direction, language=lang_code)
90
+ draw.text((word_x, text_y_pos), word, font=font, fill=this_color, direction=direction, language=lang_code)
91
+
92
+ cursor_x += (-(w_len + space_w) if is_rtl else (w_len + space_w))
93
+ global_word_counter += 1
94
+
95
+ current_line_y += line_height_px
styles/preset_elegant_pack.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont
2
+ import math
3
+
4
+ # --- بسته استایل‌های آماده: ظریف و سینمایی ---
5
+ config = {
6
+ "ids": ["elegant_thin_serif", "cinematic_letterbox", "typewriter_mono", "gradient_text"],
7
+ "name": "بسته ظریف و سینمایی",
8
+ "panels": ["color", "font", "size", "position", "radius", "padding"],
9
+ "default_font": "vazir"
10
+ }
11
+
12
+ GRADIENT_STOPS = [(0, 229, 255), (124, 77, 255), (255, 45, 149)]
13
+
14
+
15
+ def _lerp_color(c1, c2, t):
16
+ return tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(3))
17
+
18
+
19
+ def _gradient_color_at(t):
20
+ n = len(GRADIENT_STOPS) - 1
21
+ t = max(0.0, min(1.0, t))
22
+ seg = min(n - 1, int(t * n))
23
+ local_t = (t * n) - seg
24
+ return _lerp_color(GRADIENT_STOPS[seg], GRADIENT_STOPS[seg + 1], local_t)
25
+
26
+
27
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
28
+ style_name = style_config.name
29
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
30
+ direction = getattr(style_config, 'text_direction', 'rtl')
31
+ lang_code = getattr(style_config, 'lang_code', 'fa')
32
+
33
+ base_color = color_parser(style_config.primaryColor, (255, 255, 255, 255))
34
+ active_hex = style_config.styleActiveColors.get(style_name) if style_config.styleActiveColors else None
35
+ active_color = color_parser(active_hex, (255, 215, 130, 255)) if active_hex else color_parser("#FFD782", (255, 215, 130, 255))
36
+
37
+ line_height_px = int(style_config.fontSize * 1.6)
38
+ total_block_height = len(lines) * line_height_px
39
+ bottom_reference = height - style_config.marginV
40
+ start_y_of_block = bottom_reference - total_block_height
41
+ vertical_corr = int(style_config.fontSize * 0.1)
42
+ current_line_y = start_y_of_block - vertical_corr
43
+
44
+ space_w = draw.textlength(" ", font=font)
45
+ global_word_counter = 0
46
+ current_render_time = getattr(style_config, 'current_render_time', 0.0)
47
+
48
+ # --- نوار سیاه سینمایی پشت کل بلوک (letterbox) ---
49
+ if style_name == 'cinematic_letterbox' and len(lines) > 0:
50
+ max_line_width = max([m["width"] for m in line_metrics], default=0)
51
+ bar_pad_y = int(style_config.fontSize * 0.35)
52
+ by1 = start_y_of_block - bar_pad_y
53
+ by2 = start_y_of_block + total_block_height + bar_pad_y
54
+ draw.rectangle([0, by1, width, by2], fill=(0, 0, 0, 190))
55
+
56
+ for line_idx, metrics in enumerate(line_metrics):
57
+ cursor_x = (width + metrics["width"]) / 2 + (style_config.x or 0) if is_rtl else (width - metrics["width"]) / 2 + (style_config.x or 0)
58
+ text_y_pos = current_line_y
59
+ line_word_count = max(1, len(metrics["words"]))
60
+
61
+ for w_i, word in enumerate(metrics["words"]):
62
+ w_len = metrics["word_widths"][w_i]
63
+ word_x = (cursor_x - w_len) if is_rtl else cursor_x
64
+
65
+ is_active = False
66
+ should_draw = True
67
+ time_since_start = 0.0
68
+ if word_infos and global_word_counter < len(word_infos):
69
+ w_info = word_infos[global_word_counter]
70
+ is_active = current_render_time >= w_info.start and current_render_time < w_info.end
71
+ should_draw = current_render_time >= w_info.start
72
+ time_since_start = max(0.0, current_render_time - w_info.start)
73
+ elif active_idx != -1:
74
+ is_active = (global_word_counter == active_idx)
75
+ should_draw = (global_word_counter <= active_idx)
76
+
77
+ this_color = active_color if (is_active and getattr(style_config, 'useActiveColor', True)) else base_color
78
+ if word_infos and global_word_counter < len(word_infos):
79
+ w_obj = word_infos[global_word_counter]
80
+ if hasattr(w_obj, 'color') and w_obj.color:
81
+ this_color = color_parser(w_obj.color, this_color)
82
+
83
+ fade_alpha = min(1.0, time_since_start / 0.35)
84
+ alpha = int(this_color[3] * fade_alpha) if word_infos else this_color[3]
85
+ draw_color = (this_color[0], this_color[1], this_color[2], alpha)
86
+
87
+ if should_draw and alpha > 2:
88
+ if style_name == 'elegant_thin_serif':
89
+ draw.text((word_x, text_y_pos), word, font=font, fill=draw_color, direction=direction, language=lang_code)
90
+ if is_active:
91
+ uy = text_y_pos + int(style_config.fontSize * 1.02)
92
+ draw.line([(word_x, uy), (word_x + w_len, uy)], fill=draw_color, width=max(1, int(style_config.fontSize * 0.025)))
93
+
94
+ elif style_name == 'cinematic_letterbox':
95
+ draw.text((word_x, text_y_pos), word, font=font, fill=draw_color, direction=direction, language=lang_code)
96
+
97
+ elif style_name == 'typewriter_mono':
98
+ pad_x = int(style_config.fontSize * 0.1)
99
+ if is_active:
100
+ cx1, cy1 = word_x - pad_x, text_y_pos - pad_x * 0.3
101
+ cx2, cy2 = word_x + w_len + pad_x, text_y_pos + int(style_config.fontSize * 1.1)
102
+ draw.rectangle([cx1, cy1, cx2, cy2], fill=(20, 255, 90, 60))
103
+ mono_color = (20, 255, 90, alpha) if not is_active else (255, 255, 255, alpha)
104
+ draw.text((word_x, text_y_pos), word, font=font, fill=mono_color, direction=direction, language=lang_code)
105
+
106
+ elif style_name == 'gradient_text':
107
+ t = (w_i / max(1, line_word_count - 1)) if line_word_count > 1 else 0.5
108
+ grad_rgb = _gradient_color_at(t)
109
+ final_color = (grad_rgb[0], grad_rgb[1], grad_rgb[2], alpha)
110
+ if is_active and getattr(style_config, 'useActiveColor', True):
111
+ final_color = draw_color
112
+ draw.text((word_x, text_y_pos), word, font=font, fill=final_color, direction=direction, language=lang_code)
113
+
114
+ cursor_x += (-(w_len + space_w) if is_rtl else (w_len + space_w))
115
+ global_word_counter += 1
116
+
117
+ current_line_y += line_height_px
styles/preset_glow_pack.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont, ImageFilter
2
+ import math
3
+
4
+ # --- بسته استایل‌های آماده: نرم و درخشان ---
5
+ config = {
6
+ "ids": ["soft_glow_pill", "comic_bubble", "handwritten_marker"],
7
+ "name": "بسته نرم و درخشان",
8
+ "panels": ["color", "font", "size", "position", "radius", "padding"],
9
+ "default_font": "vazir"
10
+ }
11
+
12
+ def _blur_glow(img, draw, word, font, draw_x, draw_y, w_len, font_size, color, radius, direction, lang_code):
13
+ pad = radius * 3 + 8
14
+ layer_w = int(w_len) + pad * 2
15
+ layer_h = int(font_size * 1.3) + pad * 2
16
+ if layer_w <= 0 or layer_h <= 0:
17
+ return
18
+ layer = Image.new('RGBA', (layer_w, layer_h), (0, 0, 0, 0))
19
+ ld = ImageDraw.Draw(layer)
20
+ ld.text((pad, pad), word, font=font, fill=color, direction=direction, language=lang_code)
21
+ blurred = layer.filter(ImageFilter.GaussianBlur(radius))
22
+ img.paste(blurred, (int(draw_x - pad), int(draw_y - pad)), blurred)
23
+
24
+
25
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
26
+ style_name = style_config.name
27
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
28
+ direction = getattr(style_config, 'text_direction', 'rtl')
29
+ lang_code = getattr(style_config, 'lang_code', 'fa')
30
+
31
+ base_color = color_parser(style_config.primaryColor, (255, 255, 255, 255))
32
+ active_hex = style_config.styleActiveColors.get(style_name) if style_config.styleActiveColors else None
33
+ active_color = color_parser(active_hex, (255, 255, 255, 255)) if active_hex else color_parser("#FFFFFF", (255, 255, 255, 255))
34
+ accent_hex = style_config.styleBgColors.get(style_name) if style_config.styleBgColors else None
35
+ accent_color = color_parser(accent_hex, (124, 77, 255, 255)) if accent_hex else color_parser("#7C4DFF", (124, 77, 255, 255))
36
+
37
+ line_height_px = int(style_config.fontSize * 1.6)
38
+ total_block_height = len(lines) * line_height_px
39
+ bottom_reference = height - style_config.marginV
40
+ start_y_of_block = bottom_reference - total_block_height
41
+ vertical_corr = int(style_config.fontSize * 0.1)
42
+ current_line_y = start_y_of_block - vertical_corr
43
+
44
+ space_w = draw.textlength(" ", font=font)
45
+ global_word_counter = 0
46
+ current_render_time = getattr(style_config, 'current_render_time', 0.0)
47
+
48
+ for metrics in line_metrics:
49
+ cursor_x = (width + metrics["width"]) / 2 + (style_config.x or 0) if is_rtl else (width - metrics["width"]) / 2 + (style_config.x or 0)
50
+ text_y_pos = current_line_y
51
+
52
+ for w_i, word in enumerate(metrics["words"]):
53
+ w_len = metrics["word_widths"][w_i]
54
+ word_x = (cursor_x - w_len) if is_rtl else cursor_x
55
+
56
+ is_active = False
57
+ should_draw = True
58
+ if word_infos and global_word_counter < len(word_infos):
59
+ w_info = word_infos[global_word_counter]
60
+ is_active = current_render_time >= w_info.start and current_render_time < w_info.end
61
+ should_draw = current_render_time >= w_info.start if getattr(style_config, 'fadeUnread', True) else True
62
+ elif active_idx != -1:
63
+ is_active = (global_word_counter == active_idx)
64
+
65
+ this_color = active_color if (is_active and getattr(style_config, 'useActiveColor', True)) else base_color
66
+ if word_infos and global_word_counter < len(word_infos):
67
+ w_obj = word_infos[global_word_counter]
68
+ if hasattr(w_obj, 'color') and w_obj.color:
69
+ this_color = color_parser(w_obj.color, this_color)
70
+
71
+ if should_draw:
72
+ dy = 0
73
+ if style_name == 'soft_glow_pill':
74
+ pad_x = int(style_config.fontSize * 0.35)
75
+ pad_y = int(style_config.fontSize * 0.2)
76
+ sx1, sy1 = word_x - pad_x, text_y_pos - pad_y
77
+ sx2, sy2 = word_x + w_len + pad_x, text_y_pos + int(style_config.fontSize * 1.05) + pad_y
78
+ fill_col = accent_color if is_active else (accent_color[0], accent_color[1], accent_color[2], 70)
79
+ draw.rounded_rectangle([sx1, sy1, sx2, sy2], radius=(sy2 - sy1) / 2, fill=fill_col)
80
+ if is_active:
81
+ _blur_glow(img, draw, word, font, word_x, text_y_pos, w_len, style_config.fontSize, (*accent_color[:3], 180), 5, direction, lang_code)
82
+
83
+ elif style_name == 'comic_bubble':
84
+ pad_x = int(style_config.fontSize * 0.3)
85
+ pad_y = int(style_config.fontSize * 0.2)
86
+ sx1, sy1 = word_x - pad_x, text_y_pos - pad_y
87
+ sx2, sy2 = word_x + w_len + pad_x, text_y_pos + int(style_config.fontSize * 1.05) + pad_y
88
+ fill_col = accent_color if is_active else (255, 255, 255, 235)
89
+ draw.rounded_rectangle([sx1 + 4, sy1 + 4, sx2 + 4, sy2 + 4], radius=14, fill=(0, 0, 0, 90))
90
+ draw.rounded_rectangle([sx1, sy1, sx2, sy2], radius=14, fill=fill_col, outline=(0, 0, 0, 255), width=4)
91
+ this_color = (255, 255, 255, 255) if is_active else (20, 20, 20, 255)
92
+
93
+ elif style_name == 'handwritten_marker':
94
+ pad_x = int(style_config.fontSize * 0.12)
95
+ hy1 = text_y_pos + int(style_config.fontSize * 0.55)
96
+ hy2 = text_y_pos + int(style_config.fontSize * 1.05)
97
+ if is_active:
98
+ draw.rectangle([word_x - pad_x, hy1, word_x + w_len + pad_x, hy2], fill=(*accent_color[:3], 160))
99
+
100
+ draw.text((word_x, text_y_pos + dy), word, font=font, fill=this_color, direction=direction, language=lang_code)
101
+
102
+ cursor_x += (-(w_len + space_w) if is_rtl else (w_len + space_w))
103
+ global_word_counter += 1
104
+
105
+ current_line_y += line_height_px
styles/red_outline_box.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont
2
+
3
+ config = {
4
+ "ids": ["red_outline_box"],
5
+ "name": "Red Outline Box",
6
+ "panels": ["color", "font", "size", "position", "radius", "padding"],
7
+ "default_font": "vazir"
8
+ }
9
+
10
+ frontend_template = "" # فرانت‌اند توسط جاوااسکریپت در فایل editor.js هندل می‌شود
11
+
12
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
13
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
14
+ # رنگ متن پایه (سفید)
15
+ custom_text = style_config.styleColors.get('red_outline_box', '#FFFFFF')
16
+ base_text_color = color_parser(custom_text, (255, 255, 255, 255))
17
+
18
+ # رنگ کلمه فعال (سفید)
19
+ active_hex = style_config.styleActiveColors.get('red_outline_box', '#FFFFFF')
20
+ active_text_color = color_parser(active_hex, (255, 255, 255, 255))
21
+
22
+ # رنگ کادر (پیش فرض مثل کادر کلمات ریزان)
23
+ custom_bg_hex = style_config.styleBgColors.get('red_outline_box', '#F77D57')
24
+ box_color = color_parser(custom_bg_hex, (247, 125, 87, 255))
25
+
26
+ # دریافت رنگ حاشیه اختصاصی (پیش فرض قرمز خالص)
27
+ custom_stroke_hex = style_config.styleBgColors.get('red_outline_box_stroke', '#ff0000')
28
+ stroke_color = color_parser(custom_stroke_hex, (255, 0, 0, 255))
29
+ # ضخامت حاشیه
30
+ stroke_w = max(2, int((style_config.fontSize / 12) * 0.9))
31
+
32
+ line_height_px = int(style_config.fontSize * 1.5)
33
+ total_block_height = len(lines) * line_height_px
34
+ bottom_reference = height - style_config.marginV
35
+ start_y_of_block = bottom_reference - total_block_height
36
+
37
+ # محاسبه دقیق عرض کادر کلی متن با در نظر گرفتن فاصله‌ها
38
+ space_w = draw.textlength(" ", font=font)
39
+ max_line_width = 0
40
+ for m in line_metrics:
41
+ actual_w = sum(m["word_widths"]) + max(0, len(m["words"]) - 1) * space_w
42
+ if actual_w > max_line_width: max_line_width = actual_w
43
+
44
+ box_center_x = width / 2 + (style_config.x or 0)
45
+
46
+ # بازگرداندن طول کادر به حالت قبلی (با احتساب فضای مرورگر و حاشیه متن قرمز)
47
+ box_width = max_line_width + (style_config.paddingX * 2) + 20 + (stroke_w * 2)
48
+
49
+ ratio = height / width
50
+ if ratio > 1.6: box_center_y_adjustment = 12
51
+ elif ratio > 1.1: box_center_y_adjustment = 8
52
+ else: box_center_y_adjustment = 5
53
+
54
+ visual_top_correction = int(style_config.fontSize * 0.12)
55
+ box_x1 = box_center_x - (box_width / 2)
56
+ box_x2 = box_center_x + (box_width / 2)
57
+
58
+ # نقطه مبنای رسم کادر (برای حفظ جایگاه دقیق متن)
59
+ base_y1 = start_y_of_block - style_config.paddingY + box_center_y_adjustment + visual_top_correction
60
+
61
+ # تنظیم دقیق ارتفاع کادر (برش 15 درصدی بالا و پایین مشابه کلاسیک)
62
+ box_y1 = base_y1 + (style_config.paddingY * 0.15)
63
+ box_y2 = start_y_of_block + total_block_height + (style_config.paddingY * 0.85) + box_center_y_adjustment - (line_height_px * 0.3)
64
+
65
+ if box_y2 <= box_y1:
66
+ box_y2 = box_y1 + 1
67
+
68
+ # رسم کادر پس‌زمینه در صورت وجود متن
69
+ if len(lines) > 0:
70
+ draw.rounded_rectangle([box_x1, box_y1, box_x2, box_y2], radius=style_config.radius, fill=box_color)
71
+
72
+ # تنظیمات رسم حروف
73
+ text_start_y = base_y1 + (style_config.paddingY * 0.35)
74
+ vertical_corr = int(style_config.fontSize * 0.1)
75
+ current_line_y = text_start_y - vertical_corr
76
+ global_word_counter = 0
77
+ current_render_time = getattr(style_config, 'current_render_time', 0.0)
78
+
79
+ for line_idx, metrics in enumerate(line_metrics):
80
+ # محاسبه عرض دقیق و واقعی خط برای جلوگیری از هل داده شدن کلمه آخر به بیرون کادر
81
+ actual_line_width = sum(metrics["word_widths"]) + max(0, len(metrics["words"]) - 1) * space_w
82
+ if is_rtl:
83
+ start_x = (width + actual_line_width) / 2 + (style_config.x or 0)
84
+ else:
85
+ start_x = (width - actual_line_width) / 2 + (style_config.x or 0)
86
+ cursor_x = start_x
87
+ text_y_pos = current_line_y
88
+
89
+ for w_i, word in enumerate(metrics["words"]):
90
+ w_len = metrics["word_widths"][w_i]
91
+ word_x = (cursor_x - w_len) if is_rtl else cursor_x
92
+
93
+ # پیش‌فرض نمایش کلمه فعال است
94
+ should_draw = True
95
+ alpha_factor = 1.0
96
+ is_active = False
97
+
98
+ if word_infos and global_word_counter < len(word_infos):
99
+ w_info = word_infos[global_word_counter]
100
+ # تشخیص کلمه فعال در بازه زمانی رندر فریم فعلی
101
+ is_active = (current_render_time >= w_info.start and current_render_time < w_info.end)
102
+
103
+ # اعمال نیمه‌شفافی (کمرنگ شدن کلمات غیرفعال بر اساس تنظیمات کاربر)
104
+ if current_render_time < w_info.start:
105
+ # کلمات آینده
106
+ if getattr(style_config, 'fadeUnread', False) or getattr(style_config, 'fadeSurrounding', False):
107
+ alpha_factor = 0.35
108
+ elif current_render_time >= w_info.end:
109
+ # کلمات گذشته
110
+ if getattr(style_config, 'fadeSurrounding', False):
111
+ alpha_factor = 0.35
112
+ elif active_idx != -1:
113
+ is_active = (global_word_counter == active_idx)
114
+
115
+ # استخراج کلمه مورد رسم (برای کنترل انیمیشن‌های احتمالی مانند ماشین تحریر)
116
+ display_word = word
117
+ draw_x = word_x
118
+ is_typewriter = getattr(style_config, 'typewriter', False)
119
+ if is_typewriter and word_infos and global_word_counter < len(word_infos):
120
+ w_info = word_infos[global_word_counter]
121
+ if current_render_time < w_info.start:
122
+ should_draw = False
123
+ elif current_render_time >= w_info.start and current_render_time < w_info.end:
124
+ char_len = len(word)
125
+ progress = (current_render_time - w_info.start) / max(0.001, w_info.end - w_info.start)
126
+ visible_chars = min(char_len, int(progress * char_len) + 1)
127
+ display_word = word[:visible_chars]
128
+ try: sub_w = draw.textlength(display_word, font=font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
129
+ except: sub_w = font.getlength(display_word)
130
+ draw_x = (cursor_x - sub_w) if is_rtl else draw_x
131
+
132
+ # رسم نهایی کلمه همراه با حاشیه قرمز رنگ
133
+ if should_draw and display_word:
134
+ this_color = active_text_color if (is_active and getattr(style_config, 'useActiveColor', True)) else base_text_color
135
+
136
+ # بررسی رنگ‌های سفارشی اعمال شده روی تک کلمات از ابزار ادیتور
137
+ if word_infos and global_word_counter < len(word_infos):
138
+ w_obj = word_infos[global_word_counter]
139
+ if hasattr(w_obj, 'color') and w_obj.color:
140
+ this_color = color_parser(w_obj.color, this_color)
141
+
142
+ # استخراج ضریب آلفای نهایی (ترکیب شفافیت رنگ با ضریب کمرنگ شدن)
143
+ total_alpha = (this_color[3] / 255.0) * alpha_factor
144
+
145
+ # ترکیب دستی رنگ‌ها با پس‌زمینه کادر (رندر توپر) برای جلوگیری از باگ سوراخ شدن متن در کتابخانه Pillow
146
+ f_r = int((this_color[0] * total_alpha) + (box_color[0] * (1 - total_alpha)))
147
+ f_g = int((this_color[1] * total_alpha) + (box_color[1] * (1 - total_alpha)))
148
+ f_b = int((this_color[2] * total_alpha) + (box_color[2] * (1 - total_alpha)))
149
+ final_color = (f_r, f_g, f_b, 255)
150
+
151
+ s_r = int((stroke_color[0] * total_alpha) + (box_color[0] * (1 - total_alpha)))
152
+ s_g = int((stroke_color[1] * total_alpha) + (box_color[1] * (1 - total_alpha)))
153
+ s_b = int((stroke_color[2] * total_alpha) + (box_color[2] * (1 - total_alpha)))
154
+ final_stroke = (s_r, s_g, s_b, 255)
155
+
156
+ draw.text((draw_x, text_y_pos), display_word, font=font, fill=final_color, stroke_width=stroke_w, stroke_fill=final_stroke, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
157
+
158
+ cursor_x += (-(w_len + space_w) if is_rtl else (w_len + space_w))
159
+ global_word_counter += 1
160
+
161
+ current_line_y += line_height_px
styles/simple_bar.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont
2
+
3
+ config = {
4
+ "ids": ["simple_bar"],
5
+ "name": "Simple Bar",
6
+ "panels": ["font", "size", "position"],
7
+ "default_font": "vazir"
8
+ }
9
+
10
+ # قالب HTML برای نمایش زنده در ادیتور (فرانت‌اند)
11
+ frontend_template = """
12
+ <div style="display: flex; flex-direction: column; align-items: center; position: relative; transform: translateY(-20px);">
13
+ <!-- نوار پیشرفت -->
14
+ <div style="
15
+ position: relative;
16
+ width: 13em;
17
+ height: 6px;
18
+ margin-bottom: 0px;
19
+ direction: ltr;
20
+ flex-shrink: 0;
21
+ ">
22
+ <!-- خط سفید زیرین -->
23
+ <div style="
24
+ position: absolute; top: 0; left: 0; width: 100%; height: 5px;
25
+ background-color: #ffffff; border-radius: 10px; margin-top: 1.5px;
26
+ "></div>
27
+ <!-- نوار زرد پرشونده -->
28
+ <div style="
29
+ position: absolute; top: 0; left: 0; height: 10px;
30
+ background-color: #FDD835; border-radius: 10px;
31
+ box-shadow: 0 0 8px var(--progress-shadow, rgba(253, 216, 53, 0.6));
32
+ /* --- تغییرات اصلی اینجا هستند --- */
33
+ width: calc(100% - var(--progress, 100%)); /* اصلاح جهت حرکت از چپ به راست */
34
+ transition: width 0.25s linear; /* افزایش زمان برای حرکت روان‌تر */
35
+ "></div>
36
+ </div>
37
+
38
+ <!-- کادر متن -->
39
+ <div style="
40
+ position: absolute;
41
+ top: 100%;
42
+ margin-top: 20px;
43
+ left: 50%;
44
+ transform: translateX(-50%);
45
+ background-color: rgba(0, 0, 0, 0.5);
46
+ color: #ffffff;
47
+ padding: 10px 30px;
48
+ border-radius: 15px;
49
+ text-align: center;
50
+ white-space: normal;
51
+ width: max-content;
52
+ white-space: normal;
53
+ font-family: inherit;
54
+ font-weight: normal;
55
+ line-height: 1.6;
56
+ ">{{WORD}}</div>
57
+ </div>
58
+ """
59
+
60
+ # منطق تولید ویدیو (بک‌اند)
61
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
62
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
63
+ # دریافت رنگ کادر اصلی از تنظیمات (کلید اختصاصی simple_bar_main_box)
64
+ bg_hex = style_config.styleBgColors.get('simple_bar_main_box', '#00000080')
65
+ box_color = color_parser(bg_hex, (0, 0, 0, 128))
66
+
67
+ # دریافت رنگ متن از تنظیمات (تغییر جدید)
68
+ custom_hex = style_config.styleColors.get('simple_bar', '#ffffff')
69
+ text_color = color_parser(custom_hex, (255, 255, 255, 255))
70
+
71
+ track_color = (255, 255, 255, 255) # سفید کامل برای خط زیرین
72
+ # دریافت رنگ نوار پیشرفت (پیش‌فرض: زرد)
73
+ # ساخت کلید اختصاصی: simple_bar_progress
74
+ progress_key = f"{style_config.name}_progress"
75
+ bar_hex = style_config.styleBgColors.get(progress_key, '#FDD835')
76
+ bar_color = color_parser(bar_hex, (253, 216, 53, 255))
77
+
78
+ # تنظیمات هندسی
79
+ padding_x = 30
80
+ padding_y = 10
81
+ radius = 15
82
+ bar_margin_bottom = 20
83
+
84
+ # 1. آماده‌سازی متن (چند خطی)
85
+ line_height_px = int(style_config.fontSize * 1.5)
86
+ text_h = len(lines) * line_height_px
87
+
88
+ # پیدا کردن عرض عریض‌ترین خط
89
+ text_w = 0
90
+ for line in lines:
91
+ line_str = " ".join(line)
92
+ try: w = draw.textlength(line_str, font=font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
93
+ except: w = font.getlength(line_str)
94
+ if w > text_w: text_w = w
95
+
96
+ # 2. محاسبه ابعاد کادر متن
97
+ box_w = text_w + (padding_x * 2)
98
+ box_h = text_h + (padding_y * 2)
99
+
100
+ center_x = width / 2 + (style_config.x or 0)
101
+ # تغییر: نقطه شروع (بالای کادر) را ثابت می‌گیریم تا نوار زرد جابجا نشود
102
+ top_y = height - style_config.marginV
103
+
104
+ box_x1 = center_x - (box_w / 2)
105
+ box_y1 = top_y
106
+ box_x2 = center_x + (box_w / 2)
107
+ box_y2 = top_y + box_h
108
+
109
+ # 3. محاسبه درصد پیشرفت
110
+ # اصلاح باگ NoneType: بررسی می‌کنیم اگر None بود صفر در نظر بگیرد
111
+ current_t = style_config.current_render_time
112
+ if current_t is None:
113
+ current_t = 0.0
114
+
115
+ total_dur = style_config.total_video_duration
116
+ if total_dur is None or total_dur == 0:
117
+ total_dur = 1.0
118
+
119
+ progress = 0.0
120
+ if total_dur > 0:
121
+ progress = max(0.0, min(1.0, current_t / total_dur))
122
+
123
+ # 4. ابعاد نوار پیشرفت (ثابت نگه داشتن عرض نوار در کل پروژه)
124
+ # با این ضریب (13) عرض نوار بر اساس حداکثر ظرفیت 5 کلمه بزرگ تنظیم و ثابت می‌شود
125
+ bar_w_total = style_config.fontSize * 13
126
+ bar_x_start = center_x - (bar_w_total / 2)
127
+
128
+ # موقعیت عمودی نوار (بالای باکس متن)
129
+ # ارتفاع نوار 6 پیکسل است، ترَک 3 پیکسل. وسط‌چین عمودی نسبت به هم.
130
+ track_y_center = box_y1 - bar_margin_bottom - 3
131
+
132
+ # 5. رسم خط سفید (Track)
133
+ track_h = 4
134
+ draw.rounded_rectangle(
135
+ [bar_x_start, track_y_center - (track_h/2), bar_x_start + bar_w_total, track_y_center + (track_h/2)],
136
+ radius=10, fill=track_color
137
+ )
138
+
139
+ # 6. رسم نوار زرد (Progress)
140
+ bar_h = 10
141
+ filled_w = bar_w_total * progress
142
+ if filled_w > 0:
143
+ draw.rounded_rectangle(
144
+ [bar_x_start, track_y_center - (bar_h/2), bar_x_start + filled_w, track_y_center + (bar_h/2)],
145
+ radius=10, fill=bar_color
146
+ )
147
+
148
+ # 7. رسم کادر سیاه شفاف
149
+ draw.rounded_rectangle([box_x1, box_y1, box_x2, box_y2], radius=radius, fill=box_color)
150
+
151
+ # 8. رسم متن (حلقه روی خطوط)
152
+ # محاسبه نقطه شروع عمودی برای وسط‌چین کردن کل بلوک متن
153
+ current_text_y = box_y1 + (box_h - text_h) / 2
154
+
155
+ # متغیرهای کمکی برای رسم کلمه به کلمه
156
+ word_idx_counter = 0
157
+ space_w = draw.textlength(" ", font=font)
158
+
159
+ for line in lines:
160
+ # محاسبه عرض کل خط برای وسط‌چین کردن
161
+ line_str = " ".join(line)
162
+ try: lw = draw.textlength(line_str, font=font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
163
+ except: lw = font.getlength(line_str)
164
+
165
+ # نقطه شروع رسم (وسط‌چین) بر اساس جهت زبان
166
+ cursor_x = (center_x + (lw / 2)) if is_rtl else (center_x - (lw / 2))
167
+
168
+ for word in line:
169
+ try: w_len = draw.textlength(word, font=font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
170
+ except: w_len = font.getlength(word)
171
+
172
+ # 1. تنظیم رنگ پیش‌فرض
173
+ current_word_fill = text_color
174
+
175
+ # 2. اعمال رنگ کلمه فعال (Active Word)
176
+ if getattr(style_config, 'useActiveColor', True) and active_idx != -1 and word_idx_counter == active_idx:
177
+ active_hex = style_config.styleActiveColors.get('simple_bar', '#ffffff')
178
+ current_word_fill = color_parser(active_hex, text_color)
179
+
180
+ # 3. بررسی رنگ اختصاصی کاربر (اولویت بالاتر)
181
+ if word_infos and word_idx_counter < len(word_infos):
182
+ w_info = word_infos[word_idx_counter]
183
+ if hasattr(w_info, 'color') and w_info.color:
184
+ current_word_fill = color_parser(w_info.color, text_color)
185
+
186
+ # منطق تایپ حرف به حرف برای بک‌اند
187
+ display_word = word
188
+ draw_x = (cursor_x - w_len) if is_rtl else cursor_x
189
+ should_draw = True
190
+
191
+ is_typewriter = getattr(style_config, 'typewriter', False)
192
+ if is_typewriter and word_infos and word_idx_counter < len(word_infos):
193
+ w_info = word_infos[word_idx_counter]
194
+ current_t = getattr(style_config, 'current_render_time', 0.0)
195
+ if current_t < w_info.start:
196
+ should_draw = False
197
+ elif current_t >= w_info.start and current_t < w_info.end:
198
+ char_len = len(word)
199
+ progress = (current_t - w_info.start) / max(0.001, w_info.end - w_info.start)
200
+ visible_chars = min(char_len, int(progress * char_len) + 1)
201
+ display_word = word[:visible_chars]
202
+ # محاسبه عرض حروف تایپ شده برای چسبیدن به راست
203
+ try: sub_w = draw.textlength(display_word, font=font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
204
+ except: sub_w = font.getlength(display_word)
205
+ draw_x = (cursor_x - sub_w) if is_rtl else draw_x
206
+
207
+ # رسم کلمه
208
+ if should_draw and display_word:
209
+ draw.text((draw_x, current_text_y), display_word, font=font, fill=current_word_fill, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
210
+
211
+ # حرکت به کلمه بعدی (بر اساس جهت زبان)
212
+ cursor_x += (-(w_len + space_w) if is_rtl else (w_len + space_w))
213
+ word_idx_counter += 1
214
+
215
+ current_text_y += line_height_px
styles/spring_popup.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont
2
+
3
+ config = {
4
+ "ids": ["spring_popup"],
5
+ "name": "Spring Popup",
6
+ "panels": ["color", "font", "size", "position"],
7
+ "default_font": "vazir"
8
+ }
9
+
10
+ frontend_template = "" # با JS هندل می‌شود
11
+
12
+ def draw_frame(draw, img, width, height, style_config, lines, line_metrics, active_idx, font, color_parser, word_infos=None):
13
+ is_rtl = getattr(style_config, 'text_direction', 'rtl') != 'ltr'
14
+ # رنگ متن خاموش (پیش‌فرض سفید)
15
+ custom_base = style_config.styleColors.get('spring_popup', '#ffffff')
16
+ base_color = color_parser(custom_base, (255, 255, 255, 255))
17
+
18
+ # رنگ کلمه فعال (پیش‌فرض سفید)
19
+ active_hex = style_config.styleActiveColors.get('spring_popup', '#ffffff')
20
+ active_color = color_parser(active_hex, (255, 255, 255, 255))
21
+
22
+ line_height_px = int(style_config.fontSize * 1.5)
23
+ total_block_height = len(lines) * line_height_px
24
+ bottom_reference = height - style_config.marginV
25
+ start_y_of_block = bottom_reference - total_block_height
26
+
27
+ # فاصله طبیعی کلمات دقیقاً مشابه استایل سفید مینیمال
28
+ try:
29
+ space_w = draw.textlength(" ", font=font)
30
+ except:
31
+ space_w = font.getlength(" ")
32
+ # افزایش ۱۰ درصدی فاصله کلمات در خروجی
33
+ space_w = int(space_w * 1.8)
34
+
35
+ # تراز عمودی دقیقاً مشابه سفید مینیمال
36
+ ratio = height / width
37
+ if ratio > 1.6: box_center_y_adjustment = 5
38
+ elif ratio > 1.1: box_center_y_adjustment = 10
39
+ else: box_center_y_adjustment = 15
40
+
41
+ visual_top_correction = int(style_config.fontSize * 0.12)
42
+ box_y1 = start_y_of_block - (style_config.paddingY or 10) + box_center_y_adjustment + visual_top_correction
43
+ text_start_y = box_y1 + ((style_config.paddingY or 10) * 0.35)
44
+ vertical_corr = int(style_config.fontSize * 0.1)
45
+ current_line_y = text_start_y - vertical_corr
46
+
47
+ current_render_time = getattr(style_config, 'current_render_time', 0.0)
48
+ is_act_on = getattr(style_config, 'useActiveColor', True)
49
+
50
+ global_word_counter = 0
51
+ active_word_data = None
52
+
53
+ # رسم کلمات غیرفعال بر روی تصویر اصلی با تراز کاملاً یکدست
54
+ for line_idx, metrics in enumerate(line_metrics):
55
+ cursor_x = (width + metrics["width"]) / 2 + (style_config.x or 0) if is_rtl else (width - metrics["width"]) / 2 + (style_config.x or 0)
56
+
57
+ for w_i, word in enumerate(metrics["words"]):
58
+ w_len = metrics["word_widths"][w_i]
59
+ word_x = (cursor_x - w_len) if is_rtl else cursor_x
60
+
61
+ w_info = word_infos[global_word_counter] if (word_infos and global_word_counter < len(word_infos)) else None
62
+
63
+ is_active = False
64
+ scale = 1.0
65
+ alpha = 255
66
+
67
+ if w_info:
68
+ if current_render_time >= w_info.start and current_render_time < w_info.end:
69
+ is_active = True
70
+ time_passed = current_render_time - w_info.start
71
+ anim_dur = 0.32 # افزایش جزئی زمان برای نرم‌تر شدن و هماهنگی با فریم‌های دوبرابر شده
72
+ if time_passed < anim_dur:
73
+ t = time_passed / anim_dur
74
+ c1 = 1.15 # کاهش شتاب پرش برای ایجاد یک لرزش فنری بسیار ملایم و روان
75
+ c3 = c1 + 1
76
+ progress = 1 + c3 * ((t - 1) ** 3) + c1 * ((t - 1) ** 2)
77
+ scale = 1.0 + (progress * 0.2)
78
+ else:
79
+ scale = 1.2
80
+ else:
81
+ if current_render_time < w_info.start:
82
+ if getattr(style_config, 'fadeUnread', False) or getattr(style_config, 'fadeSurrounding', False):
83
+ alpha = int(255 * 0.35)
84
+ elif current_render_time >= w_info.end:
85
+ if getattr(style_config, 'fadeSurrounding', False):
86
+ alpha = int(255 * 0.35)
87
+ elif active_idx != -1:
88
+ if global_word_counter == active_idx:
89
+ is_active = True
90
+ scale = 1.2
91
+
92
+ fill_color = active_color if (is_active and is_act_on) else base_color
93
+ if w_info and hasattr(w_info, 'color') and w_info.color:
94
+ fill_color = color_parser(w_info.color, fill_color)
95
+
96
+ final_color = (fill_color[0], fill_color[1], fill_color[2], int(fill_color[3] * (alpha / 255.0)))
97
+
98
+ if final_color[3] > 0:
99
+ if not is_active:
100
+ # رسم کلمات خاموش بر روی خط تراز بدون تغییر
101
+ draw.text((word_x, current_line_y), word, font=font, fill=final_color, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
102
+ else:
103
+ # ذخیره کلمه فعال جهت اعمال افکت پاپ‌آپ هم‌تراز
104
+ active_word_data = {
105
+ "text": word, "x": word_x, "y": current_line_y,
106
+ "color": final_color, "scale": scale
107
+ }
108
+
109
+ cursor_x += (-(w_len + space_w) if is_rtl else (w_len + space_w))
110
+ global_word_counter += 1
111
+
112
+ current_line_y += line_height_px
113
+
114
+ # رسم کلمه فعال (بزرگ‌شده) با تراز فیزیکی پایین کلمه و اعمال فاصله شناور به سمت بالا
115
+ if active_word_data:
116
+ scale = active_word_data["scale"]
117
+ text = active_word_data["text"]
118
+ x = active_word_data["x"]
119
+ y = active_word_data["y"]
120
+ color = active_word_data["color"]
121
+
122
+ try:
123
+ tw = draw.textlength(text, font=font, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
124
+ except:
125
+ tw = font.getlength(text)
126
+
127
+ th = int(style_config.fontSize * 2)
128
+ pad = 30
129
+
130
+ temp_img = Image.new('RGBA', (int(tw) + pad*2, th + pad*2), (0,0,0,0))
131
+ temp_draw = ImageDraw.Draw(temp_img)
132
+
133
+ temp_draw.text((pad, pad), text, font=font, fill=color, direction=getattr(style_config, 'text_direction', 'rtl'), language=getattr(style_config, 'lang_code', 'fa'))
134
+
135
+ # پیدا کردن فیزیکی کادر دور پیکسل‌های رسم شده (حل مشکل عدم پایداری تراز فونت‌های فارسی)
136
+ t_bbox = temp_img.getbbox()
137
+ if t_bbox:
138
+ t_left, t_top, t_right, t_bottom = t_bbox
139
+ v_w = t_right - t_left
140
+ v_h = t_bottom - t_top
141
+
142
+ v_cx = t_left + (v_w / 2.0)
143
+ v_bottom = t_bottom
144
+
145
+ new_w = max(1, int(temp_img.width * scale))
146
+ new_h = max(1, int(temp_img.height * scale))
147
+
148
+ try: resample = Image.Resampling.LANCZOS
149
+ except: resample = Image.LANCZOS
150
+
151
+ scaled_img = temp_img.resize((new_w, new_h), resample)
152
+
153
+ # تراز کردن و گره زدن نقطه وسطِ لبه پایینی تصویر بزرگ‌شده به بقیه خط
154
+ target_center_x = v_cx + (x - pad)
155
+ target_bottom_y = v_bottom + (y - pad)
156
+
157
+ scaled_cx = v_cx * scale
158
+ scaled_bottom = v_bottom * scale
159
+
160
+ paste_x = int(target_center_x - scaled_cx)
161
+
162
+ # اعمال یک فاصله عمودی به سمت بالا (افزایش ضریب از ۱۰٪ به ۲۰٪ برای ۱۰ درصد بالا رفتن بیشتر)
163
+ float_offset = int(style_config.fontSize * 0.2)
164
+ paste_y = int(target_bottom_y - scaled_bottom) - float_offset
165
+
166
+ img.paste(scaled_img, (paste_x, paste_y), scaled_img)
167
+ else:
168
+ img.paste(temp_img, (int(x - pad), int(y - pad)), temp_img)