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:
parent
378c1a075e
commit
a7d6287d65
@ -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 файлов · до 20 МБ каждый</p>
|
<p class="description mt-2 text-sm dark:text-gray-500 text-gray-400 font-mono">до 50 файлов · до 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');
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user