Add video converter: convert, compress, extract audio, GIF

- New tool: /video with 4 modes (convert formats, compress, extract audio, GIF)
- Backend: ffmpeg-based processing with progress tracking
- Supports MP4, WebM, MOV, AVI, MKV input, up to 200MB
- Frontend: drag-drop upload, mode tabs, progress bar, download
- Added to sidebar, dashboard (Images category), WA_CATEGORIES
- Total tools: 14
This commit is contained in:
treamz 2026-03-21 23:28:11 +03:00
parent d9202d2821
commit 44c85f6e44
5 changed files with 1328 additions and 5 deletions

View File

@ -65,10 +65,10 @@
<svg width="18" height="18" 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> <svg width="18" height="18" 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>
<span class="cat-title">Изображения</span> <span class="cat-title">Изображения</span>
<span class="cat-count">4</span> <span class="cat-count">5</span>
<span class="cat-desc">сжатие, редактирование и генерация</span> <span class="cat-desc">сжатие, видео, редактирование и генерация</span>
</div> </div>
<div class="grid grid-cols-2 sm:grid-cols-4 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">JPEG, PNG, WebP до 20МБ</div></div>
@ -85,6 +85,10 @@
<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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"/></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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"/></svg></div>
<div><div class="tile-title">Фоторедактор</div><div class="tile-desc">Обработка фото в браузере</div></div> <div><div class="tile-title">Фоторедактор</div><div class="tile-desc">Обработка фото в браузере</div></div>
</a> </a>
<a href="/video" 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="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/></svg></div>
<div><div class="tile-title">Видео</div><div class="tile-desc">Конвертация, сжатие, GIF</div></div>
</a>
</div> </div>
<!-- Category: Code --> <!-- Category: Code -->
@ -166,7 +170,7 @@
</p> </p>
<div class="about-stats"> <div class="about-stats">
<div class="about-stat"> <div class="about-stat">
<div class="num">13</div> <div class="num">14</div>
<div class="label">инструментов</div> <div class="label">инструментов</div>
</div> </div>
<div class="about-stat"> <div class="about-stat">

View File

