docs(video): add design spec for video conversion feature

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 15:23:49 +02:00
co-authored by Claude Sonnet 5
parent 3775d34a22
commit 69b3de8031
@@ -0,0 +1,143 @@
# 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:<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:
```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.