# 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'], }; 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:` 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`: 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.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`: 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.