@ -23,7 +23,7 @@ if (typeof tailwind !== 'undefined') {
/* Categories */ /* Categories */
const WA_CATEGORIES = [ const WA_CATEGORIES = [
{ id: 'images', title: 'Изображения', description: 'Сжатие, редактирование и генерация', icon: '<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"/>', tools: ['compress', 'placeholder', 'svgeditor', 'editor'] }, { id: 'images', title: 'Изображения', description: 'Сжатие, редактирование и генерация', icon: '<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"/>', tools: ['compress', 'placeholder', 'svgeditor', 'editor', 'video'] },
{ id: 'code', title: 'Код', description: 'Форматирование, очистка и конвертация', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/>', tools: ['formatter', 'sanitizer', 'converter'] }, { id: 'code', title: 'Код', description: 'Форматирование, очистка и конвертация', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/>', tools: ['formatter', 'sanitizer', 'converter'] },
{ id: 'web', title: 'Веб', description: 'HTTP-клиент, парсер и анализ', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"/>', tools: ['parser', 'httpclient', 'redirects'] }, { id: 'web', title: 'Веб', description: 'HTTP-клиент, парсер и анализ', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"/>', tools: ['parser', 'httpclient', 'redirects'] },
{ id: 'utils', title: 'Утилиты', description: 'Пароли, Markdown и другие', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.645 3.232a.75.75 0 01-1.09-.791l1.078-6.285L1.347 7.06a.75.75 0 01.416-1.28l6.31-.917L10.89.598a.75.75 0 011.341 0l2.816 5.266 6.31.917a.75.75 0 01.416 1.28l-4.416 4.266 1.078 6.285a.75.75 0 01-1.09.791L12 15.17z"/>', tools: ['password', 'md'] }, { id: 'utils', title: 'Утилиты', description: 'Пароли, Markdown и другие', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.645 3.232a.75.75 0 01-1.09-.791l1.078-6.285L1.347 7.06a.75.75 0 01.416-1.28l6.31-.917L10.89.598a.75.75 0 011.341 0l2.816 5.266 6.31.917a.75.75 0 01.416 1.28l-4.416 4.266 1.078 6.285a.75.75 0 01-1.09.791L12 15.17z"/>', tools: ['password', 'md'] },
@ -37,6 +37,7 @@ const WA_TOOLS = [
{ id: 'placeholder', path: '/placeholder', title: 'Placeholder', category: 'images', icon: '<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"/>' }, { id: 'placeholder', path: '/placeholder', title: 'Placeholder', category: 'images', icon: '<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"/>' },
{ id: 'svgeditor', path: '/svgeditor', title: 'SVG', category: 'images', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42"/>' }, { id: 'svgeditor', path: '/svgeditor', title: 'SVG', category: 'images', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42"/>' },
{ id: 'editor', path: '/editor', title: 'Фото', category: 'images', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"/>' }, { id: 'editor', path: '/editor', title: 'Фото', category: 'images', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"/>' },
{ id: 'video', path: '/video', title: 'Видео', category: 'images', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/>' },
// Код // Код
{ id: 'formatter', path: '/formatter', title: 'Formatter', category: 'code', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/>' }, { id: 'formatter', path: '/formatter', title: 'Formatter', category: 'code', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/>' },
{ id: 'sanitizer', path: '/sanitizer', title: 'Sanitizer', category: 'code', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/>' }, { id: 'sanitizer', path: '/sanitizer', title: 'Sanitizer', category: 'code', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/>' },

1082
public/video.html Normal file

File diff suppressed because it is too large Load Diff

235
routes/video.js Normal file
View File

@ -0,0 +1,235 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const rateLimit = require('express-rate-limit');
const log = require('../lib/logger');
const router = express.Router();
const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
const DOWNLOADS_DIR = path.join(__dirname, '..', 'downloads');
const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB
const upload = multer({
dest: UPLOADS_DIR,
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (req, file, cb) => {
const valid = ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska', 'video/mpeg', 'video/3gpp', 'video/ogg'];
if (valid.includes(file.mimetype) || file.originalname.match(/\.(mp4|webm|mov|avi|mkv|mpeg|mpg|3gp|ogg|flv|wmv)$/i)) {
cb(null, true);
} else {
cb(new Error('Неподдерживаемый формат видео'));
}
},
});
const limiter = rateLimit({ windowMs: 60000, max: 10, message: { error: 'Слишком много запросов' } });
// Active jobs tracking
const jobs = new Map();
function runFFmpeg(args, jobId) {
return new Promise((resolve, reject) => {
const proc = spawn('ffmpeg', args, { timeout: 600000 }); // 10 min timeout
let stderr = '';
proc.stderr.on('data', (d) => {
stderr += d.toString();
// Parse progress
const timeMatch = stderr.match(/time=(\d{2}):(\d{2}):(\d{2})/);
if (timeMatch && jobs.has(jobId)) {
const secs = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]);
const job = jobs.get(jobId);
if (job.duration > 0) {
job.progress = Math.min(99, Math.round((secs / job.duration) * 100));
}
}
});
proc.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`ffmpeg exited with code ${code}: ${stderr.slice(-500)}`));
});
proc.on('error', reject);
});
}
// Get video duration
function getVideoDuration(filePath) {
return new Promise((resolve) => {
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath]);
let out = '';
proc.stdout.on('data', (d) => { out += d.toString(); });
proc.on('close', () => {
try {
const info = JSON.parse(out);
const duration = parseFloat(info.format?.duration || '0');
const streams = info.streams || [];
const video = streams.find(s => s.codec_type === 'video');
const audio = streams.find(s => s.codec_type === 'audio');
resolve({
duration,
width: video ? parseInt(video.width) : 0,
height: video ? parseInt(video.height) : 0,
codec: video ? video.codec_name : '',
audioCodec: audio ? audio.codec_name : '',
bitrate: info.format?.bit_rate ? Math.round(parseInt(info.format.bit_rate) / 1024) : 0,
});
} catch {
resolve({ duration: 0, width: 0, height: 0, codec: '', audioCodec: '', bitrate: 0 });
}
});
});
}
// Page
router.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'video.html'));
});
// Upload and get info
router.post('/upload', limiter, upload.single('video'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
try {
const info = await getVideoDuration(req.file.path);
const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
jobs.set(jobId, {
inputPath: req.file.path,
originalName: req.file.originalname,
size: req.file.size,
duration: info.duration,
progress: 0,
status: 'ready',
outputPath: null,
info,
});
// Cleanup job after 30 min
setTimeout(() => {
const job = jobs.get(jobId);
if (job) {
try { fs.existsSync(job.inputPath) && fs.unlinkSync(job.inputPath); } catch {}
try { job.outputPath && fs.existsSync(job.outputPath) && fs.unlinkSync(job.outputPath); } catch {}
jobs.delete(jobId);
}
}, 30 * 60 * 1000);
log.info(`Video upload: ${req.file.originalname} (${(req.file.size / 1024 / 1024).toFixed(1)}MB, ${info.duration.toFixed(1)}s)`);
res.json({ jobId, info, originalName: req.file.originalname, size: req.file.size });
} catch (err) {
try { fs.unlinkSync(req.file.path); } catch {}
res.status(500).json({ error: err.message });
}
});
// Convert
router.post('/convert', express.json(), async (req, res) => {
const { jobId, mode, format, quality, startTime, endTime } = req.body;
const job = jobs.get(jobId);
if (!job) return res.status(404).json({ error: 'Задача не найдена' });
if (job.status === 'processing') return res.status(409).json({ error: 'Уже обрабатывается' });
job.status = 'processing';
job.progress = 0;
const ext = { mp4: '.mp4', webm: '.webm', avi: '.avi', mkv: '.mkv', mp3: '.mp3', aac: '.aac', gif: '.gif' };
const outExt = ext[format] || '.mp4';
const outFile = path.join(DOWNLOADS_DIR, `${jobId}${outExt}`);
job.outputPath = outFile;
try {
let args = ['-y', '-i', job.inputPath];
// Time range (for GIF or trim)
if (startTime !== undefined && startTime > 0) args.push('-ss', String(startTime));
if (endTime !== undefined && endTime > 0) args.push('-to', String(endTime));
switch (mode) {
case 'convert': {
// Format conversion
if (format === 'mp4') args.push('-c:v', 'libx264', '-c:a', 'aac', '-movflags', '+faststart');
else if (format === 'webm') args.push('-c:v', 'libvpx-vp9', '-c:a', 'libopus', '-b:v', '1M');
else if (format === 'avi') args.push('-c:v', 'mpeg4', '-c:a', 'mp3');
else if (format === 'mkv') args.push('-c:v', 'libx264', '-c:a', 'aac');
break;
}
case 'compress': {
// Quality: 18 (high) to 35 (low)
const crf = quality === 'high' ? 20 : quality === 'low' ? 32 : 26;
args.push('-c:v', 'libx264', '-crf', String(crf), '-preset', 'medium', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart');
break;
}
case 'audio': {
// Extract audio
args.push('-vn');
if (format === 'mp3') args.push('-c:a', 'libmp3lame', '-q:a', '2');
else args.push('-c:a', 'aac', '-b:a', '192k');
break;
}
case 'gif': {
// GIF from video
const w = Math.min(job.info.width || 480, 480);
args.push('-vf', `scale=${w}:-1:flags=lanczos,fps=12`, '-loop', '0');
break;
}
default:
job.status = 'error';
return res.status(400).json({ error: 'Неизвестный режим' });
}
args.push(outFile);
await runFFmpeg(args, jobId);
job.status = 'done';
job.progress = 100;
const outStat = fs.statSync(outFile);
const savings = job.size > 0 ? Math.round((1 - outStat.size / job.size) * 100) : 0;
log.info(`Video ${mode}: ${job.originalName}${format} (${(outStat.size / 1024 / 1024).toFixed(1)}MB, ${savings}% saved)`);
res.json({
status: 'done',
downloadUrl: `/video/download/${jobId}${outExt}`,
outputSize: outStat.size,
savings,
});
} catch (err) {
job.status = 'error';
log.error('Video convert error', { error: err.message });
res.status(500).json({ error: 'Ошибка конвертации: ' + err.message.slice(0, 200) });
}
});
// Progress
router.get('/progress/:jobId', (req, res) => {
const job = jobs.get(req.params.jobId);
if (!job) return res.status(404).json({ error: 'Not found' });
res.json({ status: job.status, progress: job.progress });
});
// Download result
router.get('/download/:filename', (req, res) => {
const filename = path.basename(req.params.filename);
const filePath = path.join(DOWNLOADS_DIR, filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
res.download(filePath);
});
// Multer error handler
router.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') return res.status(413).json({ error: 'Файл слишком большой. Максимум 200MB.' });
return res.status(400).json({ error: err.message });
}
if (err) return res.status(400).json({ error: err.message });
next();
});
module.exports = router;

View File

@ -61,6 +61,7 @@ app.use('/placeholder-img', require('./routes/placeholder'));
app.use(require('./routes/httpclient')); app.use(require('./routes/httpclient'));
app.use(require('./routes/redirects')); app.use(require('./routes/redirects'));
app.use(require('./routes/svgeditor')); app.use(require('./routes/svgeditor'));
app.use('/video', require('./routes/video'));
app.use(require('./routes/logs')); app.use(require('./routes/logs'));
app.use(require('./routes/pages')); app.use(require('./routes/pages'));