diff --git a/public/shared.js b/public/shared.js
index 9e4e3ec..bf7760c 100644
--- a/public/shared.js
+++ b/public/shared.js
@@ -23,7 +23,7 @@ if (typeof tailwind !== 'undefined') {
/* Categories */
const WA_CATEGORIES = [
- { id: 'images', title: 'Изображения', description: 'Сжатие, редактирование и генерация', icon: '
', tools: ['compress', 'placeholder', 'svgeditor', 'editor'] },
+ { id: 'images', title: 'Изображения', description: 'Сжатие, редактирование и генерация', icon: '
', tools: ['compress', 'placeholder', 'svgeditor', 'editor', 'video'] },
{ id: 'code', title: 'Код', description: 'Форматирование, очистка и конвертация', icon: '
', tools: ['formatter', 'sanitizer', 'converter'] },
{ id: 'web', title: 'Веб', description: 'HTTP-клиент, парсер и анализ', icon: '
', tools: ['parser', 'httpclient', 'redirects'] },
{ id: 'utils', title: 'Утилиты', description: 'Пароли, Markdown и другие', icon: '
', tools: ['password', 'md'] },
@@ -37,6 +37,7 @@ const WA_TOOLS = [
{ id: 'placeholder', path: '/placeholder', title: 'Placeholder', category: 'images', icon: '
' },
{ id: 'svgeditor', path: '/svgeditor', title: 'SVG', category: 'images', icon: '
' },
{ id: 'editor', path: '/editor', title: 'Фото', category: 'images', icon: '
' },
+ { id: 'video', path: '/video', title: 'Видео', category: 'images', icon: '
' },
// Код
{ id: 'formatter', path: '/formatter', title: 'Formatter', category: 'code', icon: '
' },
{ id: 'sanitizer', path: '/sanitizer', title: 'Sanitizer', category: 'code', icon: '
' },
diff --git a/public/video.html b/public/video.html
new file mode 100644
index 0000000..f21a6a5
--- /dev/null
+++ b/public/video.html
@@ -0,0 +1,1082 @@
+
+
+
+
+
+
WA Dev Tools — Видео конвертер
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/routes/video.js b/routes/video.js
new file mode 100644
index 0000000..a14567c
--- /dev/null
+++ b/routes/video.js
@@ -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;
diff --git a/server.js b/server.js
index f5a7bc8..18e1db3 100755
--- a/server.js
+++ b/server.js
@@ -61,6 +61,7 @@ app.use('/placeholder-img', require('./routes/placeholder'));
app.use(require('./routes/httpclient'));
app.use(require('./routes/redirects'));
app.use(require('./routes/svgeditor'));
+app.use('/video', require('./routes/video'));
app.use(require('./routes/logs'));
app.use(require('./routes/pages'));