ex510 commited on
Commit
8f7c473
·
verified ·
1 Parent(s): b35007e

Upload 7 files

Browse files
Files changed (7) hide show
  1. Dockerfile +26 -0
  2. README.md +59 -10
  3. __init__.py +30 -0
  4. base.py +92 -0
  5. main.py +155 -0
  6. renderer.py +95 -0
  7. showcase_arabic.py +138 -0
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # تثبيت FFmpeg والأدوات
4
+ RUN apt-get update && apt-get install -y \
5
+ ffmpeg \
6
+ wget \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ # تثبيت المكتبات
10
+ RUN pip install --no-cache-dir \
11
+ fastapi \
12
+ uvicorn \
13
+ pillow \
14
+ python-multipart
15
+
16
+ WORKDIR /app
17
+ COPY . .
18
+
19
+ # تحميل الخط العربي وقت البناء
20
+ RUN wget -q -O /tmp/arabic.ttf \
21
+ 'https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoNaskhArabic/NotoNaskhArabic-Bold.ttf' \
22
+ && echo "✅ الخط جاهز"
23
+
24
+ EXPOSE 7860
25
+
26
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,59 @@
1
- ---
2
- title: Post
3
- emoji: 🌖
4
- colorFrom: green
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🎬 Video Showcase API
2
+
3
+ FastAPI بيعمل فيديو Product Showcase تلقائي
4
+
5
+ ## الملفات
6
+
7
+ ```
8
+ video_api/
9
+ ├── main.py ← FastAPI
10
+ ├── renderer.py ← محرك الفيديو
11
+ ├── Dockerfile ← للـ HuggingFace
12
+ ├── templates/
13
+ │ ├── __init__.py ← سجل التمبلتس
14
+ │ ├── base.py ← الكلاس الأساسي
15
+ │ └── showcase_arabic.py ← تمبلت عربي
16
+ ```
17
+
18
+ ## الـ Endpoints
19
+
20
+ | Method | URL | الوظيفة |
21
+ |--------|-----|---------|
22
+ | GET | `/` | معلومات الـ API |
23
+ | GET | `/templates` | التمبلتس المتاحة |
24
+ | POST | `/render` | اعمل فيديو |
25
+ | GET | `/video/{id}` | حمّل الفيديو |
26
+
27
+ ## مثال من n8n
28
+
29
+ ```json
30
+ POST /render
31
+ {
32
+ "template": "showcase_arabic",
33
+ "title": "احدث اجهزة\nكهربائية",
34
+ "discount": "خصم 20٪",
35
+ "badge": "ضمان\nسنتين",
36
+ "phone": "+20-100-000-0000",
37
+ "website": "www.example.com",
38
+ "image_path": "/tmp/product.png",
39
+ "music_path": "/tmp/music.mp3"
40
+ }
41
+ ```
42
+
43
+ ## إضافة تمبلت جديد
44
+
45
+ 1. عمل ملف `templates/my_template.py`
46
+ 2. ترث من `BaseTemplate`
47
+ 3. بتعمل `make_frame` بتاعك
48
+ 4. تضيفه في `templates/__init__.py`
49
+
50
+ ```python
51
+ from .my_template import MyTemplate
52
+
53
+ TEMPLATES = {
54
+ t.NAME: t for t in [
55
+ ShowcaseArabic,
56
+ MyTemplate, # ← هنا
57
+ ]
58
+ }
59
+ ```
__init__.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Template Registry
3
+ بيسجّل كل التمبلتس تلقائياً
4
+ لما تضيف تمبلت جديد — بس استورده هنا
5
+ """
6
+
7
+ from .showcase_arabic import ShowcaseArabic
8
+ # from .showcase_dark import ShowcaseDark ← تمبلت جديد
9
+ # from .minimal_product import MinimalProduct ← تمبلت جديد
10
+
11
+ TEMPLATES = {
12
+ t.NAME: t
13
+ for t in [
14
+ ShowcaseArabic,
15
+ # ShowcaseDark,
16
+ # MinimalProduct,
17
+ ]
18
+ }
19
+
20
+
21
+ def get_template(name: str):
22
+ cls = TEMPLATES.get(name)
23
+ if not cls:
24
+ available = list(TEMPLATES.keys())
25
+ raise ValueError(f"التمبلت '{name}' مش موجود. المتاح: {available}")
26
+ return cls()
27
+
28
+
29
+ def list_templates() -> list:
30
+ return [cls().info for cls in TEMPLATES.values()]
base.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Base Template Class
3
+ كل تمبلت جديد بيرث من الكلاس ده
4
+ """
5
+
6
+ from abc import ABC, abstractmethod
7
+ from PIL import Image
8
+ from dataclasses import dataclass
9
+ from typing import Optional
10
+
11
+
12
+ @dataclass
13
+ class RenderRequest:
14
+ """البيانات اللي بتيجي من n8n"""
15
+ title: str
16
+ discount: str = ""
17
+ badge: str = ""
18
+ phone: str = ""
19
+ website: str = ""
20
+ image_path: str = ""
21
+ music_path: str = ""
22
+ output_path: str = "/tmp/output.mp4"
23
+ # إضافات اختيارية
24
+ bg_left: str = ""
25
+ bg_right: str = ""
26
+ duration: int = 6
27
+ fps: int = 30
28
+ width: int = 1280
29
+ height: int = 720
30
+ music_volume: float = 0.20
31
+
32
+
33
+ class BaseTemplate(ABC):
34
+ """
35
+ الكلاس الأساسي — كل تمبلت بيرث منه
36
+
37
+ عشان تعمل تمبلت جديد:
38
+ 1. عمل ملف في templates/
39
+ 2. ترث من BaseTemplate
40
+ 3. تعمل make_frame بتاعك
41
+ """
42
+
43
+ NAME = "base"
44
+ DESCRIPTION = "Base template"
45
+ AUTHOR = ""
46
+
47
+ def ease_out(self, t: float) -> float:
48
+ return 1 - (1 - t) ** 3
49
+
50
+ def ease_in_out(self, t: float) -> float:
51
+ return t * t * (3 - 2 * t)
52
+
53
+ def load_font(self, size: int):
54
+ from PIL import ImageFont
55
+ candidates = [
56
+ '/tmp/arabic.ttf',
57
+ '/usr/share/fonts/truetype/noto/NotoNaskhArabic-Bold.ttf',
58
+ '/usr/share/fonts/truetype/noto/NotoSansArabic-Bold.ttf',
59
+ '/usr/share/fonts/opentype/noto/NotoNaskhArabic-Bold.otf',
60
+ 'C:/Windows/Fonts/arial.ttf',
61
+ ]
62
+ for path in candidates:
63
+ import os
64
+ if os.path.exists(path):
65
+ try:
66
+ return ImageFont.truetype(path, size)
67
+ except:
68
+ continue
69
+ return ImageFont.load_default()
70
+
71
+ def parse_color(self, s: str, default: tuple) -> tuple:
72
+ try:
73
+ return tuple(int(x) for x in s.split(','))
74
+ except:
75
+ return default
76
+
77
+ @abstractmethod
78
+ def make_frame(self, t: float, req: RenderRequest, product_img, logo_img) -> Image.Image:
79
+ """
80
+ ارسم frame واحد
81
+ t = الوقت الحالي بالثواني
82
+ يرجع PIL Image RGB
83
+ """
84
+ pass
85
+
86
+ @property
87
+ def info(self) -> dict:
88
+ return {
89
+ "name": self.NAME,
90
+ "description": self.DESCRIPTION,
91
+ "author": self.AUTHOR,
92
+ }
main.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Video Showcase API
3
+ FastAPI — بتبعتله بيانات المنتج، بيرجعلك فيديو
4
+ """
5
+
6
+ import os
7
+ import uuid
8
+ from fastapi import FastAPI, HTTPException, BackgroundTasks
9
+ from fastapi.responses import FileResponse, JSONResponse
10
+ from pydantic import BaseModel
11
+ from typing import Optional
12
+
13
+ from templates import get_template, list_templates
14
+ from templates.base import RenderRequest
15
+ from renderer import render_video
16
+
17
+
18
+ app = FastAPI(
19
+ title="Video Showcase API",
20
+ description="بيعمل فيديو Product Showcase تلقائي",
21
+ version="1.0.0"
22
+ )
23
+
24
+ OUTPUT_DIR = "/tmp/videos"
25
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
26
+
27
+
28
+ # ============================================================
29
+ # Models
30
+ # ============================================================
31
+ class VideoRequest(BaseModel):
32
+ # --- مطلوب ---
33
+ template: str = "showcase_arabic"
34
+ title: str
35
+
36
+ # --- اختياري ---
37
+ discount: Optional[str] = ""
38
+ badge: Optional[str] = ""
39
+ phone: Optional[str] = ""
40
+ website: Optional[str] = ""
41
+ image_path: Optional[str] = ""
42
+ music_path: Optional[str] = ""
43
+ bg_left: Optional[str] = ""
44
+ bg_right: Optional[str] = ""
45
+ duration: Optional[int] = 6
46
+ fps: Optional[int] = 30
47
+ width: Optional[int] = 1280
48
+ height: Optional[int] = 720
49
+ music_volume: Optional[float] = 0.20
50
+
51
+
52
+ # ============================================================
53
+ # Routes
54
+ # ============================================================
55
+
56
+ @app.get("/")
57
+ def root():
58
+ return {
59
+ "status": "running",
60
+ "message": "Video Showcase API شغال ✅",
61
+ "endpoints": {
62
+ "GET /templates": "شوف التمبلتس المتاحة",
63
+ "POST /render": "اعمل فيديو",
64
+ "GET /video/{video_id}": "حمّل الفيديو",
65
+ "GET /health": "Health check"
66
+ }
67
+ }
68
+
69
+
70
+ @app.get("/health")
71
+ def health():
72
+ return {"status": "ok"}
73
+
74
+
75
+ @app.get("/templates")
76
+ def get_templates():
77
+ """شوف كل التمبلتس المتاحة"""
78
+ return {
79
+ "templates": list_templates(),
80
+ "count": len(list_templates())
81
+ }
82
+
83
+
84
+ @app.post("/render")
85
+ def render(req: VideoRequest):
86
+ """
87
+ اعمل فيديو
88
+
89
+ مثال من n8n:
90
+ POST /render
91
+ {
92
+ "template": "showcase_arabic",
93
+ "title": "احدث اجهزة\\nكهربائية",
94
+ "discount": "خصم 20٪",
95
+ "image_path": "/tmp/product.png",
96
+ "music_path": "/tmp/music.mp3"
97
+ }
98
+ """
99
+ # تحقق من التمبلت
100
+ try:
101
+ template = get_template(req.template)
102
+ except ValueError as e:
103
+ raise HTTPException(status_code=404, detail=str(e))
104
+
105
+ # ID فريد للفيديو
106
+ video_id = str(uuid.uuid4())[:8]
107
+ output = os.path.join(OUTPUT_DIR, f"{video_id}.mp4")
108
+
109
+ # عمل الـ RenderRequest
110
+ render_req = RenderRequest(
111
+ title = req.title,
112
+ discount = req.discount or "",
113
+ badge = req.badge or "",
114
+ phone = req.phone or "",
115
+ website = req.website or "",
116
+ image_path = req.image_path or "",
117
+ music_path = req.music_path or "",
118
+ output_path = output,
119
+ bg_left = req.bg_left or "",
120
+ bg_right = req.bg_right or "",
121
+ duration = req.duration or 6,
122
+ fps = req.fps or 30,
123
+ width = req.width or 1280,
124
+ height = req.height or 720,
125
+ music_volume = req.music_volume or 0.20,
126
+ )
127
+
128
+ # Render
129
+ try:
130
+ final_path = render_video(template, render_req)
131
+ except Exception as e:
132
+ raise HTTPException(status_code=500, detail=f"خطأ في الـ render: {str(e)}")
133
+
134
+ size_mb = os.path.getsize(final_path) / 1024 / 1024
135
+
136
+ return {
137
+ "status": "success",
138
+ "video_id": video_id,
139
+ "download": f"/video/{video_id}",
140
+ "size_mb": round(size_mb, 2),
141
+ "template": req.template,
142
+ }
143
+
144
+
145
+ @app.get("/video/{video_id}")
146
+ def download_video(video_id: str):
147
+ """حمّل الفيديو"""
148
+ path = os.path.join(OUTPUT_DIR, f"{video_id}.mp4")
149
+ if not os.path.exists(path):
150
+ raise HTTPException(status_code=404, detail="الفيديو مش موجود أو اتمسح")
151
+ return FileResponse(
152
+ path,
153
+ media_type="video/mp4",
154
+ filename=f"showcase_{video_id}.mp4"
155
+ )
renderer.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Video Renderer
3
+ المحرك المشترك بين كل التمبلتس
4
+ بياخد التمبلت + البيانات → بيعمل الفيديو
5
+ """
6
+
7
+ import os
8
+ import subprocess
9
+ import tempfile
10
+ import shutil
11
+ from PIL import Image
12
+ from templates.base import RenderRequest
13
+
14
+
15
+ def download_font():
16
+ if not os.path.exists('/tmp/arabic.ttf'):
17
+ print('📥 تحميل الخط العربي...')
18
+ result = subprocess.run([
19
+ 'wget', '-q', '-O', '/tmp/arabic.ttf',
20
+ 'https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoNaskhArabic/NotoNaskhArabic-Bold.ttf'
21
+ ], capture_output=True)
22
+ if result.returncode == 0:
23
+ print('✅ الخط جاهز')
24
+
25
+
26
+ def render_video(template, req: RenderRequest) -> str:
27
+ """
28
+ بياخد التمبلت + الـ request
29
+ بيرجع مسار الفيديو النهائي
30
+ """
31
+ download_font()
32
+
33
+ W, H = req.width, req.height
34
+ fps = req.fps
35
+ total = req.duration * fps
36
+ tmp = tempfile.mkdtemp()
37
+
38
+ # تحميل الصور
39
+ product_img = None
40
+ if req.image_path and os.path.exists(req.image_path):
41
+ product_img = Image.open(req.image_path).convert('RGBA')
42
+
43
+ logo_img = None
44
+
45
+ # رسم الـ frames
46
+ print(f'🎬 [{template.NAME}] بيرسم {total} frame...')
47
+ for i in range(total):
48
+ t = i / fps
49
+ frame = template.make_frame(t, req, product_img, logo_img)
50
+ frame.save(os.path.join(tmp, f'frame_{i:05d}.png'))
51
+ if i % fps == 0:
52
+ print(f' {i//fps}/{req.duration} ثانية')
53
+
54
+ # FFmpeg — بيعمل الفيديو
55
+ video_raw = os.path.join(tmp, 'raw.mp4')
56
+ print('🎞️ FFmpeg...')
57
+ subprocess.run([
58
+ 'ffmpeg', '-y',
59
+ '-framerate', str(fps),
60
+ '-i', os.path.join(tmp, 'frame_%05d.png'),
61
+ '-c:v', 'libx264',
62
+ '-pix_fmt', 'yuv420p',
63
+ '-crf', '18',
64
+ '-movflags', '+faststart',
65
+ video_raw
66
+ ], capture_output=True)
67
+
68
+ # إضافة الموسيقى
69
+ final = req.output_path
70
+ if req.music_path and os.path.exists(req.music_path):
71
+ print('🎵 بيضيف الموسيقى...')
72
+ result = subprocess.run([
73
+ 'ffmpeg', '-y',
74
+ '-i', video_raw,
75
+ '-i', req.music_path,
76
+ '-filter_complex',
77
+ f'[1:a]volume={req.music_volume},'
78
+ f'afade=t=in:st=0:d=1,'
79
+ f'afade=t=out:st=4:d=2[m];[m]apad[audio]',
80
+ '-map', '0:v',
81
+ '-map', '[audio]',
82
+ '-shortest',
83
+ '-c:v', 'copy',
84
+ '-c:a', 'aac',
85
+ '-b:a', '192k',
86
+ final
87
+ ], capture_output=True, text=True)
88
+
89
+ if result.returncode != 0:
90
+ shutil.copy(video_raw, final)
91
+ else:
92
+ shutil.copy(video_raw, final)
93
+
94
+ shutil.rmtree(tmp)
95
+ return final
showcase_arabic.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Template: showcase_arabic
3
+ تمبلت عرض المنتجات بالعربي
4
+ خلفية بنفسجية + نصوص عربية + badge ذهبي
5
+ """
6
+
7
+ from PIL import Image, ImageDraw
8
+ from .base import BaseTemplate, RenderRequest
9
+
10
+
11
+ class ShowcaseArabic(BaseTemplate):
12
+
13
+ NAME = "showcase_arabic"
14
+ DESCRIPTION = "تمبلت عرض منتج احترافي — خلفية بنفسجية وبادج ذهبي"
15
+ AUTHOR = "your_name"
16
+
17
+ # ألوان افتراضية
18
+ DEFAULT_BG_LEFT = (98, 70, 180)
19
+ DEFAULT_BG_RIGHT = (15, 15, 45)
20
+
21
+ # ============================================================
22
+ def _create_bg(self, w, h, cl, cr):
23
+ img = Image.new('RGBA', (w, h))
24
+ d = ImageDraw.Draw(img)
25
+ for x in range(w):
26
+ if x < w * 0.6:
27
+ r2 = x / (w * 0.6)
28
+ c = (
29
+ int(cl[0] * (1 - r2 * 0.3)),
30
+ int(cl[1] * (1 - r2 * 0.3)),
31
+ int(cl[2] * (1 - r2 * 0.1)),
32
+ 255
33
+ )
34
+ else:
35
+ r2 = (x - w * 0.6) / (w * 0.4)
36
+ c = (
37
+ int(cl[0] * 0.7 * (1-r2) + cr[0] * r2),
38
+ int(cl[1] * 0.7 * (1-r2) + cr[1] * r2),
39
+ int(cl[2] * 0.9 * (1-r2) + cr[2] * r2),
40
+ 255
41
+ )
42
+ d.line([(x, 0), (x, h)], fill=c)
43
+ return img
44
+
45
+ def _draw_badge(self, draw, cx, cy, r, lines, font):
46
+ if r < 5:
47
+ return
48
+ draw.ellipse([cx-r-8, cy-r-8, cx+r+8, cy+r+8], fill=(160, 130, 10))
49
+ draw.ellipse([cx-r, cy-r, cx+r, cy+r ], fill=(200, 160, 20))
50
+ total_h = sum(font.getbbox(l)[3] for l in lines) + (len(lines)-1) * 4
51
+ y = cy - total_h // 2
52
+ for line in lines:
53
+ b = font.getbbox(line)
54
+ draw.text((cx - (b[2]-b[0])//2, y), line, font=font, fill=(255, 255, 255))
55
+ y += b[3] + 4
56
+
57
+ # ============================================================
58
+ def make_frame(self, t, req: RenderRequest, product_img, logo_img):
59
+ W, H, D = req.width, req.height, req.duration
60
+
61
+ cl = self.parse_color(req.bg_left, self.DEFAULT_BG_LEFT)
62
+ cr = self.parse_color(req.bg_right, self.DEFAULT_BG_RIGHT)
63
+
64
+ # خلفية
65
+ frame = self._create_bg(W, H, cl, cr)
66
+
67
+ # زخرفة دوائر
68
+ ov = Image.new('RGBA', (W, H), (0, 0, 0, 0))
69
+ od = ImageDraw.Draw(ov)
70
+ od.ellipse([W*.45, -H*.3, W*1.2, H*1.3], fill=(30, 20, 70, 80))
71
+ od.ellipse([W*.55, H*.3, W*1.1, H*1.1], fill=(20, 15, 50, 60))
72
+ frame = Image.alpha_composite(frame, ov)
73
+
74
+ ft = self.load_font(72)
75
+ fd = self.load_font(48)
76
+ fc = self.load_font(30)
77
+
78
+ # --- صورة المنتج ---
79
+ ip = min(1.0, t / (D * 0.4))
80
+ io = int((1 - self.ease_out(ip)) * -W * 0.5)
81
+ if product_img:
82
+ pw = int(W * 0.42)
83
+ ph = int(product_img.height * pw / product_img.width)
84
+ rs = product_img.resize((pw, ph), Image.LANCZOS)
85
+ px = int(W * 0.02) + io
86
+ py = (H - ph) // 2
87
+ frame.paste(rs, (px, py), rs if rs.mode == 'RGBA' else None)
88
+
89
+ tl = Image.new('RGBA', (W, H), (0, 0, 0, 0))
90
+ td = ImageDraw.Draw(tl)
91
+
92
+ # --- نصوص ---
93
+ tp = min(1.0, max(0.0, (t - D*.2) / (D*.4)))
94
+ to = int((1 - self.ease_out(tp)) * -H * 0.3)
95
+ ta = int(self.ease_out(tp) * 255)
96
+
97
+ RX = int(W * 0.95)
98
+ ty = int(H * 0.15) + to
99
+ for i, line in enumerate(req.title.replace('\\n', '\n').split('\n')):
100
+ b = ft.getbbox(line)
101
+ tw = b[2] - b[0]
102
+ td.text((RX - tw, ty + i*90), line, font=ft, fill=(255, 255, 255, ta))
103
+
104
+ if req.discount:
105
+ b = fd.getbbox(req.discount)
106
+ dw = b[2] - b[0]
107
+ td.text((RX - dw, int(H*.55) + to), req.discount,
108
+ font=fd, fill=(220, 160, 40, ta))
109
+
110
+ # --- معلومات اتصال ---
111
+ cp = min(1.0, max(0.0, (t - D*.5) / (D*.3)))
112
+ ca = int(self.ease_out(cp) * 255)
113
+ cy2 = int(H * 0.68)
114
+ for i, txt in enumerate([req.phone, req.website]):
115
+ if not txt:
116
+ continue
117
+ b = fc.getbbox(txt)
118
+ tw = b[2] - b[0]
119
+ td.text((RX - tw, cy2 + i*42), txt,
120
+ font=fc, fill=(200-i*20, 200-i*20, 200-i*20, ca))
121
+
122
+ frame = Image.alpha_composite(frame, tl)
123
+
124
+ # --- badge ---
125
+ if req.badge:
126
+ bp = min(1.0, max(0.0, (t - D*.1) / (D*.3)))
127
+ bs = self.ease_out(bp)
128
+ if bs > 0.05:
129
+ bl = Image.new('RGBA', (W, H), (0, 0, 0, 0))
130
+ bd = ImageDraw.Draw(bl)
131
+ self._draw_badge(bd,
132
+ int(W*.08), int(H*.18),
133
+ int(70 * bs),
134
+ req.badge.replace('\\n', '\n').split('\n'),
135
+ self.load_font(int(32 * bs)))
136
+ frame = Image.alpha_composite(frame, bl)
137
+
138
+ return frame.convert('RGB')