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>
164 lines
11 KiB
Markdown
164 lines
11 KiB
Markdown
# 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:<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`: 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:<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`):
|
||
```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.
|