Merge pull request 'production' (#5) from production into main

Reviewed-on: #5
This commit is contained in:
vladislav 2026-08-31 18:19:47 +00:00
commit 588793f66c
6 changed files with 807 additions and 367 deletions

View File

@ -5,7 +5,7 @@ from datetime import timedelta
ctx = ssl.create_default_context() ctx = ssl.create_default_context()
ctx.load_verify_locations(cafile="certs.pem") ctx.load_verify_locations(cafile="certs.pem")
CHUNK_TTL = 3600
PROD = os.getenv("PROD", "True").lower() != "false" PROD = os.getenv("PROD", "True").lower() != "false"
SOCIALS = {'vk', 'tg', 'max', 'fb', 'inst'} SOCIALS = {'vk', 'tg', 'max', 'fb', 'inst'}
ATOKEN = os.getenv("ATOKEN") ATOKEN = os.getenv("ATOKEN")

View File

@ -72,6 +72,7 @@
"import asyncio\n", "import asyncio\n",
"from fastapi.middleware.cors import CORSMiddleware\n", "from fastapi.middleware.cors import CORSMiddleware\n",
"from fastapi.responses import HTMLResponse, StreamingResponse, RedirectResponse\n", "from fastapi.responses import HTMLResponse, StreamingResponse, RedirectResponse\n",
"from starlette.datastructures import UploadFile as StarletteUploadFile\n",
"from fastapi.staticfiles import StaticFiles\n", "from fastapi.staticfiles import StaticFiles\n",
"import mimetypes\n", "import mimetypes\n",
"from urllib.parse import quote\n", "from urllib.parse import quote\n",
@ -3022,8 +3023,6 @@
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": [
"\n",
"\n",
"@app.get(\"/form/exhibition/{exhibition_id}\", response_class=HTMLResponse)\n", "@app.get(\"/form/exhibition/{exhibition_id}\", response_class=HTMLResponse)\n",
"async def form_exhibition(\n", "async def form_exhibition(\n",
" request: Request,\n", " request: Request,\n",
@ -3054,11 +3053,11 @@
" try:\n", " try:\n",
" avatar_id = await save_to_gridfs(avatar, 'users')\n", " avatar_id = await save_to_gridfs(avatar, 'users')\n",
" await users_collection.update_one({'_id': ObjectId(current_user.get('_id'))}, {'$set': {'img': avatar_id}})\n", " await users_collection.update_one({'_id': ObjectId(current_user.get('_id'))}, {'$set': {'img': avatar_id}})\n",
" current_user.update({'img': avatar_id})\n",
" background_tasks.add_task(logo_user_task, bson_clear(current_user))\n", " background_tasks.add_task(logo_user_task, bson_clear(current_user))\n",
" return {'ok': True}\n", " return {'ok': True}\n",
" except Exception as e:\n", " except Exception as e:\n",
" raise HTTPException(status_code=500, detail=str(e))\n", " raise HTTPException(status_code=500, detail=str(e))\n"
"\n"
], ],
"display_name": ".venv" "display_name": ".venv"
}, },
@ -3308,16 +3307,98 @@
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": [
"@app.post(\"/api/posts/media/chunk\")\n",
"async def upload_chunk(\n",
" chunk: UploadFile = File(...),\n",
" file_id: str = Form(...),\n",
" chunk_index: int = Form(...),\n",
" total_chunks: int = Form(...),\n",
" filename: str = Form(...),\n",
" content_type: str = Form(...),\n",
" current_user: Dict = Security(get_current_user, scopes=[\"create:posts\"])\n",
"):\n",
" try:\n",
" data = await chunk.read()\n",
" b64_data = base64.b64encode(data).decode(\"ascii\")\n",
"\n",
" key = f\"upload:{file_id}\"\n",
"\n",
" # Store meta only once\n",
" meta = await redis_client.hgetall(key)\n",
" if not meta:\n",
" await redis_client.hset(key, mapping={\n",
" \"filename\": filename,\n",
" \"content_type\": content_type,\n",
" \"total_chunks\": str(total_chunks),\n",
" \"user_id\": str(current_user.get(\"id\", \"\")),\n",
" })\n",
" await redis_client.expire(key, CHUNK_TTL)\n",
"\n",
" # Store the chunk\n",
" await redis_client.hset(key, f\"chunk:{chunk_index}\", b64_data)\n",
" await redis_client.expire(key, CHUNK_TTL)\n",
"\n",
" return {\"ok\": True, \"received\": chunk_index}\n",
" except Exception as e:\n",
" raise HTTPException(status_code=500, detail=str(e))\n",
"\n",
"\n",
"@app.post(\"/api/posts/media/finalize\")\n",
"async def finalize_upload(\n",
" payload: dict,\n",
" current_user: Dict = Security(get_current_user, scopes=[\"create:posts\"])\n",
"):\n",
" file_id = payload.get(\"file_id\")\n",
" if not file_id:\n",
" raise HTTPException(status_code=400, detail=\"file_id required\")\n",
"\n",
" key = f\"upload:{file_id}\"\n",
" meta = await redis_client.hgetall(key)\n",
"\n",
" if not meta:\n",
" raise HTTPException(status_code=400, detail=\"Unknown or expired file_id\")\n",
"\n",
" total_chunks = int(meta[\"total_chunks\"])\n",
" filename = meta[\"filename\"]\n",
" content_type = meta[\"content_type\"]\n",
"\n",
" # Collect all chunks\n",
" chunks = []\n",
" for i in range(total_chunks):\n",
" b64 = await redis_client.hget(key, f\"chunk:{i}\")\n",
" if b64 is None:\n",
" raise HTTPException(status_code=400, detail=f\"Missing chunk {i}\")\n",
" chunks.append(base64.b64decode(b64))\n",
"\n",
" full_content = b\"\".join(chunks)\n",
"\n",
" temp_file = StarletteUploadFile(\n",
" file=BytesIO(full_content),\n",
" filename=filename,\n",
" headers={\"content-type\": content_type}\n",
" )\n",
"\n",
" try:\n",
" saved_name = await save_to_gridfs(temp_file, \"posts\")\n",
" return {\"post_medias\": [saved_name]}\n",
" finally:\n",
" # Cleanup\n",
" await redis_client.delete(key)\n",
"\n",
"\n",
"# Optional fallback (non-chunked)\n",
"@app.post(\"/api/posts/media\", response_model=dict)\n", "@app.post(\"/api/posts/media\", response_model=dict)\n",
"async def post_media(\n", "async def post_media(\n",
" medias: Optional[List[UploadFile]] = File(None),\n", " medias: Optional[List[UploadFile]] = File(None),\n",
" current_user: Dict = Security(get_current_user, scopes=[\"create:posts\"])\n", " current_user: Dict = Security(get_current_user, scopes=[\"create:posts\"])\n",
"):\n", "):\n",
" try:\n", " try:\n",
" post_medias = await asyncio.gather(*[save_to_gridfs(media, 'posts') for media in medias])\n", " post_medias = await asyncio.gather(\n",
" return {'post_medias': post_medias}\n", " *[save_to_gridfs(media, \"posts\") for media in medias or []]\n",
" )\n",
" return {\"post_medias\": post_medias}\n",
" except Exception as e:\n", " except Exception as e:\n",
" raise HTTPException(status_code=500, detail=str(e))\n" " raise HTTPException(status_code=500, detail=str(e))"
], ],
"display_name": ".venv" "display_name": ".venv"
}, },

