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)
|
||||
|
||||
|
||||
# آپلود رسانه به 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:
|
||||
|
||||
Reference in New Issue
Block a user