Backend: - Increase ffmpeg/ffprobe timeouts from 15s to 60s for large videos - Classify processing errors (timeout, OOM, corrupt) with user-facing messages - Emit failure details via media:job:update socket event (error field) - Bump refboard container memory 512M → 1G Frontend: - Client-side file size validation (200MB) before upload starts - Oversized files show immediate error in upload manager, skip upload - Handle media:job:update status='failed' — surface error in upload panel - Add processingFailed() to UploadManager for video processing errors
140 lines
4.1 KiB
JavaScript
140 lines
4.1 KiB
JavaScript
/**
|
|
* video-utils.js — Extract poster frame and metadata from video buffers using ffmpeg.
|
|
*
|
|
* Called at upload time so every video gets a poster image and cached metadata.
|
|
* The board never needs to create a <video> element just to discover dimensions
|
|
* or capture a first frame.
|
|
*/
|
|
|
|
const { execFile } = require('child_process');
|
|
const { writeFileSync, readFileSync, unlinkSync, mkdtempSync } = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
|
|
/**
|
|
* Extract video metadata (dimensions, duration, hasAudio) using ffprobe.
|
|
* Returns { width, height, duration, hasAudio } or null on failure.
|
|
*/
|
|
function probeVideo(buffer) {
|
|
return new Promise((resolve) => {
|
|
let tmpDir, tmpFile;
|
|
try {
|
|
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'refboard-vid-'));
|
|
tmpFile = path.join(tmpDir, 'input.vid');
|
|
writeFileSync(tmpFile, buffer);
|
|
} catch (err) {
|
|
console.warn('[video-utils] Failed to write temp file:', err.message);
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
execFile('ffprobe', [
|
|
'-v', 'quiet',
|
|
'-print_format', 'json',
|
|
'-show_format',
|
|
'-show_streams',
|
|
tmpFile,
|
|
], { timeout: 60000 }, (err, stdout) => {
|
|
cleanup(tmpFile, tmpDir);
|
|
if (err) {
|
|
console.warn('[video-utils] ffprobe failed:', err.message);
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const info = JSON.parse(stdout);
|
|
const videoStream = (info.streams || []).find(s => s.codec_type === 'video');
|
|
const audioStream = (info.streams || []).find(s => s.codec_type === 'audio');
|
|
|
|
if (!videoStream) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
resolve({
|
|
width: videoStream.width || null,
|
|
height: videoStream.height || null,
|
|
duration: info.format?.duration ? parseFloat(info.format.duration) : null,
|
|
hasAudio: !!audioStream,
|
|
});
|
|
} catch (parseErr) {
|
|
console.warn('[video-utils] ffprobe parse failed:', parseErr.message);
|
|
resolve(null);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Extract a poster frame (JPEG) from a video buffer.
|
|
* Returns a Buffer containing JPEG data, or null on failure.
|
|
*/
|
|
function extractPoster(buffer) {
|
|
return new Promise((resolve) => {
|
|
let tmpDir, tmpInput, tmpOutput;
|
|
try {
|
|
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'refboard-poster-'));
|
|
tmpInput = path.join(tmpDir, 'input.vid');
|
|
tmpOutput = path.join(tmpDir, 'poster.jpg');
|
|
writeFileSync(tmpInput, buffer);
|
|
} catch (err) {
|
|
console.warn('[video-utils] Failed to write temp file:', err.message);
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
execFile('ffmpeg', [
|
|
'-i', tmpInput,
|
|
'-vframes', '1', // single frame
|
|
'-ss', '0.1', // skip 0.1s (avoid black leader)
|
|
'-q:v', '3', // JPEG quality (2=best, 31=worst)
|
|
'-y', // overwrite
|
|
tmpOutput,
|
|
], { timeout: 60000 }, (err) => {
|
|
if (err) {
|
|
console.warn('[video-utils] ffmpeg poster extraction failed:', err.message);
|
|
cleanup(tmpInput, tmpDir);
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const posterBuffer = readFileSync(tmpOutput);
|
|
cleanup(tmpInput, tmpDir, tmpOutput);
|
|
resolve(posterBuffer);
|
|
} catch (readErr) {
|
|
console.warn('[video-utils] Failed to read poster:', readErr.message);
|
|
cleanup(tmpInput, tmpDir, tmpOutput);
|
|
resolve(null);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clean up temp files and directories.
|
|
* Pass file paths first, then the temp directory last.
|
|
* Files are unlinked, then the directory is removed.
|
|
*/
|
|
function cleanup(...paths) {
|
|
const dirs = [];
|
|
// First pass: unlink files, collect directories
|
|
for (const p of paths) {
|
|
try {
|
|
const stat = require('fs').statSync(p);
|
|
if (stat.isDirectory()) {
|
|
dirs.push(p);
|
|
} else {
|
|
unlinkSync(p);
|
|
}
|
|
} catch { /* already gone */ }
|
|
}
|
|
// Second pass: remove directories (now empty)
|
|
for (const d of dirs) {
|
|
try { require('fs').rmdirSync(d); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
module.exports = { probeVideo, extractPoster };
|