diff --git a/main.ipynb b/main.ipynb index 8ec3546..0396292 100644 --- a/main.ipynb +++ b/main.ipynb @@ -4155,6 +4155,54 @@ ], "display_name": ".venv" }, + { + "cell_type": "code", + "execution_count": null, + "id": "30acea7c", + "metadata": {}, + "outputs": [], + "source": [ + "@app.get(\"/b/{bucket_name}/{file_id}/{new_w}/{quality}/{GaussianBlur_radius}\")\n", + "async def get_file(\n", + " bucket_name: str,\n", + " file_id: str,\n", + " new_w: int,\n", + " quality: int,\n", + " GaussianBlur_radius: int,\n", + "):\n", + " res = await stream_gridfs_file(file_id, bucket_name)\n", + " if res is None:\n", + " raise HTTPException(status_code=404, detail=\"File not found\")\n", + "\n", + " filename, content_type, file_length, iterator = res\n", + "\n", + " # Собираем все байты файла\n", + " data = b\"\".join([chunk async for chunk in iterator()])\n", + "\n", + " # Обрабатываем\n", + " processed = await build_composite(\n", + " data,\n", + " new_w=new_w,\n", + " quality=quality,\n", + " GaussianBlur_radius=GaussianBlur_radius,\n", + " )\n", + "\n", + " headers = {\n", + " \"Content-Disposition\": content_disposition_header(filename, inline=True),\n", + " \"Cache-Control\": \"public, max-age=3600\",\n", + " \"ETag\": f'\"{bucket_name}:{file_id}:{new_w}:{quality}:{GaussianBlur_radius}\"',\n", + " \"Vary\": \"Accept-Encoding\",\n", + " \"Content-Length\": str(len(processed.getvalue())),\n", + " }\n", + "\n", + " return StreamingResponse(\n", + " processed,\n", + " media_type=\"image/webp\",\n", + " headers=headers,\n", + " )" + ], + "display_name": ".venv" + }, { "cell_type": "code", "execution_count": null, @@ -4258,7 +4306,7 @@ " users = [doc async for doc in users_collection.find().sort({'_id':-1})]\n", " return templates.TemplateResponse(\n", " request=request, name=\"profile/admin/users.html\", context={\"user\": user,\n", - " 'users': users \n", + " 'users': users\n", " }\n", " )" ], diff --git a/main.py b/main.py index d1d4273..fe75bdc 100644 --- a/main.py +++ b/main.py @@ -3195,6 +3195,45 @@ async def get_avatar(user_id: str): return StreamingResponse(iterator(), media_type=content_type, headers=headers) +@app.get("/b/{bucket_name}/{file_id}/{new_w}/{quality}/{GaussianBlur_radius}") +async def get_file( + bucket_name: str, + file_id: str, + new_w: int, + quality: int, + GaussianBlur_radius: int, +): + res = await stream_gridfs_file(file_id, bucket_name) + if res is None: + raise HTTPException(status_code=404, detail="File not found") + + filename, content_type, file_length, iterator = res + + # Собираем все байты файла + data = b"".join([chunk async for chunk in iterator()]) + + # Обрабатываем + processed = await build_composite( + data, + new_w=new_w, + quality=quality, + GaussianBlur_radius=GaussianBlur_radius, + ) + + headers = { + "Content-Disposition": content_disposition_header(filename, inline=True), + "Cache-Control": "public, max-age=3600", + "ETag": f'"{bucket_name}:{file_id}:{new_w}:{quality}:{GaussianBlur_radius}"', + "Vary": "Accept-Encoding", + "Content-Length": str(len(processed.getvalue())), + } + + return StreamingResponse( + processed, + media_type="image/webp", + headers=headers, + ) + async def unsubscribe_user( email: str, token: Optional[str] = None @@ -3275,7 +3314,7 @@ async def profile_admin_statistics( users = [doc async for doc in users_collection.find().sort({'_id':-1})] return templates.TemplateResponse( request=request, name="profile/admin/users.html", context={"user": user, - 'users': users + 'users': users } ) diff --git a/utils.py b/utils.py index d3da572..abe44b2 100644 --- a/utils.py +++ b/utils.py @@ -352,81 +352,72 @@ def is_safe_redirect(next_url: str) -> bool: return False -async def build_composite(data, scale: float = 1.5, new_w: int = 500, quality: int = 90, GaussianBlur_radius: int = 80): - src_img = Image.open(BytesIO(data)).convert('RGB') +async def build_composite( + data: bytes, + scale: float = 1.5, + new_w: int = 500, + quality: int = 90, + GaussianBlur_radius: int = 80, +) -> BytesIO: + src_img = Image.open(BytesIO(data)).convert("RGB") # -------------------------------------------------------------- # 2️⃣ Параметры конечного результата # -------------------------------------------------------------- - new_h = int(new_w / scale) # высота для соотношения 3:2 (≈333) - canvas_sz = (new_w, new_h) # размер «фонового» холста + new_h = int(new_w / scale) # высота для соотношения 3:2 (≈333) + canvas_sz = (new_w, new_h) - # -------------------------------------------------------------- - # 3️⃣ Фоновое изображение – cover + blur(3) - # -------------------------------------------------------------- - # - «cover» = заполняем весь холст, обрезая лишнее, - # сохраняем пропорции оригинала. - # - Затем делаем размытие радиусом 3. - # -------------------------------------------------------------- - # Сначала масштабируем так, чтобы одна из сторон полностью покрывала холст src_ratio = src_img.width / src_img.height canvas_ratio = new_w / new_h - if src_ratio > canvas_ratio: # изображение более «широкое» - # масштабируем по высоте, ширина будет больше холста → обрезаем по горизонтали + # -------------------------------------------------------------- + # 3️⃣ Фоновое изображение – cover + blur (optional) + # -------------------------------------------------------------- + if src_ratio > canvas_ratio: scale_h = new_h scale_w = int(scale_h * src_ratio) - else: # изображение более «высокое» или точное соотношение + else: scale_w = new_w scale_h = int(scale_w / src_ratio) - # масштабируем bg_resized = src_img.resize((scale_w, scale_h), Image.LANCZOS) - # обрезаем центр, чтобы получить точно new_w × new_h left = (scale_w - new_w) // 2 top = (scale_h - new_h) // 2 right = left + new_w bottom = top + new_h bg_cropped = bg_resized.crop((left, top, right, bottom)) - # размытие - bg_blurred = bg_cropped.filter(ImageFilter.GaussianBlur(radius=GaussianBlur_radius)) - # -------------------------------------------------------------- # 4️⃣ Переднее изображение – contain + центрировать # -------------------------------------------------------------- - # - «contain» = вписать изображение в границы canvas, - # полностью показывая его, оставляя пустые поля (прозрачные) - # -------------------------------------------------------------- - # Находим масштаб, чтобы изображение полностью поместилось if src_ratio > canvas_ratio: - # ограничиваем ширину fore_w = new_w fore_h = int(fore_w / src_ratio) else: - # ограничиваем высоту fore_h = new_h fore_w = int(fore_h * src_ratio) fore_resized = src_img.resize((fore_w, fore_h), Image.LANCZOS) - # Создаём прозрачный холст того же размера, что и фон - final_img = Image.new('RGB', canvas_sz, (0, 0, 0)) + # -------------------------------------------------------------- + # 5️⃣ Сборка + # -------------------------------------------------------------- + final_img = Image.new("RGB", canvas_sz, ('f', 'f', 'f')) - # Сначала помещаем размытый фон - final_img.paste(bg_blurred, (0, 0)) + if GaussianBlur_radius: + bg_blurred = bg_cropped.filter(ImageFilter.GaussianBlur(radius=GaussianBlur_radius)) + final_img.paste(bg_blurred, (0, 0)) + # else: оставляем чёрный фон (или можно вставить bg_cropped без blur) - # Затем — переднее изображение, центрированное offset_x = (new_w - fore_w) // 2 offset_y = (new_h - fore_h) // 2 final_img.paste(fore_resized, (offset_x, offset_y)) # -------------------------------------------------------------- - # 5️⃣ Возврат / сохранение + # 6️⃣ Возврат # -------------------------------------------------------------- out_bytes = BytesIO() - # final_img.save(out_bytes, format='JPEG', quality=85) final_img.save(out_bytes, format="WEBP", quality=quality, method=6) out_bytes.seek(0) - return out_bytes # JPEG‑байты готового композитного изображения + return out_bytes