Files
convert/docs/superpowers/specs/2026-08-01-video-conversion-design.md
T

144 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.