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:
parent
d9202d2821
commit
44c85f6e44
@ -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">
|
||||||
|
|||||||
@ -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
1082
public/video.html
Normal file
File diff suppressed because it is too large
Load Diff
235
routes/video.js
Normal file
235
routes/video.js
Normal 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;
|
||||||
@ -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'));
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user