wa-dev-tools/routes/video.js

235 lines
8.4 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);
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;