8.6 KiB
Video conversion (mp4, webm, mov, avi, mkv)
Date: 2026-08-01
Goal
Add a new video family: cross conversion between mp4, webm, mov, avi, mkv, all backed by ffmpeg (FFMPEG_PATH env var, already configured on o2switch and already used by src/converters/audio.js). Same-format pairs (mp4 -> mp4, etc.) are not registered — no resize-in-place use case was requested, so this follows the audio/image/document/font/ebook precedent of rejecting sourceFormat === targetFormat, unlike the archive family's deliberate exception.
Net registration: 5 formats × 4 other targets each = 20 pairs, family 'video'.
Video → audio-only or video → GIF conversions are explicitly out of scope for this feature — those are cross-family conversions with their own quality semantics and were not requested.
Architecture: fixed codec-per-target map, same shape as audio.js
src/converters/video.js:
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { register } from './registry.js';
const execFileAsync = promisify(execFile);
export const VIDEO_FORMATS = ['mp4', 'webm', 'mov', 'avi', 'mkv'];
const CODEC_ARGS = {
mp4: ['-c:v', 'libx264', '-c:a', 'aac'],
mov: ['-c:v', 'libx264', '-c:a', 'aac'],
mkv: ['-c:v', 'libx264', '-c:a', 'aac'],
webm: ['-c:v', 'libvpx-vp9', '-c:a', 'libopus'],
avi: ['-c:v', 'mpeg4', '-c:a', 'libmp3lame'],
};
const RESOLUTION_HEIGHTS = [480, 720, 1080];
export function registerVideoConverters() {
for (const sourceFormat of VIDEO_FORMATS) {
for (const targetFormat of VIDEO_FORMATS) {
if (sourceFormat === targetFormat) continue;
register({
family: 'video',
sourceFormat,
targetFormat,
convert: async (inputPath, outputPath, { quality, timeoutMs } = {}) => {
const ffmpegPath = process.env.FFMPEG_PATH || 'ffmpeg';
const args = ['-y', '-i', inputPath, ...CODEC_ARGS[targetFormat]];
if (quality != null && RESOLUTION_HEIGHTS.includes(quality)) {
args.push('-vf', `scale=-2:${quality}`);
}
args.push(outputPath);
await execFileAsync(ffmpegPath, args, { timeout: timeoutMs });
},
});
}
}
}
This mirrors audio.js exactly: a fixed per-target codec map, a flat register loop, quality passed straight through from the job row with no extra config threading.
Codec choice per target container (fixed, no user choice — confirmed with the user)
| target | video codec | audio codec |
|---|---|---|
mp4 |
libx264 |
aac |
mov |
libx264 |
aac |
mkv |
libx264 |
aac |
webm |
libvpx-vp9 |
libopus |
avi |
mpeg4 |
libmp3lame |
All five are standard ffmpeg built-in encoders (no separate binary/license concern beyond ffmpeg itself, which is already deployed via FFMPEG_PATH).
Resolution control: reuse quality, three fixed heights + "original"
No schema change — ConversionJob.quality (Int?) already exists and already flows app.js → createJob → worker.js → entry.convert(..., { quality }) unchanged, same as every other family's per-format quality reuse (PNG compression level, archive compression level, audio bitrate).
Allowed values: 480, 720, 1080, or null/omitted (original resolution, no -vf scale argument added — passthrough).
-vf scale=-2:<height> scales to the requested height while computing width automatically (-2 forces an even width, required by most video codecs including libx264/vp9).
isValidQuality(targetFormat, quality) in src/app.js gains:
if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(targetFormat)) return [480, 720, 1080].includes(quality);
placed alongside the existing format-specific branches. The existing early return (if (quality === null || quality === undefined) return true;) already covers the "original" case — no extra branch needed for that.
Timeout: new VIDEO_JOB_TIMEOUT_MS, default 300000 (5 min)
Video transcoding is slower than every other conversion family currently handled by the default 60s (JOB_TIMEOUT_MS in worker.js). Follows the exact precedent of ebookJobTimeoutMs:
src/config.js:
videoJobTimeoutMs: Number(process.env.VIDEO_JOB_TIMEOUT_MS ?? 300000),
src/worker.js, processJob:
const timeoutMs =
entry.family === 'ebook' ? config.ebookJobTimeoutMs :
entry.family === 'video' ? config.videoJobTimeoutMs :
JOB_TIMEOUT_MS;
No change to MAX_FILE_SIZE_MB — it stays a single global upload-size limit for all file types, as today; the user can raise it via .env/.env.local if needed, with no video-specific override.
src/mime.js
OUTPUT_MIME_TYPES additions:
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
mkv: 'video/x-matroska',
resolveInputFormat/normalizeFormat: relies on file-type's existing magic-byte sniffing (no UNDETECTABLE_* fallback expected to be needed, since all five containers have well-defined signatures). The exact ext string file-type v22 returns for .mov and .mkv inputs is not yet independently verified against real fixtures — flagging this explicitly (same as the archive spec did for its own file-type behavior) so the implementation plan verifies it with real sample files during TDD, adding a normalizeFormat alias only if a mismatch is found (e.g. if mov sniffs as qt rather than mov).
Registration wiring
src/app.js and src/worker.js: add import { registerVideoConverters } from './converters/video.js' and one call inside registerAllConverters() / main(), alongside the existing eight (image, imageToPdf, document, ico, heic, font, dfont, ebook, archive, audio).
Frontend
frontend/src/data/formats.js: new entry{ key: 'video', formats: ['mp4', 'webm', 'mov', 'avi', 'mkv'] }.frontend/src/utils/fileFamily.jsandfrontend/src/components/FormatsGrid.jsx(both maintain their own duplicatedFAMILY_ICONSmap, confirmed by reading both files): addvideo: VideoCamerausing theVideoCameraicon from@phosphor-icons/react(already a dependency).frontend/src/index.css: new--color-family-videoCSS var in both the light and dark variable blocks (alongside the existing--color-family-audioetc.), a distinct color not already used by another family.frontend/src/components/FileConfigCard.jsx: new constantVIDEO_RESOLUTION_FORMATS = ['mp4', 'webm', 'mov', 'avi', 'mkv']and a chip-group control (same pattern asAUDIO_BITRATE_FORMATS), options480p,720p,1080p, andOriginal(mapping toquality: null).frontend/src/pages/HomePage.jsx: noDEFAULT_QUALITYentry needed for video targets — default is "Original" (quality: null), which is alreadydefaultQualityFor's fallback behavior when a format has no entry inDEFAULT_QUALITY.frontend/src/components/FormatMarquee.jsx: add one new pair toPAIRS, e.g.['mov', 'mp4'].frontend/src/locales/en.json/fr.json: addformats.video("Video" / "Vidéo") andquality.resolution("Resolution" / "Résolution") keys.
No DB/schema changes anywhere in this feature.
Testing
test/converters/video.test.js, same convention as test/converters/audio.test.js: small real video fixtures (need to source or generate tiny sample clips — e.g. generate a few-frame test clip with ffmpeg itself as a fixture-creation step, since there's no existing video fixture in the repo). One representative conversion test per target codec path (mp4 -> webm, mp4 -> avi, webm -> mkv, etc. — not all 20 pairs), plus a resolution-scaling test asserting the output's height matches the requested 480/720/1080 value (via ffprobe or a lightweight video-metadata read), plus a same-format-pair rejection test (resolve('mp4', 'mp4') returns null).
test/mime.test.js: cases for the 5 new MIME types, plus resolveInputFormat cases for .mov/.mkv fixtures (the two formats whose file-type ext output isn't yet independently confirmed).
test/api/jobs.test.js: valid resolution values accepted, out-of-range resolution values rejected, family: 'video' recorded on the created job.
Frontend: manual browser check per CLAUDE.md's existing convention (no automated frontend test runner) — upload an .mp4, confirm target chips show WEBM/MOV/AVI/MKV (not MP4), confirm the resolution chip group (480p/720p/1080p/Original) appears, confirm the "Video" tile appears on the homepage formats grid with its own icon/color. Real end-to-end conversion requires the backend server and worker restarted to pick up the new converter code, per CLAUDE.md's stale-worker caveat.