fix(media): هیچ آپلودی هرگز متحرک ذخیره نمی‌شود — منبع چندفریمی (gif/ویدیو) همیشه به پوستر فریم اول می‌رسد؛ انیمیشن در کارت‌ها سوسو می‌زد

This commit is contained in:
aramesh-site deploy
2026-10-01 11:48:08 +00:00
parent 68863eb44e
commit 6f1f42ba7a
2 changed files with 37 additions and 76 deletions
+23 -70
View File
@@ -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,28 +410,12 @@ 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,
_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])
# ویدیو → متحرک با سقف فریم‌نرخ/ضلع/مدت؛ تصویر خاص → تک‌فریم
if animated:
run_convert(["-t", str(VIDEO_MAX_SECONDS)], video_vf)
else:
run_convert(["-frames:v", "1"], still_vf)
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
@@ -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:
+13 -5
View File
@@ -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):