docs(video): add design spec for video conversion feature
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||||
Reference in New Issue
Block a user