Files
convert/docs/superpowers/specs/2026-08-01-video-conversion-design.md
T
anthonyandClaude Sonnet 5 728247a2cf docs(video): refine spec with verified file-type detection and simplified converter
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>
2026-08-01 21:51:50 +02:00

164 lines
11 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'],
};
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.