diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 34870e3..a563592 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -336,15 +336,11 @@ register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch) register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch) -# آپلود رسانه به Object Storage (MinIO) — هر ورودی قابل‌رمزگشایی به WebP نرمال می‌شود: -# تصویر ثابت → WebP تک‌فریمی | gif/apng/webp متحرک → WebP متحرک | ویدیو → WebP متحرک با سقف. +# رسانه آپلودی به Object Storage (MinIO) — هر ورودی قابل‌رمزگشایی به WebP «تک‌فریمی» نرمال می‌شود: +# هیچ آپلودی هرگز متحرک ذخیره نمی‌شود — منبع متحرک (gif/apng/webp متحرک/ویدیو) از فریم اول +# پوستر می‌گیرد؛ انیمیشن در کارت‌ها و اسلایدرها سوسو می‌زند و مطلوب نیست. # URL نسبی برمی‌گردد تا بک‌اند ذخیره‌سازی قابل تعویض باشد. -MAX_DIMENSION = 2000 # سقف ضلع تصویر ثابت — ابعاد بزرگ اصلی‌ترین عامل کندی سایت است -MAX_ANIM_DIMENSION = 800 # انیمیشن: سقف ضلع فریم -MAX_ANIM_FRAMES = 150 # انیمیشن: سقف فریم -VIDEO_MAX_SECONDS = 8 # ویدیو: سقف مدت ورودی -VIDEO_FPS = 12 # ویدیو: نرخ فریم خروجی -VIDEO_WEBP_LIMIT = 5 * 1024 * 1024 # انیمیشن ویدیویی حجیم‌تر از این → فقط فریم پوستر +MAX_DIMENSION = 2000 # سقف ضلع تصویر — ابعاد بزرگ اصلی‌ترین عامل کندی سایت است WEBP_QUALITY = 82 WEBP_CONTENT_TYPE = "image/webp" @@ -360,7 +356,9 @@ def _to_webp_mode(img: Image.Image) -> Image.Image: def _webp_still(img: Image.Image) -> tuple[bytes, int, int]: - """تک‌فریم → WebP؛ چرخش EXIF اعمال، متادیتا حذف و سقف ضلع ۲۰۰۰ پیکسل رعایت می‌شود.""" + """فریم اول → WebP تک‌فریمی؛ چرخش EXIF اعمال، متادیتا حذف و سقف ضلع ۲۰۰۰ پیکسل رعایت می‌شود.""" + if getattr(img, "n_frames", 1) > 1: + img.seek(0) img = ImageOps.exif_transpose(img) if max(img.size) > MAX_DIMENSION: img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS) @@ -370,50 +368,25 @@ def _webp_still(img: Image.Image) -> tuple[bytes, int, int]: return buf.getvalue(), *img.size -def _webp_animated(img: Image.Image) -> tuple[bytes, int, int] | None: - """چندفریم (gif/apng/webp متحرک) → WebP متحرک؛ None یعنی ورودی تک‌فریمی است. - فریم‌های بیش از سقف رها می‌شوند تا حافظه و حجم خروجی مهار شود.""" - total = getattr(img, "n_frames", 1) - if total <= 1: - return None - count = min(total, MAX_ANIM_FRAMES) - frames: list[Image.Image] = [] - durations: list[int] = [] - for i in range(count): - img.seek(i) - durations.append(int(img.info.get("duration") or 100)) - frame = img.convert("RGBA") - if max(frame.size) > MAX_ANIM_DIMENSION: - frame.thumbnail((MAX_ANIM_DIMENSION, MAX_ANIM_DIMENSION), Image.LANCZOS) - frames.append(frame) - buf = io.BytesIO() - frames[0].save( - buf, "WEBP", save_all=True, append_images=frames[1:], - duration=durations, loop=0, quality=WEBP_QUALITY, method=4, - ) - return buf.getvalue(), *frames[0].size - - -def _ffprobe_video(path: str) -> tuple[dict | None, dict]: - """استریم ویدیوی اول + مشخصات فرمت با ffprobe — استریم None یعنی ویدیویی در فایل نیست.""" +def _ffprobe_video(path: str) -> dict | None: + """استریم ویدیوی اول با ffprobe — None یعنی ویدیویی در فایل نیست (رسانهٔ تصویری نیست).""" ffprobe = shutil.which("ffprobe") if ffprobe is None: - return None, {} + return None try: proc = subprocess.run( - [ffprobe, "-v", "error", "-print_format", "json", "-show_streams", "-show_format", path], + [ffprobe, "-v", "error", "-print_format", "json", "-show_streams", path], capture_output=True, timeout=20, ) except (subprocess.TimeoutExpired, OSError): - return None, {} + return None if proc.returncode != 0: - return None, {} + return None try: - info = json.loads(proc.stdout or "{}") + streams = json.loads(proc.stdout or "{}").get("streams") or [] except json.JSONDecodeError: - return None, {} - video = next((s for s in info.get("streams") or [] if s.get("codec_type") == "video"), None) - return video, info.get("format") or {} + return None + return next((s for s in streams if s.get("codec_type") == "video"), None) def _run_ffmpeg(args: list[str]) -> None: @@ -429,7 +402,7 @@ def _run_ffmpeg(args: list[str]) -> None: def _ffmpeg_webp(data: bytes) -> tuple[bytes, int | None, int | None]: """مسیر FFmpeg — ورودی‌هایی که Pillow نمی‌خواند: ویدیو و فرمت‌های تصویری خاص. - ویدیو تا ۸ ثانیه با ۱۲fps به WebP متحرک می‌رسد؛ خروجی حجیم → فقط فریم پوستر.""" + خروجی همیشه تک‌فریم (پوستر) است — ویدیوی متحرک در صفحه لوپ می‌شود و سوسو می‌زند.""" if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None: raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL) with tempfile.TemporaryDirectory() as tmp: @@ -437,30 +410,14 @@ def _ffmpeg_webp(data: bytes) -> tuple[bytes, int | None, int | None]: dst = os.path.join(tmp, "out.webp") with open(src, "wb") as fh: fh.write(data) - video, fmt = _ffprobe_video(src) - if video is None: + if _ffprobe_video(src) is None: raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL) ff = shutil.which("ffmpeg") or "ffmpeg" - animated = float(fmt.get("duration") or 0) >= 0.5 - video_vf = f"fps={VIDEO_FPS},scale='min({MAX_ANIM_DIMENSION},iw)':-2:flags=lanczos" - still_vf = f"scale='min({MAX_DIMENSION},iw)':-2:flags=lanczos" - - def run_convert(extra: list[str], vf: str) -> None: - _run_ffmpeg([ff, "-y", "-v", "error", "-i", src, *extra, "-vf", vf, - "-c:v", "libwebp", "-q:v", str(WEBP_QUALITY), "-map_metadata", "-1", dst]) - - # ویدیو → متحرک با سقف فریم‌نرخ/ضلع/مدت؛ تصویر خاص → تک‌فریم - if animated: - run_convert(["-t", str(VIDEO_MAX_SECONDS)], video_vf) - else: - run_convert(["-frames:v", "1"], still_vf) + _run_ffmpeg([ff, "-y", "-v", "error", "-i", src, "-frames:v", "1", + "-vf", f"scale='min({MAX_DIMENSION},iw)':-2:flags=lanczos", + "-c:v", "libwebp", "-q:v", str(WEBP_QUALITY), "-map_metadata", "-1", dst]) with open(dst, "rb") as fh: out = fh.read() - # انیمیشن ویدیویی ناگذیر حجیم → فقط فریم پوستر - if animated and len(out) > VIDEO_WEBP_LIMIT: - run_convert(["-frames:v", "1"], still_vf) - with open(dst, "rb") as fh: - out = fh.read() # ffprobe روی WebP عرض/ارتفاع صفر برمی‌گرداند — ابعاد از خود خروجی با Pillow try: width, height = Image.open(io.BytesIO(out)).size @@ -470,14 +427,10 @@ def _ffmpeg_webp(data: bytes) -> tuple[bytes, int | None, int | None]: def _normalize_media(data: bytes) -> tuple[bytes, int | None, int | None]: - """هر ورودی قابل‌رمزگشایی → WebP. اول Pillow (دقیق‌ترین برای EXIF/شفافیت/انیمیشن)؛ - اگر رمزگشایی نشد، FFmpeg (ویدیو و فرمت‌های خاص). هیچ‌کدام نشد → ۴۱۵.""" + """هر ورودی قابل‌رمزگشایی → WebP تک‌فریمی. اول Pillow (دقیق‌ترین برای EXIF/شفافیت)؛ + اگر رمزگشایی نشد، FFmpeg (پوستر ویدیو و فرمت‌های خاص). هیچ‌کدام نشد → ۴۱۵.""" try: - img = Image.open(io.BytesIO(data)) - animated = _webp_animated(img) - if animated is not None: - return animated - return _webp_still(img) + return _webp_still(Image.open(io.BytesIO(data))) except HTTPException: raise except Exception: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index e218040..d9b5f12 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -441,7 +441,7 @@ def test_upload_jpeg_converted_to_webp(auth_client, monkeypatch): assert body["width"] == 2000 and body["height"] == 1000 -def test_upload_animated_gif_becomes_animated_webp(auth_client, monkeypatch): +def test_upload_animated_gif_becomes_static_webp(auth_client, monkeypatch): fake = _mock_storage(monkeypatch) res = auth_client.post( "/api/admin/upload", @@ -455,7 +455,8 @@ def test_upload_animated_gif_becomes_animated_webp(auth_client, monkeypatch): stored = next(iter(fake.objects.values())) out = Image.open(io.BytesIO(stored)) assert out.format == "WEBP" - assert getattr(out, "n_frames", 1) == 2 # حرکت از بین نرفته + # هیچ آپلودی هرگز متحرک ذخیره نمی‌شود — پوستر فریم اول + assert getattr(out, "n_frames", 1) == 1 def test_upload_undecodable_file_rejected(auth_client, monkeypatch): @@ -467,14 +468,14 @@ def test_upload_undecodable_file_rejected(auth_client, monkeypatch): assert res.status_code == 415 -def test_upload_video_becomes_animated_webp(auth_client, monkeypatch): - # ویدیو تنها با FFmpeg قابل پذیرش است — بدون آن ۴۱۵، با آن WebP متحرک +def test_upload_video_becomes_static_webp_poster(auth_client, monkeypatch): + # ویدیو تنها با FFmpeg قابل پذیرش است — بدون آن ۴۱۵، با آن پوستر تک‌فریمی WebP import shutil as _shutil import subprocess as _subprocess import tempfile as _tempfile import os as _os - _mock_storage(monkeypatch) + fake = _mock_storage(monkeypatch) if _shutil.which("ffmpeg") is None: res = auth_client.post( "/api/admin/upload", @@ -499,6 +500,13 @@ def test_upload_video_becomes_animated_webp(auth_client, monkeypatch): ) assert res.status_code == 200, res.text assert res.json()["content_type"] == "image/webp" + from PIL import Image + import io + + stored = next(iter(fake.objects.values())) + out = Image.open(io.BytesIO(stored)) + # ویدیو هرگز وب متحرک نمی‌شود — در کارت‌ها سوسو نمی‌زند + assert getattr(out, "n_frames", 1) == 1 def test_upload_without_name_uses_filename(auth_client, monkeypatch):