# 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`: ```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:` 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: ```js 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`: ```js videoJobTimeoutMs: Number(process.env.VIDEO_JOB_TIMEOUT_MS ?? 300000), ``` `src/worker.js`, `processJob`: ```js 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: ```js 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.js` and `frontend/src/components/FormatsGrid.jsx` (both maintain their own duplicated `FAMILY_ICONS` map, confirmed by reading both files): add `video: VideoCamera` using the `VideoCamera` icon from `@phosphor-icons/react` (already a dependency). - `frontend/src/index.css`: new `--color-family-video` CSS var in both the light and dark variable blocks (alongside the existing `--color-family-audio` etc.), a distinct color not already used by another family. - `frontend/src/components/FileConfigCard.jsx`: new constant `VIDEO_RESOLUTION_FORMATS = ['mp4', 'webm', 'mov', 'avi', 'mkv']` and a chip-group control (same pattern as `AUDIO_BITRATE_FORMATS`), options `480p`, `720p`, `1080p`, and `Original` (mapping to `quality: null`). - `frontend/src/pages/HomePage.jsx`: no `DEFAULT_QUALITY` entry needed for video targets — default is "Original" (`quality: null`), which is already `defaultQualityFor`'s fallback behavior when a format has no entry in `DEFAULT_QUALITY`. - `frontend/src/components/FormatMarquee.jsx`: add one new pair to `PAIRS`, e.g. `['mov', 'mp4']`. - `frontend/src/locales/en.json` / `fr.json`: add `formats.video` ("Video" / "Vidéo") and `quality.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:` 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`): ```js // 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.