Spaces:
Sleeping
Sleeping
Transfer Bot commited on
Commit ·
5ff2fd9
0
Parent(s):
Moved to Hugging Face automatically
Browse files- Dockerfile +23 -0
- README.md +10 -0
- app.py +1185 -0
- index.html +1376 -0
- requirements.txt +11 -0
- static/css/style.css +1023 -0
- static/js/api.js +473 -0
- static/js/db.js +56 -0
- static/js/editor.js +0 -0
- static/js/globals.js +413 -0
- static/js/main.js +184 -0
- static/js/ui.js +306 -0
- static/js/utils.js +39 -0
- styles/ai_dynamic.py +198 -0
- styles/blue_cloud.py +137 -0
- styles/blue_outline.py +128 -0
- styles/box.py +273 -0
- styles/classic.py +154 -0
- styles/dark_edges.py +248 -0
- styles/falling_karaoke.py +167 -0
- styles/falling_plain.py +124 -0
- styles/falling_words.py +164 -0
- styles/karaoke.py +156 -0
- styles/music.py +588 -0
- styles/preset_bold_pack.py +95 -0
- styles/preset_elegant_pack.py +117 -0
- styles/preset_glow_pack.py +105 -0
- styles/red_outline_box.py +161 -0
- styles/simple_bar.py +215 -0
- styles/spring_popup.py +168 -0
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)
|