- /convert now returns immediately, ffmpeg runs in background - Client polls /progress every second for live progress % - Progress endpoint returns downloadUrl/outputSize when done - UI shows real-time percentage during conversion
245 lines
8.9 KiB
JavaScript
245 lines
8.9 KiB
JavaScript
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 (software encoders — mpeg4 for mp4/avi/mkv)
|
|
if (format === 'mp4') args.push('-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'aac', '-movflags', '+faststart');
|
|
else if (format === 'webm') args.push('-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'aac', '-f', 'avi'); // webm fallback to avi
|
|
else if (format === 'avi') args.push('-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'aac');
|
|
else if (format === 'mkv') args.push('-c:v', 'mpeg4', '-q:v', '5', '-c:a', 'aac');
|
|
break;
|
|
}
|
|
case 'compress': {
|
|
// Quality via mpeg4 q:v (1=best, 31=worst)
|
|
const qv = quality === 'high' ? '3' : quality === 'low' ? '15' : '8';
|
|
args.push('-c:v', 'mpeg4', '-q:v', qv, '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart');
|
|
break;
|
|
}
|
|
case 'audio': {
|
|
args.push('-vn');
|
|
if (format === 'mp3') args.push('-c:a', 'aac', '-b:a', '192k');
|
|
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);
|
|
|
|
// Start ffmpeg in background — respond immediately
|
|
res.json({ status: 'processing' });
|
|
|
|
runFFmpeg(args, jobId).then(() => {
|
|
job.status = 'done';
|
|
job.progress = 100;
|
|
const outStat = fs.statSync(outFile);
|
|
job.outputSize = outStat.size;
|
|
job.savings = job.size > 0 ? Math.round((1 - outStat.size / job.size) * 100) : 0;
|
|
job.downloadUrl = `/video/download/${jobId}${outExt}`;
|
|
log.info(`Video ${mode}: ${job.originalName} → ${format} (${(outStat.size / 1024 / 1024).toFixed(1)}MB, ${job.savings}% saved)`);
|
|
}).catch(err => {
|
|
job.status = 'error';
|
|
job.error = err.message.slice(0, 200);
|
|
log.error('Video convert error', { error: err.message });
|
|
});
|
|
} catch (err) {
|
|
job.status = 'error';
|
|
log.error('Video convert error', { error: err.message });
|
|
res.status(500).json({ error: 'Ошибка конвертации: ' + err.message.slice(0, 200) });
|
|
}
|
|
});
|
|
|
|
// Progress (returns downloadUrl when done)
|
|
router.get('/progress/:jobId', (req, res) => {
|
|
const job = jobs.get(req.params.jobId);
|
|
if (!job) return res.status(404).json({ error: 'Not found' });
|
|
const result = { status: job.status, progress: job.progress };
|
|
if (job.status === 'done') {
|
|
result.downloadUrl = job.downloadUrl;
|
|
result.outputSize = job.outputSize;
|
|
result.savings = job.savings;
|
|
}
|
|
if (job.status === 'error') {
|
|
result.error = job.error || 'Ошибка обработки';
|
|
}
|
|
res.json(result);
|
|
});
|
|
|
|
// 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;
|