Verified exact file-type v22 magic-byte detection for all 5 formats against real fixture buffers (no normalizeFormat aliases needed), and simplified the converter's resolution-scaling gate to match audio.js's validation-boundary pattern (app.js range-checks, converter trusts the value). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11 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'],
};
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) {
args.push('-vf', `scale=-2:${quality}`);
}
args.push(outputPath);
await execFileAsync(ffmpegPath, args, { timeout: timeoutMs });
},
});
}
}
}
This mirrors audio.js's overall shape (fixed per-target codec map, a flat register loop, quality passed straight through from the job row with no extra config threading), with one simplification: audio.js gates its -b:a push on BITRATE_CAPABLE_FORMATS.includes(targetFormat) because bitrate doesn't apply to lossless targets (wav, flac); resolution scaling applies uniformly to every video target, so no analogous per-target gate is needed here — quality != null alone is sufficient, since app.js's isValidQuality is what restricts the actual value set (480/720/1080) before the converter ever runs, same division of responsibility the audio family already uses (the converter trusts the value; app.js is the only place that range-checks it).
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: verified directly against the installed file-type v22 source (source/index.js, source/detectors/ebml.js) and confirmed with hand-crafted minimal fixture buffers run through fileTypeFromBuffer:
| format | detection | ext returned |
mime returned |
|---|---|---|---|
mp4 |
ftyp box at offset 4 (ISO base media) |
mp4 |
video/mp4 |
mov |
free/mdat/moov/wide atom at offset 4, or ftyp with brand qt |
mov |
video/quicktime |
avi |
RIFF + AVI at offset 8 |
avi |
video/vnd.avi |
webm |
EBML DocType webm |
webm |
video/webm |
mkv |
EBML DocType matroska |
mkv |
video/matroska |
All five ext values are identical to their declared format name, so no normalizeFormat alias is needed for any of them (unlike tar.gz/tar.bz2/tar.7z in the archive family). Note file-type's detected mime for avi (video/vnd.avi) and mkv (video/matroska) differ from this feature's own OUTPUT_MIME_TYPES values (video/x-msvideo, video/x-matroska) — this is not a bug: the detected mime is only ever stored as inputMimeType (informational), while OUTPUT_MIME_TYPES independently drives the download Content-Type header; the two are never compared to each other anywhere in the codebase (confirmed by reading app.js/worker.js/mime.js — resolveInputFormat's valid check compares ext, never mime).
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 — confirmed by reading that file: it mocks node:child_process's execFile entirely (vi.mock('node:child_process', ...)) and asserts on the exact args array passed, rather than invoking a real ffmpeg binary or using real media fixtures. ffmpeg is not installed on this dev machine (confirmed: ffmpeg -version → command not found), so this mocked approach is also the only one that works locally, not just the established convention. Tests: registration (all 20 pairs registered, all 5 same-format pairs return null via resolve), codec args per target (one assertion per row of the codec table), -vf scale=-2:<height> appended only when quality is one of 480/720/1080 and omitted otherwise, FFMPEG_PATH env var respected, timeoutMs forwarded, and a rejected execFile callback propagating as a rejected promise.
test/mime.test.js: cases for the 5 new OUTPUT_MIME_TYPES entries, plus resolveInputFormat cases for all 5 formats using hand-crafted minimal magic-byte buffers (verified against the installed file-type v22 via a throwaway script — exact bytes, written directly in the test with fs.writeFile then deleted, same pattern the existing mime.test.js already uses for .txt/.md):
// mp4: [box size][ftyp][isom]
Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftyp'), Buffer.from('isom')])
// mov: [box size][moov]
Buffer.concat([Buffer.from([0, 0, 0, 0x08]), Buffer.from('moov')])
// avi: RIFF + size(4, arbitrary) + 'AVI '
Buffer.concat([Buffer.from('RIFF'), Buffer.from([0, 0, 0, 0]), Buffer.from('AVI ')])
// webm: EBML id(4) + len(1)=0x81 + DocType id(2)=0x42,0x82 + len(1)=0x84 + 'webm'
Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x81, 0x42, 0x82, 0x84]), Buffer.from('webm')])
// mkv: same but DocType payload 'matroska' (8 bytes), len byte 0x88
Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x81, 0x42, 0x82, 0x88]), Buffer.from('matroska')])
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.