92
main.py
View File

@ -11,6 +11,7 @@ import io
import asyncio import asyncio
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, StreamingResponse, RedirectResponse from fastapi.responses import HTMLResponse, StreamingResponse, RedirectResponse
from starlette.datastructures import UploadFile as StarletteUploadFile
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import mimetypes import mimetypes
from urllib.parse import quote from urllib.parse import quote
@ -2238,8 +2239,6 @@ async def form_exhibition(
request=request, name="form/exhibition.html", context={"id": 1, "user": user} request=request, name="form/exhibition.html", context={"id": 1, "user": user}
) )
@app.get("/form/exhibition/{exhibition_id}", response_class=HTMLResponse) @app.get("/form/exhibition/{exhibition_id}", response_class=HTMLResponse)
async def form_exhibition( async def form_exhibition(
request: Request, request: Request,
@ -2261,13 +2260,13 @@ async def avatar_change(
try: try:
avatar_id = await save_to_gridfs(avatar, 'users') avatar_id = await save_to_gridfs(avatar, 'users')
await users_collection.update_one({'_id': ObjectId(current_user.get('_id'))}, {'$set': {'img': avatar_id}}) await users_collection.update_one({'_id': ObjectId(current_user.get('_id'))}, {'$set': {'img': avatar_id}})
current_user.update({'img': avatar_id})
background_tasks.add_task(logo_user_task, bson_clear(current_user)) background_tasks.add_task(logo_user_task, bson_clear(current_user))
return {'ok': True} return {'ok': True}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/user/form", response_model=dict) @app.put("/api/user/form", response_model=dict)
async def user_form( async def user_form(
request: Request, request: Request,
@ -2466,18 +2465,99 @@ async def put_painting(
return doc_out return doc_out
@app.post("/api/posts/media/chunk")
async def upload_chunk(
chunk: UploadFile = File(...),
file_id: str = Form(...),
chunk_index: int = Form(...),
total_chunks: int = Form(...),
filename: str = Form(...),
content_type: str = Form(...),
current_user: Dict = Security(get_current_user, scopes=["create:posts"])
):
try:
data = await chunk.read()
b64_data = base64.b64encode(data).decode("ascii")
key = f"upload:{file_id}"
# Store meta only once
meta = await redis_client.hgetall(key)
if not meta:
await redis_client.hset(key, mapping={
"filename": filename,
"content_type": content_type,
"total_chunks": str(total_chunks),
"user_id": str(current_user.get("id", "")),
})
await redis_client.expire(key, CHUNK_TTL)
# Store the chunk
await redis_client.hset(key, f"chunk:{chunk_index}", b64_data)
await redis_client.expire(key, CHUNK_TTL)
return {"ok": True, "received": chunk_index}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/posts/media/finalize")
async def finalize_upload(
payload: dict,
current_user: Dict = Security(get_current_user, scopes=["create:posts"])
):
file_id = payload.get("file_id")
if not file_id:
raise HTTPException(status_code=400, detail="file_id required")
key = f"upload:{file_id}"
meta = await redis_client.hgetall(key)
if not meta:
raise HTTPException(status_code=400, detail="Unknown or expired file_id")
total_chunks = int(meta["total_chunks"])
filename = meta["filename"]
content_type = meta["content_type"]
# Collect all chunks
chunks = []
for i in range(total_chunks):
b64 = await redis_client.hget(key, f"chunk:{i}")
if b64 is None:
raise HTTPException(status_code=400, detail=f"Missing chunk {i}")
chunks.append(base64.b64decode(b64))
full_content = b"".join(chunks)
temp_file = StarletteUploadFile(
file=BytesIO(full_content),
filename=filename,
headers={"content-type": content_type}
)
try:
saved_name = await save_to_gridfs(temp_file, "posts")
return {"post_medias": [saved_name]}
finally:
# Cleanup
await redis_client.delete(key)
# Optional fallback (non-chunked)
@app.post("/api/posts/media", response_model=dict) @app.post("/api/posts/media", response_model=dict)
async def post_media( async def post_media(
medias: Optional[List[UploadFile]] = File(None), medias: Optional[List[UploadFile]] = File(None),
current_user: Dict = Security(get_current_user, scopes=["create:posts"]) current_user: Dict = Security(get_current_user, scopes=["create:posts"])
): ):
try: try:
post_medias = await asyncio.gather(*[save_to_gridfs(media, 'posts') for media in medias]) post_medias = await asyncio.gather(
return {'post_medias': post_medias} *[save_to_gridfs(media, "posts") for media in medias or []]
)
return {"post_medias": post_medias}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/news", response_model=dict) @app.post("/api/news", response_model=dict)
async def post_news( async def post_news(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,

View File

@ -51,28 +51,27 @@
<div class="preview" id="preview"></div> <div class="preview" id="preview"></div>
</div> </div>
</form> </form>
<script defer> <script>
const post = JSON.parse({{post|tojson}}) const post = JSON.parse({{ post|tojson }});
const postId = post._id const postId = post._id;
const fileInput = document.getElementById('fileInput'); const fileInput = document.getElementById('fileInput');
const preview = document.getElementById('preview'); const preview = document.getElementById('preview');
const createBtn = document.getElementById('createBtn');
const form = document.getElementById('postForm');
// Fill form fields
document.getElementById('author').value = post.author; document.getElementById('author').value = post.author;
if (document.getElementById('grade')){ if (document.getElementById('grade')) {
document.getElementById('grade').value = post.grade; document.getElementById('grade').value = post.grade;
const inputEl = document.getElementById('grade'); const inputEl = document.getElementById('grade');
let list = inputEl.dataset.list;
let list = inputEl.dataset.list; list = JSON.parse(list.replace(/'/g, '"'));
list = JSON.parse(list.replace(/'/g, '"'));
list.push(post.grade); list.push(post.grade);
list = [...new Set(list)]; list = [...new Set(list)];
inputEl.dataset.list = JSON.stringify(list); inputEl.dataset.list = JSON.stringify(list);
inputEl.dataset.value = post.grade; inputEl.dataset.value = post.grade;
} }
document.getElementById('title').value = post.title; document.getElementById('title').value = post.title;
document.getElementById('city').value = post.city; document.getElementById('city').value = post.city;
document.getElementById('size').value = post.size; document.getElementById('size').value = post.size;
@ -80,39 +79,110 @@
document.getElementById('genre').value = post.genre; document.getElementById('genre').value = post.genre;
document.getElementById('price').value = post.price; document.getElementById('price').value = post.price;
document.getElementById('description').value = post.description; document.getElementById('description').value = post.description;
let filesOrdered = []; let filesOrdered = [];
console.log(post.medias_original) let isSubmitting = false;
const existingMedias = (post?.medias_original ?? []).map((media) => ({ url: `/b/posts/${media}`, name: `${media}`, isExisting: true, type: "image",}));
async function uploadMediaFiles(files) { const existingMedias = (post?.medias_original ?? []).map((media) => ({
if (!files || files.length === 0) return []; url: `/b/posts/${media}`,
name: `${media}`,
isExisting: true,
type: "image",
}));
const formData = new FormData(); const CHUNK_SIZE = 2 * 1024 * 1024; // 2 MB
for (let file of files) {
formData.append('medias', file); // ========== Chunked upload ==========
async function uploadFileInChunks(file, onProgress) {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const fileId = crypto.randomUUID();
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk, file.name);
formData.append('file_id', fileId);
formData.append('chunk_index', i);
formData.append('total_chunks', totalChunks);
formData.append('filename', file.name);
formData.append('content_type', file.type || 'application/octet-stream');
const res = await fetch('/api/posts/media/chunk', {
method: 'POST',
body: formData
});
if (!res.ok) throw new Error(`Chunk ${i} failed`);
onProgress((i + 1) / totalChunks);
} }
const res = await fetch('/api/posts/media', { const finalRes = await fetch('/api/posts/media/finalize', {
method: 'POST', method: 'POST',
body: formData headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_id: fileId })
}); });
if (!res.ok) throw new Error('Ошибка загрузки медиа'); if (!finalRes.ok) throw new Error('Finalize failed');
return await res.json(); const data = await finalRes.json();
return data.post_medias[0];
} }
function loadExistingMedias() { function createProgressBar(fileName) {
existingMedias.forEach(media => { const wrapper = document.createElement('div');
filesOrdered.push({ wrapper.className = 'upload-item';
name: media.name, wrapper.innerHTML = `
url: media.url, <div class="upload-name">${fileName}</div>
isExisting: true, <div class="progress-bar"><div class="progress-fill"></div></div>
type: media.type <div class="progress-text">0%</div>
}); `;
}); return wrapper;
renderPreviews();
} }
async function uploadMediaFiles(files) {
if (!files || files.length === 0) return { post_medias: [] };
let progressContainer = document.getElementById('uploadProgress');
if (!progressContainer) {
progressContainer = document.createElement('div');
progressContainer.id = 'uploadProgress';
progressContainer.className = 'upload-progress-container';
preview.parentNode.insertBefore(progressContainer, preview);
}
progressContainer.innerHTML = '';
const results = [];
for (const file of files) {
const progressEl = createProgressBar(file.name);
progressContainer.appendChild(progressEl);
const fill = progressEl.querySelector('.progress-fill');
const text = progressEl.querySelector('.progress-text');
try {
const name = await uploadFileInChunks(file, (p) => {
const pct = Math.round(p * 100);
fill.style.width = pct + '%';
text.textContent = pct + '%';
});
results.push(name);
progressEl.classList.add('done');
text.textContent = '✓';
} catch (err) {
progressEl.classList.add('error');
text.textContent = 'Ошибка';
throw err;
}
}
setTimeout(() => progressContainer.remove(), 1200);
return { post_medias: results };
}
// ========== Preview helpers ==========
function createObjectURLSafe(item) { function createObjectURLSafe(item) {
if (item.isExisting || !item.file) return item.url; if (item.isExisting || !item.file) return item.url;
if (!item.previewURL) { if (!item.previewURL) {
@ -130,7 +200,6 @@
function renderPreviews() { function renderPreviews() {
preview.innerHTML = ''; preview.innerHTML = '';
filesOrdered.forEach((item, idx) => { filesOrdered.forEach((item, idx) => {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'item'; div.className = 'item';
@ -142,13 +211,11 @@
title.textContent = idx === 0 ? 'Титульная' : `${idx + 1}`; title.textContent = idx === 0 ? 'Титульная' : `${idx + 1}`;
const isImage = item.type && item.type.startsWith('image'); const isImage = item.type && item.type.startsWith('image');
const mediaEl = document.createElement(isImage ? 'img' : 'video'); const mediaEl = document.createElement(isImage ? 'img' : 'video');
mediaEl.className = 'thumb'; mediaEl.className = 'thumb';
mediaEl.src = createObjectURLSafe(item); mediaEl.src = createObjectURLSafe(item);
if (!isImage) mediaEl.controls = true; if (!isImage) mediaEl.controls = true;
// Контролы
const controls = document.createElement('div'); const controls = document.createElement('div');
controls.className = 'controls'; controls.className = 'controls';
@ -159,13 +226,12 @@
makeFirstBtn.addEventListener('click', () => moveFile(idx, 0)); makeFirstBtn.addEventListener('click', () => moveFile(idx, 0));
const leftBtn = document.createElement('button'); const leftBtn = document.createElement('button');
leftBtn.textContent = '←';
leftBtn.textContent = idx;
leftBtn.disabled = idx === 0; leftBtn.disabled = idx === 0;
leftBtn.addEventListener('click', () => moveFile(idx, idx - 1)); leftBtn.addEventListener('click', () => moveFile(idx, idx - 1));
const rightBtn = document.createElement('button'); const rightBtn = document.createElement('button');
rightBtn.textContent = idx + 2; rightBtn.textContent = '→';
rightBtn.disabled = idx === filesOrdered.length - 1; rightBtn.disabled = idx === filesOrdered.length - 1;
rightBtn.addEventListener('click', () => moveFile(idx, idx + 1)); rightBtn.addEventListener('click', () => moveFile(idx, idx + 1));
@ -173,7 +239,9 @@
removeBtn.textContent = '✖'; removeBtn.textContent = '✖';
removeBtn.addEventListener('click', () => removeFile(idx)); removeBtn.addEventListener('click', () => removeFile(idx));
controls.append(idx>0?makeFirstBtn:'', idx>0?leftBtn:'', idx < filesOrdered.length - 1?rightBtn:'', removeBtn); if (idx > 0) controls.append(makeFirstBtn, leftBtn);
if (idx < filesOrdered.length - 1) controls.append(rightBtn);
controls.append(removeBtn);
div.append(mediaEl, title, controls); div.append(mediaEl, title, controls);
preview.appendChild(div); preview.appendChild(div);
@ -183,19 +251,15 @@
e.dataTransfer.setData('text/plain', idx.toString()); e.dataTransfer.setData('text/plain', idx.toString());
div.classList.add('dragging'); div.classList.add('dragging');
}); });
div.addEventListener('dragend', () => { div.addEventListener('dragend', () => {
div.classList.remove('dragging'); div.classList.remove('dragging');
document.querySelectorAll('.item').forEach(el => el.classList.remove('drag-over')); document.querySelectorAll('.item').forEach(el => el.classList.remove('drag-over'));
}); });
div.addEventListener('dragover', (e) => { div.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
div.classList.add('drag-over'); div.classList.add('drag-over');
}); });
div.addEventListener('dragleave', () => div.classList.remove('drag-over')); div.addEventListener('dragleave', () => div.classList.remove('drag-over'));
div.addEventListener('drop', (e) => { div.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
div.classList.remove('drag-over'); div.classList.remove('drag-over');
@ -220,86 +284,105 @@
renderPreviews(); renderPreviews();
} }
function loadExistingMedias() {
existingMedias.forEach(media => {
filesOrdered.push({
name: media.name,
url: media.url,
isExisting: true,
type: media.type
});
});
renderPreviews();
}
// ========== File select → upload → show blob only after success ==========
fileInput.addEventListener('change', async (e) => { fileInput.addEventListener('change', async (e) => {
const newFiles = Array.from(e.target.files); const newFiles = Array.from(e.target.files);
if (newFiles.length === 0) return; if (newFiles.length === 0) return;
createBtn.disabled = true;
try { try {
const uploadedNames = await uploadMediaFiles(newFiles); const uploaded = await uploadMediaFiles(newFiles);
newFiles.forEach((file, i) => { newFiles.forEach((file, i) => {
filesOrdered.push({ filesOrdered.push({
name: uploadedNames['post_medias'][i], name: uploaded.post_medias[i],
url: `/b/posts/${uploadedNames['post_medias'][i]}`, url: `/b/posts/${uploaded.post_medias[i]}`,
file: file, file: file,
type: file.type, type: file.type,
isExisting: false isExisting: false
}); });
}); });
renderPreviews(); renderPreviews(); // blob appears only here
} catch (err) { } catch (err) {
console.error('Ошибка загрузки фото:', err); console.error('Ошибка загрузки фото:', err);
alert('Не удалось загрузить фото. Попробуйте ещё раз.'); alert('Не удалось загрузить фото. Попробуйте ещё раз.');
} finally {
createBtn.disabled = false;
fileInput.value = '';
} }
fileInput.value = '';
}); });
window.addEventListener('load', () => { // ========== Save button – only one request ==========
loadExistingMedias();
});
window.addEventListener('beforeunload', () => {
filesOrdered.forEach(revokeObjectURLSafe);
});
// === Отправка формы ===
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const author = '{{user.username}}'; const author = '{{ user.username }}';
const grade = '{{user.grade}}'; const grade = '{{ user.grade }}';
const form = document.getElementById('postForm');
const createBtn = document.getElementById('createBtn');
createBtn.addEventListener('click', async (e) => { createBtn.addEventListener('click', async (e) => {
e.preventDefault(); e.preventDefault();
if (isSubmitting) return;
// ... (проверка обязательных полей остаётся без изменений) const requiredFields = [
...document.querySelectorAll('input[required]'),
const requiredFields = [...document.querySelectorAll('input[required]'), ...document.querySelectorAll('textarea[required]')]; ...document.querySelectorAll('textarea[required]')
];
let hasError = false; let hasError = false;
requiredFields.forEach(field => { requiredFields.forEach(field => {
const group = field.closest('.floating-group'); const group = field.closest('.floating-group');
const isEmpty = field.type === 'file' const isEmpty = field.type === 'file'
? !field.files || field.files.length === 0 ? !field.files || field.files.length === 0
: !field.value.trim(); : !field.value.trim();
if (isEmpty) { if (isEmpty) {
group.classList.add('error'); group?.classList.add('error');
hasError = true; hasError = true;
} else { } else {
group.classList.remove('error'); group?.classList.remove('error');
} }
}); });
if (hasError) return; if (hasError) return;
// === Подготовка данных === if (filesOrdered.length === 0) {
showToast({ message: 'Поле фото должно быть заполнено', duration: 2500 });
return;
}
// Lock
isSubmitting = true;
createBtn.disabled = true;
createBtn.classList.add('disabled');
const data = new FormData(form); const data = new FormData(form);
if (document.querySelector('#author').value === '') { if (document.querySelector('#author')?.value === '') {
data.set('author', author); data.set('author', author);
} }
{% if 'user' in user.get('roles') and user.get('grade') is not none %} {% if 'user' in user.get('roles') and user.get('grade') is not none %}
data.set('grade', grade); data.set('grade', grade);
{% endif %} {% endif %}
filesOrdered.forEach(f => data.append('post_medias', f.name)); filesOrdered.forEach(f => data.append('post_medias', f.name));
try { try {
const res = await fetch(`/api/post/${postId}`, { const res = await fetch(`/api/post/${postId}`, {
method: 'PUT', method: 'PUT',
body: data body: data
}); });
if (res.ok) { if (res.ok) {
@ -307,21 +390,31 @@
const next = params.get('next') || '/profile/posts'; const next = params.get('next') || '/profile/posts';
window.location.href = next; window.location.href = next;
} else { } else {
showToast({message: 'Ошибка при сохранение поста', status: "error", duration:2500}); showToast({ message: 'Ошибка при сохранении поста', status: 'error', duration: 2500 });
isSubmitting = false;
createBtn.disabled = false;
createBtn.classList.remove('disabled');
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
showToast({message: 'Ошибка соединения', status: "error", duration:2500}); showToast({ message: 'Ошибка соединения', status: 'error', duration: 2500 });
isSubmitting = false;
createBtn.disabled = false;
createBtn.classList.remove('disabled');
} }
}); });
// Удаление ошибки при вводе
document.querySelectorAll('.floating-group input, .floating-group textarea').forEach(input => { document.querySelectorAll('.floating-group input, .floating-group textarea').forEach(input => {
input.addEventListener('input', () => { input.addEventListener('input', () => {
input.closest('.floating-group').classList.remove('error'); input.closest('.floating-group')?.classList.remove('error');
}); });
}); });
}); });
window.addEventListener('load', loadExistingMedias);
window.addEventListener('beforeunload', () => {
filesOrdered.forEach(revokeObjectURLSafe);
});
</script> </script>
@ -329,6 +422,62 @@
{% macro style() -%} {% macro style() -%}
<style> <style>
.upload-progress-container {
margin: 12px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.upload-item {
background: #f5f5f5;
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
}
.upload-name {
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-bar {
height: 6px;
background: #e0e0e0;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #4f46e5, #7c3aed);
border-radius: 3px;
transition: width 0.25s ease;
}
.progress-text {
margin-top: 4px;
font-size: 12px;
color: #666;
}
.upload-item.done .progress-fill {
background: #16a34a;
}
.upload-item.error .progress-fill {
background: #dc2626;
}
#createBtn.disabled,
#createBtn:disabled {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
}
.preview { .preview {
display: flex; display: flex;
gap: 8px; gap: 8px;

View File

@ -124,7 +124,350 @@
<span class="c_som9s">Опубликовать</span> <span class="c_som9s">Опубликовать</span>
</button> </button>
</div> </div>
<script async> <div class="c_rtopc">
<label class="c_aq9et ba" for="fileInput">
<span class="c_som9s">Добавить фото</span>
</label>
<input class="c_1gai0" id="fileInput" type="file" multiple accept="image/*" style="display: none;"/>
<div class="preview" id="preview"></div>
</div>
</form>
<script>
const fileInput = document.getElementById('fileInput');
const preview = document.getElementById('preview');
const createBtn = document.getElementById('createBtn');
const form = document.getElementById('postForm');
let filesOrdered = [];
const existingMedias = [];
let isSubmitting = false;
const CHUNK_SIZE = 2 * 1024 * 1024; // 2 MB
// ========== Chunked upload ==========
async function uploadFileInChunks(file, onProgress) {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const fileId = crypto.randomUUID();
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk, file.name);
formData.append('file_id', fileId);
formData.append('chunk_index', i);
formData.append('total_chunks', totalChunks);
formData.append('filename', file.name);
formData.append('content_type', file.type || 'application/octet-stream');
const res = await fetch('/api/posts/media/chunk', {
method: 'POST',
body: formData
});
if (!res.ok) throw new Error(`Chunk ${i} failed`);
onProgress((i + 1) / totalChunks);
}
const finalRes = await fetch('/api/posts/media/finalize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_id: fileId })
});
if (!finalRes.ok) throw new Error('Finalize failed');
const data = await finalRes.json();
return data.post_medias[0];
}
function createProgressBar(fileName) {
const wrapper = document.createElement('div');
wrapper.className = 'upload-item';
wrapper.innerHTML = `
<div class="upload-name">${fileName}</div>
<div class="progress-bar"><div class="progress-fill"></div></div>
<div class="progress-text">0%</div>
`;
return wrapper;
}
async function uploadMediaFiles(files) {
if (!files || files.length === 0) return { post_medias: [] };
let progressContainer = document.getElementById('uploadProgress');
if (!progressContainer) {
progressContainer = document.createElement('div');
progressContainer.id = 'uploadProgress';
progressContainer.className = 'upload-progress-container';
preview.parentNode.insertBefore(progressContainer, preview);
}
progressContainer.innerHTML = '';
const results = [];
for (const file of files) {
const progressEl = createProgressBar(file.name);
progressContainer.appendChild(progressEl);
const fill = progressEl.querySelector('.progress-fill');
const text = progressEl.querySelector('.progress-text');
try {
const name = await uploadFileInChunks(file, (p) => {
const pct = Math.round(p * 100);
fill.style.width = pct + '%';
text.textContent = pct + '%';
});
results.push(name);
progressEl.classList.add('done');
text.textContent = '✓';
} catch (err) {
progressEl.classList.add('error');
text.textContent = 'Ошибка';
throw err;
}
}
setTimeout(() => progressContainer.remove(), 1200);
return { post_medias: results };
}
// ========== Preview helpers ==========
function createObjectURLSafe(item) {
if (item.isExisting || !item.file) return item.url;
if (!item.previewURL) item.previewURL = URL.createObjectURL(item.file);
return item.previewURL;
}
function revokeObjectURLSafe(item) {
if (item.previewURL) {
URL.revokeObjectURL(item.previewURL);
item.previewURL = null;
}
}
function renderPreviews() {
preview.innerHTML = '';
filesOrdered.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'item';
div.draggable = true;
div.dataset.index = idx;
const title = document.createElement('div');
title.className = 'title';
title.textContent = idx === 0 ? 'Титульная' : `${idx + 1}`;
const isImage = item.type && item.type.startsWith('image');
const mediaEl = document.createElement(isImage ? 'img' : 'video');
mediaEl.className = 'thumb';
mediaEl.src = createObjectURLSafe(item);
if (!isImage) mediaEl.controls = true;
const controls = document.createElement('div');
controls.className = 'controls';
const makeFirstBtn = document.createElement('button');
makeFirstBtn.textContent = 'Титульная';
makeFirstBtn.title = 'Сделать титульной';
makeFirstBtn.disabled = idx === 0;
makeFirstBtn.addEventListener('click', () => moveFile(idx, 0));
const leftBtn = document.createElement('button');
leftBtn.textContent = '←';
leftBtn.disabled = idx === 0;
leftBtn.addEventListener('click', () => moveFile(idx, idx - 1));
const rightBtn = document.createElement('button');
rightBtn.textContent = '→';
rightBtn.disabled = idx === filesOrdered.length - 1;
rightBtn.addEventListener('click', () => moveFile(idx, idx + 1));
const removeBtn = document.createElement('button');
removeBtn.textContent = '✖';
removeBtn.addEventListener('click', () => removeFile(idx));
if (idx > 0) controls.append(makeFirstBtn, leftBtn);
if (idx < filesOrdered.length - 1) controls.append(rightBtn);
controls.append(removeBtn);
div.append(mediaEl, title, controls);
preview.appendChild(div);
// Drag & Drop
div.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', idx.toString());
div.classList.add('dragging');
});
div.addEventListener('dragend', () => {
div.classList.remove('dragging');
document.querySelectorAll('.item').forEach(el => el.classList.remove('drag-over'));
});
div.addEventListener('dragover', (e) => {
e.preventDefault();
div.classList.add('drag-over');
});
div.addEventListener('dragleave', () => div.classList.remove('drag-over'));
div.addEventListener('drop', (e) => {
e.preventDefault();
div.classList.remove('drag-over');
const from = parseInt(e.dataTransfer.getData('text/plain'));
const to = parseInt(div.dataset.index);
if (from !== to) moveFile(from, to);
});
});
}
function moveFile(from, to) {
if (to < 0 || to >= filesOrdered.length || from === to) return;
const [moved] = filesOrdered.splice(from, 1);
filesOrdered.splice(to, 0, moved);
renderPreviews();
}
function removeFile(index) {
const item = filesOrdered[index];
revokeObjectURLSafe(item);
filesOrdered.splice(index, 1);
renderPreviews();
}
function loadExistingMedias() {
existingMedias.forEach(media => {
filesOrdered.push({
name: media.name,
url: media.url,
isExisting: true,
type: media.type
});
});
renderPreviews();
}
// ========== File select → upload → show blob only after success ==========
fileInput.addEventListener('change', async (e) => {
const newFiles = Array.from(e.target.files);
if (newFiles.length === 0) return;
createBtn.disabled = true;
try {
const uploaded = await uploadMediaFiles(newFiles);
newFiles.forEach((file, i) => {
filesOrdered.push({
name: uploaded.post_medias[i],
url: `/b/posts/${uploaded.post_medias[i]}`,
file: file,
type: file.type,
isExisting: false
});
});
renderPreviews(); // ← blob appears only here
} catch (err) {
console.error('Ошибка загрузки фото:', err);
alert('Не удалось загрузить фото. Попробуйте ещё раз.');
} finally {
createBtn.disabled = false;
fileInput.value = '';
}
});
// ========== Publish button – only one request ==========
document.addEventListener('DOMContentLoaded', () => {
const author = '{{ user.username }}';
const grade = '{{ user.grade }}';
createBtn.addEventListener('click', async (e) => {
e.preventDefault();
if (isSubmitting) return;
const requiredFields = [
...document.querySelectorAll('input[required]'),
...document.querySelectorAll('textarea[required]')
];
let hasError = false;
requiredFields.forEach(field => {
const group = field.closest('.floating-group');
const isEmpty = field.type === 'file'
? !field.files || field.files.length === 0
: !field.value.trim();
if (isEmpty) {
group?.classList.add('error');
hasError = true;
showToast({ message: field.dataset.toast, duration: 2500 });
} else {
group?.classList.remove('error');
}
});
if (hasError) return;
if (filesOrdered.length === 0) {
showToast({ message: 'Поле фото должно быть заполнено', duration: 2500 });
return;
}
// Lock
isSubmitting = true;
createBtn.disabled = true;
createBtn.classList.add('disabled');
const data = new FormData(form);
if (document.querySelector('#author')?.value === '') {
data.set('author', author);
}
{% if 'user' in user.get('roles') and user.get('grade') is not none %}
data.set('grade', grade);
{% endif %}
filesOrdered.forEach(f => data.append('post_medias', f.name));
try {
const res = await fetch('/api/post', {
method: 'POST',
body: data
});
if (res.ok) {
const params = new URLSearchParams(window.location.search);
const next = params.get('next') || '/profile/posts';
window.location.href = next;
} else {
showToast({ message: 'Ошибка при создании поста', status: 'error', duration: 2500 });
isSubmitting = false;
createBtn.disabled = false;
createBtn.classList.remove('disabled');
}
} catch (err) {
console.error(err);
showToast({ message: 'Ошибка соединения', status: 'error', duration: 2500 });
isSubmitting = false;
createBtn.disabled = false;
createBtn.classList.remove('disabled');
}
});
document.querySelectorAll('.floating-group input, .floating-group textarea').forEach(input => {
input.addEventListener('input', () => {
input.closest('.floating-group')?.classList.remove('error');
});
});
});
window.addEventListener('load', loadExistingMedias);
window.addEventListener('beforeunload', () => {
filesOrdered.forEach(revokeObjectURLSafe);
});
</script>
<script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.getElementById('genre').dataset.list=JSON.stringify([ document.getElementById('genre').dataset.list=JSON.stringify([
{ {
@ -245,275 +588,6 @@
} }
]) ])
}) })
</script>
<div class="c_rtopc">
<label class="c_aq9et ba" for="fileInput">
<span class="c_som9s">Добавить фото</span>
</label>
<input class="c_1gai0" id="fileInput" type="file" multiple accept="image/*" style="display: none;"/>
<div class="preview" id="preview"></div>
</div>
</form>
<script>
const fileInput = document.getElementById('fileInput');
const preview = document.getElementById('preview');
let filesOrdered = [];
const existingMedias = [
{% if post %}
{% for media in post.medias %}
{
url: "/b/posts/{{ media }}",
name: "{{ media }}",
isExisting: true,
type: "image"
}{% if not loop.last %},{% endif %}
{% endfor %}
{% endif %}
];
async function uploadMediaFiles(files) {
if (!files || files.length === 0) return [];
const formData = new FormData();
for (let file of files) {
formData.append('medias', file);
}
const res = await fetch('/api/posts/media', {
method: 'POST',
body: formData
});
if (!res.ok) throw new Error('Ошибка загрузки медиа');
return await res.json();
}
function loadExistingMedias() {
existingMedias.forEach(media => {
filesOrdered.push({
name: media.name,
url: media.url,
isExisting: true,
type: media.type
});
});
renderPreviews();
}
function createObjectURLSafe(item) {
if (item.isExisting || !item.file) return item.url;
if (!item.previewURL) {
item.previewURL = URL.createObjectURL(item.file);
}
return item.previewURL;
}
function revokeObjectURLSafe(item) {
if (item.previewURL) {
URL.revokeObjectURL(item.previewURL);
item.previewURL = null;
}
}
function renderPreviews() {
preview.innerHTML = '';
filesOrdered.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'item';
div.draggable = true;
div.dataset.index = idx;
const title = document.createElement('div');
title.className = 'title';
title.textContent = idx === 0 ? 'Титульная' : `${idx + 1}`;
const isImage = item.type && item.type.startsWith('image');
const mediaEl = document.createElement(isImage ? 'img' : 'video');
mediaEl.className = 'thumb';
mediaEl.src = createObjectURLSafe(item);
if (!isImage) mediaEl.controls = true;
// Контролы
const controls = document.createElement('div');
controls.className = 'controls';
const makeFirstBtn = document.createElement('button');
makeFirstBtn.textContent = 'Титульная';
makeFirstBtn.title = 'Сделать титульной';
makeFirstBtn.disabled = idx === 0;
makeFirstBtn.addEventListener('click', () => moveFile(idx, 0));
const leftBtn = document.createElement('button');
leftBtn.textContent = idx + 1 - 1;
leftBtn.disabled = idx === 0;
leftBtn.addEventListener('click', () => moveFile(idx, idx - 1));
const rightBtn = document.createElement('button');
rightBtn.textContent = idx + 2;
rightBtn.disabled = idx === filesOrdered.length - 1;
rightBtn.addEventListener('click', () => moveFile(idx, idx + 1));
const removeBtn = document.createElement('button');
removeBtn.textContent = '✖';
removeBtn.addEventListener('click', () => removeFile(idx));
controls.append(idx>0?makeFirstBtn:'', idx>0?leftBtn:'', idx < filesOrdered.length - 1?rightBtn:'', removeBtn);
div.append(mediaEl, title, controls);
preview.appendChild(div);
// Drag & Drop
div.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', idx.toString());
div.classList.add('dragging');
});
div.addEventListener('dragend', () => {
div.classList.remove('dragging');
document.querySelectorAll('.item').forEach(el => el.classList.remove('drag-over'));
});
div.addEventListener('dragover', (e) => {
e.preventDefault();
div.classList.add('drag-over');
});
div.addEventListener('dragleave', () => div.classList.remove('drag-over'));
div.addEventListener('drop', (e) => {
e.preventDefault();
div.classList.remove('drag-over');
const from = parseInt(e.dataTransfer.getData('text/plain'));
const to = parseInt(div.dataset.index);
if (from !== to) moveFile(from, to);
});
});
}
function moveFile(from, to) {
if (to < 0 || to >= filesOrdered.length || from === to) return;
const [moved] = filesOrdered.splice(from, 1);
filesOrdered.splice(to, 0, moved);
renderPreviews();
}
function removeFile(index) {
const item = filesOrdered[index];
revokeObjectURLSafe(item);
filesOrdered.splice(index, 1);
renderPreviews();
}
fileInput.addEventListener('change', async (e) => {
const newFiles = Array.from(e.target.files);
if (newFiles.length === 0) return;
try {
const uploadedNames = await uploadMediaFiles(newFiles);
newFiles.forEach((file, i) => {
filesOrdered.push({
name: uploadedNames['post_medias'][i],
url: `/b/posts/${uploadedNames['post_medias'][i]}`,
file: file,
type: file.type,
isExisting: false
});
});
renderPreviews();
} catch (err) {
console.error('Ошибка загрузки фото:', err);
alert('Не удалось загрузить фото. Попробуйте ещё раз.');
}
fileInput.value = '';
});
window.addEventListener('load', () => {
loadExistingMedias();
});
window.addEventListener('beforeunload', () => {
filesOrdered.forEach(revokeObjectURLSafe);
});
document.addEventListener('DOMContentLoaded', () => {
const author = '{{user.username}}';
const grade = '{{user.grade}}';
const form = document.getElementById('postForm');
const createBtn = document.getElementById('createBtn');
createBtn.addEventListener('click', async (e) => {
e.preventDefault();
const requiredFields = [...document.querySelectorAll('input[required]'), ...document.querySelectorAll('textarea[required]')];
let hasError = false;
requiredFields.forEach(field => {
const group = field.closest('.floating-group');
const isEmpty = field.type === 'file'
? !field.files || field.files.length === 0
: !field.value.trim();
if (isEmpty) {
group.classList.add('error');
hasError = true;
showToast({message: field.dataset.toast, duration:2500});
} else {
group.classList.remove('error');
}
});
if (hasError) return;
const data = new FormData(form);
if (document.querySelector('#author').value === '') {
data.set('author', author);
}
{% if 'user' in user.get('roles') and user.get('grade') is not none %}
data.set('grade', grade);
{% endif %}
filesOrdered.forEach(f => data.append('post_medias', f.name));
if (filesOrdered.length === 0){
showToast({message: 'Поле фото должно быть заполнено', duration:2500});
return;
}
try {
const res = await fetch('/api/post', {
method: 'POST',
body: data
});
if (res.ok) {
const params = new URLSearchParams(window.location.search);
const next = params.get('next') || '/profile/posts';
window.location.href = next;
} else {
showToast({message: 'Ошибка при создании поста', status: "error", duration:2500});
}
} catch (err) {
console.error(err);
showToast({message: 'Ошибка соединения', status: "error", duration:2500});
}
});
document.querySelectorAll('.floating-group input, .floating-group textarea').forEach(input => {
input.addEventListener('input', () => {
input.closest('.floating-group').classList.remove('error');
});
});
});
</script> </script>
{{ footer.section() }} {{ footer.section() }}
{% endblock %} {% endblock %}
@ -522,6 +596,62 @@
{% block head %} {% block head %}
{{ header.style() }} {{ header.style() }}
<style> <style>
.upload-progress-container {
margin: 12px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.upload-item {
background: #f5f5f5;
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
}
.upload-name {
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-bar {
height: 6px;
background: #e0e0e0;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #4f46e5, #7c3aed);
border-radius: 3px;
transition: width 0.25s ease;
}
.progress-text {
margin-top: 4px;
font-size: 12px;
color: #666;
}
.upload-item.done .progress-fill {
background: #16a34a;
}
.upload-item.error .progress-fill {
background: #dc2626;
}
#createBtn.disabled,
#createBtn:disabled {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
}
.b_xj1br { color: #FFF; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; } .b_xj1br { color: #FFF; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
.b_dc4rx { align-items: center; align-self: stretch; background: #5F1798; border-radius: 5px; display: flex; justify-content: center; padding: 12px 0; } .b_dc4rx { align-items: center; align-self: stretch; background: #5F1798; border-radius: 5px; display: flex; justify-content: center; padding: 12px 0; }
.b_5rck0 { align-items: center; align-self: stretch; background: #F39318; border-radius: 5px; display: flex; justify-content: center; padding: 12px 0; } .b_5rck0 { align-items: center; align-self: stretch; background: #F39318; border-radius: 5px; display: flex; justify-content: center; padding: 12px 0; }

View File

@ -352,7 +352,7 @@ def is_safe_redirect(next_url: str) -> bool:
return False return False
async def build_composite(data, scale: float = 1.5, new_w: int = 500): 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') src_img = Image.open(BytesIO(data)).convert('RGB')
# -------------------------------------------------------------- # --------------------------------------------------------------
@ -391,7 +391,7 @@ async def build_composite(data, scale: float = 1.5, new_w: int = 500):
bg_cropped = bg_resized.crop((left, top, right, bottom)) bg_cropped = bg_resized.crop((left, top, right, bottom))
# размытие # размытие
bg_blurred = bg_cropped.filter(ImageFilter.GaussianBlur(radius=3)) bg_blurred = bg_cropped.filter(ImageFilter.GaussianBlur(radius=GaussianBlur_radius))
# -------------------------------------------------------------- # --------------------------------------------------------------
# 4️⃣ Переднее изображение – contain + центрировать # 4️⃣ Переднее изображение – contain + центрировать
@ -427,6 +427,6 @@ async def build_composite(data, scale: float = 1.5, new_w: int = 500):
# -------------------------------------------------------------- # --------------------------------------------------------------
out_bytes = BytesIO() out_bytes = BytesIO()
# final_img.save(out_bytes, format='JPEG', quality=85) # final_img.save(out_bytes, format='JPEG', quality=85)
final_img.save(out_bytes, format="WEBP", quality=85, method=6) final_img.save(out_bytes, format="WEBP", quality=quality, method=6)
out_bytes.seek(0) out_bytes.seek(0)
return out_bytes # JPEG‑байты готового композитного изображения return out_bytes # JPEG‑байты готового композитного изображения