fix(media): هیچ آپلودی هرگز متحرک ذخیره نمیشود — منبع چندفریمی (gif/ویدیو) همیشه به پوستر فریم اول میرسد؛ انیمیشن در کارتها سوسو میزد
This commit is contained in:
+24
-71
@@ -336,15 +336,11 @@ register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
|
|||||||
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
|
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
|
||||||
|
|
||||||
|
|
||||||
# آپلود رسانه به Object Storage (MinIO) — هر ورودی قابلرمزگشایی به WebP نرمال میشود:
|
# رسانه آپلودی به Object Storage (MinIO) — هر ورودی قابلرمزگشایی به WebP «تکفریمی» نرمال میشود:
|
||||||
# تصویر ثابت → WebP تکفریمی | gif/apng/webp متحرک → WebP متحرک | ویدیو → WebP متحرک با سقف.
|
# هیچ آپلودی هرگز متحرک ذخیره نمیشود — منبع متحرک (gif/apng/webp متحرک/ویدیو) از فریم اول
|
||||||
|
# پوستر میگیرد؛ انیمیشن در کارتها و اسلایدرها سوسو میزند و مطلوب نیست.
|
||||||
# URL نسبی برمیگردد تا بکاند ذخیرهسازی قابل تعویض باشد.
|
# URL نسبی برمیگردد تا بکاند ذخیرهسازی قابل تعویض باشد.
|
||||||
MAX_DIMENSION = 2000 # سقف ضلع تصویر ثابت — ابعاد بزرگ اصلیترین عامل کندی سایت است
|
MAX_DIMENSION = 2000 # سقف ضلع تصویر — ابعاد بزرگ اصلیترین عامل کندی سایت است
|
||||||
MAX_ANIM_DIMENSION = 800 # انیمیشن: سقف ضلع فریم
|
|
||||||
MAX_ANIM_FRAMES = 150 # انیمیشن: سقف فریم
|
|
||||||
VIDEO_MAX_SECONDS = 8 # ویدیو: سقف مدت ورودی
|
|
||||||
VIDEO_FPS = 12 # ویدیو: نرخ فریم خروجی
|
|
||||||
VIDEO_WEBP_LIMIT = 5 * 1024 * 1024 # انیمیشن ویدیویی حجیمتر از این → فقط فریم پوستر
|
|
||||||
WEBP_QUALITY = 82
|
WEBP_QUALITY = 82
|
||||||
WEBP_CONTENT_TYPE = "image/webp"
|
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]:
|
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)
|
img = ImageOps.exif_transpose(img)
|
||||||
if max(img.size) > MAX_DIMENSION:
|
if max(img.size) > MAX_DIMENSION:
|
||||||
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
|
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
|
return buf.getvalue(), *img.size
|
||||||
|
|
||||||
|
|
||||||
def _webp_animated(img: Image.Image) -> tuple[bytes, int, int] | None:
|
def _ffprobe_video(path: str) -> dict | None:
|
||||||
"""چندفریم (gif/apng/webp متحرک) → WebP متحرک؛ None یعنی ورودی تکفریمی است.
|
"""استریم ویدیوی اول با ffprobe — 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 یعنی ویدیویی در فایل نیست."""
|
|
||||||
ffprobe = shutil.which("ffprobe")
|
ffprobe = shutil.which("ffprobe")
|
||||||
if ffprobe is None:
|
if ffprobe is None:
|
||||||
return None, {}
|
return None
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
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,
|
capture_output=True, timeout=20,
|
||||||
)
|
)
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
return None, {}
|
return None
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
return None, {}
|
return None
|
||||||
try:
|
try:
|
||||||
info = json.loads(proc.stdout or "{}")
|
streams = json.loads(proc.stdout or "{}").get("streams") or []
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return None, {}
|
return None
|
||||||
video = next((s for s in info.get("streams") or [] if s.get("codec_type") == "video"), None)
|
return next((s for s in streams if s.get("codec_type") == "video"), None)
|
||||||
return video, info.get("format") or {}
|
|
||||||
|
|
||||||
|
|
||||||
def _run_ffmpeg(args: list[str]) -> 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]:
|
def _ffmpeg_webp(data: bytes) -> tuple[bytes, int | None, int | None]:
|
||||||
"""مسیر FFmpeg — ورودیهایی که Pillow نمیخواند: ویدیو و فرمتهای تصویری خاص.
|
"""مسیر FFmpeg — ورودیهایی که Pillow نمیخواند: ویدیو و فرمتهای تصویری خاص.
|
||||||
ویدیو تا ۸ ثانیه با ۱۲fps به WebP متحرک میرسد؛ خروجی حجیم → فقط فریم پوستر."""
|
خروجی همیشه تکفریم (پوستر) است — ویدیوی متحرک در صفحه لوپ میشود و سوسو میزند."""
|
||||||
if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
|
if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
|
||||||
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
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")
|
dst = os.path.join(tmp, "out.webp")
|
||||||
with open(src, "wb") as fh:
|
with open(src, "wb") as fh:
|
||||||
fh.write(data)
|
fh.write(data)
|
||||||
video, fmt = _ffprobe_video(src)
|
if _ffprobe_video(src) is None:
|
||||||
if video is None:
|
|
||||||
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
||||||
ff = shutil.which("ffmpeg") or "ffmpeg"
|
ff = shutil.which("ffmpeg") or "ffmpeg"
|
||||||
animated = float(fmt.get("duration") or 0) >= 0.5
|
_run_ffmpeg([ff, "-y", "-v", "error", "-i", src, "-frames:v", "1",
|
||||||
video_vf = f"fps={VIDEO_FPS},scale='min({MAX_ANIM_DIMENSION},iw)':-2:flags=lanczos"
|
"-vf", f"scale='min({MAX_DIMENSION},iw)':-2:flags=lanczos",
|
||||||
still_vf = f"scale='min({MAX_DIMENSION},iw)':-2:flags=lanczos"
|
"-c:v", "libwebp", "-q:v", str(WEBP_QUALITY), "-map_metadata", "-1", dst])
|
||||||
|
|
||||||
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)
|
|
||||||
with open(dst, "rb") as fh:
|
with open(dst, "rb") as fh:
|
||||||
out = fh.read()
|
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
|
# ffprobe روی WebP عرض/ارتفاع صفر برمیگرداند — ابعاد از خود خروجی با Pillow
|
||||||
try:
|
try:
|
||||||
width, height = Image.open(io.BytesIO(out)).size
|
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]:
|
def _normalize_media(data: bytes) -> tuple[bytes, int | None, int | None]:
|
||||||
"""هر ورودی قابلرمزگشایی → WebP. اول Pillow (دقیقترین برای EXIF/شفافیت/انیمیشن)؛
|
"""هر ورودی قابلرمزگشایی → WebP تکفریمی. اول Pillow (دقیقترین برای EXIF/شفافیت)؛
|
||||||
اگر رمزگشایی نشد، FFmpeg (ویدیو و فرمتهای خاص). هیچکدام نشد → ۴۱۵."""
|
اگر رمزگشایی نشد، FFmpeg (پوستر ویدیو و فرمتهای خاص). هیچکدام نشد → ۴۱۵."""
|
||||||
try:
|
try:
|
||||||
img = Image.open(io.BytesIO(data))
|
return _webp_still(Image.open(io.BytesIO(data)))
|
||||||
animated = _webp_animated(img)
|
|
||||||
if animated is not None:
|
|
||||||
return animated
|
|
||||||
return _webp_still(img)
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ def test_upload_jpeg_converted_to_webp(auth_client, monkeypatch):
|
|||||||
assert body["width"] == 2000 and body["height"] == 1000
|
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)
|
fake = _mock_storage(monkeypatch)
|
||||||
res = auth_client.post(
|
res = auth_client.post(
|
||||||
"/api/admin/upload",
|
"/api/admin/upload",
|
||||||
@@ -455,7 +455,8 @@ def test_upload_animated_gif_becomes_animated_webp(auth_client, monkeypatch):
|
|||||||
stored = next(iter(fake.objects.values()))
|
stored = next(iter(fake.objects.values()))
|
||||||
out = Image.open(io.BytesIO(stored))
|
out = Image.open(io.BytesIO(stored))
|
||||||
assert out.format == "WEBP"
|
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):
|
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
|
assert res.status_code == 415
|
||||||
|
|
||||||
|
|
||||||
def test_upload_video_becomes_animated_webp(auth_client, monkeypatch):
|
def test_upload_video_becomes_static_webp_poster(auth_client, monkeypatch):
|
||||||
# ویدیو تنها با FFmpeg قابل پذیرش است — بدون آن ۴۱۵، با آن WebP متحرک
|
# ویدیو تنها با FFmpeg قابل پذیرش است — بدون آن ۴۱۵، با آن پوستر تکفریمی WebP
|
||||||
import shutil as _shutil
|
import shutil as _shutil
|
||||||
import subprocess as _subprocess
|
import subprocess as _subprocess
|
||||||
import tempfile as _tempfile
|
import tempfile as _tempfile
|
||||||
import os as _os
|
import os as _os
|
||||||
|
|
||||||
_mock_storage(monkeypatch)
|
fake = _mock_storage(monkeypatch)
|
||||||
if _shutil.which("ffmpeg") is None:
|
if _shutil.which("ffmpeg") is None:
|
||||||
res = auth_client.post(
|
res = auth_client.post(
|
||||||
"/api/admin/upload",
|
"/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.status_code == 200, res.text
|
||||||
assert res.json()["content_type"] == "image/webp"
|
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):
|
def test_upload_without_name_uses_filename(auth_client, monkeypatch):
|
||||||
|
|||||||
Reference in New Issue
Block a user