Image converter: add AVIF/TIFF/GIF/BMP/HEIC, quality slider, custom resize

- Renamed: Сжать картинки → Конвертер изображений
- New output formats: AVIF, TIFF, GIF (via sharp)
- New input formats: AVIF, TIFF, GIF, BMP, SVG, HEIC
- Quality slider (10-100%) in UI
- Custom resize option (prompt for px)
- 3-column control layout (format, size, quality)
- Updated descriptions in dashboard, DB, page title
This commit is contained in:
treamz 2026-03-22 01:15:03 +03:00
parent 378c1a075e
commit a7d6287d65
3 changed files with 34 additions and 24 deletions

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Сжатие картинок — WA Dev Tools</title> <title>Конвертер изображений — WA Dev Tools</title>
<script src="/vendor/tailwind.js"></script> <script src="/vendor/tailwind.js"></script>
<script src="/shared.js"></script> <script src="/shared.js"></script>
<link href="/vendor/fonts.css" rel="stylesheet"> <link href="/vendor/fonts.css" rel="stylesheet">
@ -37,7 +37,7 @@
<div class="text-center mb-8 fade-up"> <div class="text-center mb-8 fade-up">
<div class="page-header"> <div class="page-header">
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900"> <h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight dark:text-white text-gray-900">
Сжать <span class="text-accent">картинки</span> Конвертер <span class="text-accent">изображений</span>
</h1> </h1>
<p class="description mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">до 50 файлов &middot; до 20 МБ каждый</p> <p class="description mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">до 50 файлов &middot; до 20 МБ каждый</p>
</div> </div>
@ -73,11 +73,14 @@
</div> </div>
<!-- Controls --> <!-- Controls -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-5"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-5">
<div> <div>
<label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Формат</label> <label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Формат</label>
<select id="format" class="w-full px-3 py-2.5 dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 rounded-lg text-sm focus:outline-none focus:border-accent/50 transition-colors"> <select id="format" class="w-full px-3 py-2.5 dark:bg-surface-700 bg-gray-50 border dark:border-surface-600 border-gray-200 rounded-lg text-sm focus:outline-none focus:border-accent/50 transition-colors">
<option value="original">Оригинал</option> <option value="original">Оригинал</option>
<option value="avif">AVIF</option>
<option value="tiff">TIFF</option>
<option value="gif">GIF</option>
<option value="webp">WebP</option> <option value="webp">WebP</option>
<option value="jpeg">JPEG</option> <option value="jpeg">JPEG</option>
<option value="png">PNG</option> <option value="png">PNG</option>
@ -93,8 +96,10 @@
<option value="1600">1600px</option> <option value="1600">1600px</option>
<option value="1200">1200px</option> <option value="1200">1200px</option>
<option value="800">800px</option> <option value="800">800px</option>
<option value="custom">Свой размер...</option>
</select> </select>
</div> </div>
<div> <label class="block text-xs font-medium dark:text-gray-500 text-gray-400 mb-1.5 font-mono uppercase tracking-wider">Качество</label> <input type="range" id="quality" min="10" max="100" value="80" class="w-full" oninput="document.getElementById('qualityVal').textContent=this.value+'%'" /> <div class="text-xs text-center dark:text-gray-500 text-gray-400 mt-1" id="qualityVal">80%</div> </div>
</div> </div>
<!-- Progress --> <!-- Progress -->
@ -213,7 +218,7 @@ function processFiles(files) {
const valid = Array.from(files).filter(f => validTypes.includes(f.type) && f.size <= 20 * 1024 * 1024); const valid = Array.from(files).filter(f => validTypes.includes(f.type) && f.size <= 20 * 1024 * 1024);
if (!valid.length) { if (!valid.length) {
showError('Нет подходящих файлов. Допустимы JPEG, PNG, WebP до 20 МБ.'); showError('Нет подходящих файлов. Допустимы JPEG, PNG, WebP, AVIF, TIFF, GIF, BMP, HEIC до 20 МБ.');
return; return;
} }
@ -263,8 +268,11 @@ compressBtn.addEventListener('click', () => {
const formData = new FormData(); const formData = new FormData();
selectedFiles.forEach(f => formData.append('images', f)); selectedFiles.forEach(f => formData.append('images', f));
formData.append('resize', $('resize').value);
formData.append('format', $('format').value); formData.append('format', $('format').value);
formData.append('quality', $('quality').value);
var rv = $('resize').value;
if (rv === 'custom') rv = prompt('Размер по длинной стороне (px):', '1024') || '0';
formData.append('resize', rv);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', '/compress'); xhr.open('POST', '/compress');

View File

@ -111,7 +111,7 @@
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 fade-d1"> <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 fade-d1">
<a href="/compress" class="tool-tile"> <a href="/compress" class="tool-tile">
<div class="tile-icon"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/></svg></div> <div class="tile-icon"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0023.25 18.75V5.25A2.25 2.25 0 0021 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/></svg></div>
<div><div class="tile-title">Сжатие</div><div class="tile-desc">JPEG, PNG, WebP до 20МБ</div></div> <div><div class="tile-title">Сжатие</div><div class="tile-desc">Сжатие, ресайз, конвертация</div></div>
</a> </a>
<a href="/placeholder" class="tool-tile"> <a href="/placeholder" class="tool-tile">
<div class="tile-icon"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714a2.25 2.25 0 00.659 1.591L19 14.5M14.25 3.104c.251.023.501.05.75.082M19 14.5l-2.47 2.47a2.25 2.25 0 01-1.59.659H9.06a2.25 2.25 0 01-1.591-.659L5 14.5m14 0V17a2.25 2.25 0 01-2.25 2.25H7.25A2.25 2.25 0 015 17v-2.5"/></svg></div> <div class="tile-icon"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714a2.25 2.25 0 00.659 1.591L19 14.5M14.25 3.104c.251.023.501.05.75.082M19 14.5l-2.47 2.47a2.25 2.25 0 01-1.59.659H9.06a2.25 2.25 0 01-1.591-.659L5 14.5m14 0V17a2.25 2.25 0 01-2.25 2.25H7.25A2.25 2.25 0 015 17v-2.5"/></svg></div>

View File

@ -43,9 +43,9 @@ const upload = multer({
dest: UPLOADS_DIR, dest: UPLOADS_DIR,
limits: { fileSize: maxFileSize }, limits: { fileSize: maxFileSize },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
const validTypes = ['image/jpeg', 'image/png', 'image/webp']; const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/tiff', 'image/gif', 'image/bmp', 'image/svg+xml'];
if (validTypes.includes(file.mimetype)) cb(null, true); if (validTypes.includes(file.mimetype) || file.originalname.match(/\.(jpe?g|png|webp|avif|tiff?|gif|bmp|svg|heic|heif)$/i)) cb(null, true);
else cb(new Error('Unsupported file type')); else cb(new Error('Неподдерживаемый формат изображения'));
}, },
}); });
@ -86,10 +86,8 @@ function transliterate(str) {
} }
function getOutputExtension(format, originalName) { function getOutputExtension(format, originalName) {
if (format === 'webp') return '.webp'; const extMap = { webp: '.webp', jpeg: '.jpg', png: '.png', avif: '.avif', tiff: '.tiff', gif: '.gif' };
if (format === 'jpeg') return '.jpg'; return extMap[format] || path.extname(originalName);
if (format === 'png') return '.png';
return path.extname(originalName);
} }
function changeExtension(filename, newExt) { function changeExtension(filename, newExt) {
@ -112,11 +110,11 @@ router.get('/', (req, res) => {
router.post('/', limiter, upload.array('images', maxFiles), async (req, res) => { router.post('/', limiter, upload.array('images', maxFiles), async (req, res) => {
const resize = parseInt(req.body.resize || '0', 10); const resize = parseInt(req.body.resize || '0', 10);
const format = (req.body.format || 'original').toLowerCase(); const format = (req.body.format || 'original').toLowerCase();
const quality = Math.min(100, Math.max(1, parseInt(req.body.quality || process.env.COMPRESS_QUALITY || '60', 10))); const quality = Math.min(100, Math.max(1, parseInt(req.body.quality || process.env.COMPRESS_QUALITY || '80', 10)));
const validFormats = ['original', 'webp', 'jpeg', 'png']; const validFormats = ['original', 'webp', 'jpeg', 'png', 'avif', 'tiff', 'gif'];
if (!validFormats.includes(format)) { if (!validFormats.includes(format)) {
return res.status(400).json({ error: 'Неверный формат. Допустимые: original, webp, jpeg, png' }); return res.status(400).json({ error: 'Неверный формат' });
} }
if (!req.files || !req.files.length) { if (!req.files || !req.files.length) {
return res.status(400).json({ error: 'Файлы не загружены' }); return res.status(400).json({ error: 'Файлы не загружены' });
@ -154,17 +152,21 @@ router.post('/', limiter, upload.array('images', maxFiles), async (req, res) =>
} }
let buffer; let buffer;
if (format === 'webp') { if (format === 'webp') buffer = await image.webp({ quality }).toBuffer();
buffer = await image.webp({ quality }).toBuffer(); else if (format === 'jpeg') buffer = await image.jpeg({ quality }).toBuffer();
} else if (format === 'jpeg') { else if (format === 'png') buffer = await image.png({ compressionLevel: 9 }).toBuffer();
buffer = await image.jpeg({ quality }).toBuffer(); else if (format === 'avif') buffer = await image.avif({ quality }).toBuffer();
} else if (format === 'png') { else if (format === 'tiff') buffer = await image.tiff({ quality }).toBuffer();
buffer = await image.png({ compressionLevel: 9 }).toBuffer(); else if (format === 'gif') buffer = await image.gif().toBuffer();
} else { else {
// original — keep source format with compression
if (file.mimetype === 'image/jpeg') buffer = await image.jpeg({ quality }).toBuffer(); if (file.mimetype === 'image/jpeg') buffer = await image.jpeg({ quality }).toBuffer();
else if (file.mimetype === 'image/png') buffer = await image.png({ compressionLevel: 9 }).toBuffer(); else if (file.mimetype === 'image/png') buffer = await image.png({ compressionLevel: 9 }).toBuffer();
else if (file.mimetype === 'image/webp') buffer = await image.webp({ quality }).toBuffer(); else if (file.mimetype === 'image/webp') buffer = await image.webp({ quality }).toBuffer();
else continue; else if (file.mimetype === 'image/avif') buffer = await image.avif({ quality }).toBuffer();
else if (file.mimetype === 'image/tiff') buffer = await image.tiff({ quality }).toBuffer();
else if (file.mimetype === 'image/gif') buffer = await image.gif().toBuffer();
else buffer = await image.jpeg({ quality }).toBuffer(); // fallback
} }
const readableName = fixMulterFilename(file.originalname); const readableName = fixMulterFilename(file.originalname);