Files
convert/docs/superpowers/plans/2026-08-01-video-conversion.md

822 lines
32 KiB
Markdown

# Video Conversion Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a new `video` converter family supporting `mp4`, `webm`, `mov`, `avi`, `mkv` (all pairs except same-format), each with an optional resolution control (`480`/`720`/`1080`/original) reusing the existing `quality` job field, ffmpeg-backed via the existing `FFMPEG_PATH` env var.
**Architecture:** One flat converter in `src/converters/video.js`, same shape as `src/converters/audio.js`: a fixed `CODEC_ARGS` map keyed by target format, registered as the full cross product (minus same-format pairs) via the existing `registry.js`. A new `VIDEO_JOB_TIMEOUT_MS` config value (default 300000ms) gives video jobs a longer worker timeout than the 60s default, matching the precedent `EBOOK_JOB_TIMEOUT_MS` already set.
**Tech Stack:** `ffmpeg` via `child_process.execFile` (already a dependency of the running system via `FFMPEG_PATH`, not an npm package — identical to `audio.js`). No new npm dependencies.
## Global Constraints
- Formats: `mp4`, `webm`, `mov`, `avi`, `mkv`. All pairs registered except `sourceFormat === targetFormat` (same rule as `audio`/`image`/`document`/`font`/`ebook` — unlike `archive`, which is a deliberate exception).
- Codec map (fixed, no user choice): `mp4`/`mov`/`mkv``libx264` video + `aac` audio; `webm``libvpx-vp9` video + `libopus` audio; `avi``mpeg4` video + `libmp3lame` audio.
- Resolution control reuses `ConversionJob.quality` (`Int?`, no schema change): allowed values `480`, `720`, `1080`, or `null` (original, no scale filter). `app.js`'s `isValidQuality` is the only place that range-checks the value; the converter itself applies `-vf scale=-2:<height>` whenever `quality != null`, trusting the upstream check (same division of responsibility `audio.js` uses).
- New `VIDEO_JOB_TIMEOUT_MS` config var, default `300000`, read the same way `EBOOK_JOB_TIMEOUT_MS` is.
- No video → audio-only or video → GIF conversions — out of scope, cross-family concerns not requested.
- `ffmpeg` is not installed on this dev machine (confirmed: `ffmpeg -version` → command not found), so all backend converter/worker tests mock `node:child_process`'s `execFile`, never invoking a real binary — same approach `audio.test.js` and the ebook case in `worker.test.js` already use.
- `file-type` v22's detection for all 5 formats was verified against hand-crafted magic-byte buffers (see spec) — every format's detected `ext` equals its own name, so no `normalizeFormat` alias is needed for any of them.
- Spec reference: `docs/superpowers/specs/2026-08-01-video-conversion-design.md`.
---
### Task 1: `VIDEO_JOB_TIMEOUT_MS` config value
**Files:**
- Modify: `src/config.js`
- Test: `test/config.test.js`
**Interfaces:**
- Produces: `config.videoJobTimeoutMs` (number, default `300000`, from `process.env.VIDEO_JOB_TIMEOUT_MS`).
- [ ] **Step 1: Write the failing test**
Append to `test/config.test.js`, inside the `describe('loadConfig', ...)` block (after the existing `ebookJobTimeoutMs` test):
```js
it('defaults videoJobTimeoutMs to 300000ms and reads VIDEO_JOB_TIMEOUT_MS when set', () => {
process.env.STORAGE_DIR = './storage';
process.env.DB_HOST = 'localhost';
process.env.DB_USER = 'user';
process.env.DB_PASSWORD = 'pass';
process.env.DB_NAME = 'db';
delete process.env.VIDEO_JOB_TIMEOUT_MS;
expect(loadConfig().videoJobTimeoutMs).toBe(300000);
process.env.VIDEO_JOB_TIMEOUT_MS = '400000';
expect(loadConfig().videoJobTimeoutMs).toBe(400000);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/config.test.js`
Expected: FAIL — `loadConfig().videoJobTimeoutMs` is `undefined`, not `300000`.
- [ ] **Step 3: Write minimal implementation**
In `src/config.js`, add one line inside the returned object, alongside `ebookJobTimeoutMs`:
```js
ebookJobTimeoutMs: Number(process.env.EBOOK_JOB_TIMEOUT_MS ?? 180000),
videoJobTimeoutMs: Number(process.env.VIDEO_JOB_TIMEOUT_MS ?? 300000),
```
- [ ] **Step 4: Run test to verify it passes**
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/config.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/config.js test/config.test.js
git commit -m "feat(video): add configurable VIDEO_JOB_TIMEOUT_MS"
```
---
### Task 2: Video converter (`src/converters/video.js`)
**Files:**
- Create: `src/converters/video.js`
- Test: `test/converters/video.test.js`
**Interfaces:**
- Consumes: `register` from `./registry.js` (existing, `register({ family, sourceFormat, targetFormat, convert })`).
- Produces: `registerVideoConverters(): void`, `VIDEO_FORMATS: string[]` (exactly `['mp4', 'webm', 'mov', 'avi', 'mkv']`).
- [ ] **Step 1: Write the failing test**
```js
// test/converters/video.test.js
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { registerVideoConverters, VIDEO_FORMATS } from '../../src/converters/video.js';
import { resolve, _resetForTests } from '../../src/converters/registry.js';
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
vi.mock('node:child_process', () => ({ execFile: execFileMock }));
beforeEach(() => {
_resetForTests();
execFileMock.mockReset();
execFileMock.mockImplementation((file, args, options, callback) => callback(null, '', ''));
});
describe('registerVideoConverters — registration', () => {
it('registers every pair among the 5 video formats, and nothing for source === target', () => {
registerVideoConverters();
for (const sourceFormat of VIDEO_FORMATS) {
for (const targetFormat of VIDEO_FORMATS) {
const entry = resolve(sourceFormat, targetFormat);
if (sourceFormat === targetFormat) {
expect(entry).toBeNull();
} else {
expect(entry).not.toBeNull();
expect(entry.family).toBe('video');
}
}
}
});
it('exposes exactly the 5 requested formats', () => {
expect(VIDEO_FORMATS.sort()).toEqual(['avi', 'mkv', 'mov', 'mp4', 'webm'].sort());
});
});
describe('registerVideoConverters — subprocess invocation', () => {
beforeEach(() => {
registerVideoConverters();
});
it('calls ffmpeg with -i, the codec args, and the output path, omitting -vf when no quality is given', async () => {
await resolve('mov', 'mp4').convert('/tmp/in.mov', '/tmp/out.mp4');
expect(execFileMock).toHaveBeenCalledTimes(1);
const [file, args, options] = execFileMock.mock.calls[0];
expect(file).toBe('ffmpeg');
expect(args).toEqual([
'-y', '-i', '/tmp/in.mov', '-c:v', 'libx264', '-c:a', 'aac', '/tmp/out.mp4',
]);
expect(options).toEqual({ timeout: undefined });
});
it('appends -vf scale=-2:<height> when a resolution quality is given', async () => {
await resolve('mov', 'mp4').convert('/tmp/in.mov', '/tmp/out.mp4', { quality: 720 });
const [, args] = execFileMock.mock.calls[0];
expect(args).toEqual([
'-y', '-i', '/tmp/in.mov', '-c:v', 'libx264', '-c:a', 'aac', '-vf', 'scale=-2:720', '/tmp/out.mp4',
]);
});
it('uses libvpx-vp9 and libopus for a webm target', async () => {
await resolve('mp4', 'webm').convert('/tmp/in.mp4', '/tmp/out.webm');
const [, args] = execFileMock.mock.calls[0];
expect(args).toEqual([
'-y', '-i', '/tmp/in.mp4', '-c:v', 'libvpx-vp9', '-c:a', 'libopus', '/tmp/out.webm',
]);
});
it('uses mpeg4 and libmp3lame for an avi target', async () => {
await resolve('mp4', 'avi').convert('/tmp/in.mp4', '/tmp/out.avi');
const [, args] = execFileMock.mock.calls[0];
expect(args).toEqual([
'-y', '-i', '/tmp/in.mp4', '-c:v', 'mpeg4', '-c:a', 'libmp3lame', '/tmp/out.avi',
]);
});
it('uses libx264 and aac for an mkv target', async () => {
await resolve('mp4', 'mkv').convert('/tmp/in.mp4', '/tmp/out.mkv');
const [, args] = execFileMock.mock.calls[0];
expect(args).toEqual([
'-y', '-i', '/tmp/in.mp4', '-c:v', 'libx264', '-c:a', 'aac', '/tmp/out.mkv',
]);
});
it('forwards options.timeoutMs to execFile as its timeout', async () => {
await resolve('mp4', 'webm').convert('/tmp/in.mp4', '/tmp/out.webm', { timeoutMs: 300000 });
const [, , options] = execFileMock.mock.calls[0];
expect(options).toEqual({ timeout: 300000 });
});
it('uses FFMPEG_PATH from the environment when set', async () => {
const previous = process.env.FFMPEG_PATH;
process.env.FFMPEG_PATH = '/opt/ffmpeg/bin/ffmpeg';
await resolve('mp4', 'webm').convert('/tmp/in.mp4', '/tmp/out.webm');
expect(execFileMock.mock.calls[0][0]).toBe('/opt/ffmpeg/bin/ffmpeg');
if (previous === undefined) delete process.env.FFMPEG_PATH;
else process.env.FFMPEG_PATH = previous;
});
it('propagates a rejection from execFile as a rejected promise', async () => {
execFileMock.mockImplementation((file, args, options, callback) =>
callback(new Error('ffmpeg exited with code 1'), '', 'Unknown encoder')
);
await expect(resolve('mp4', 'webm').convert('/tmp/in.mp4', '/tmp/out.webm')).rejects.toThrow(
/exited with code 1/
);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/video.test.js`
Expected: FAIL with a module-not-found error for `../../src/converters/video.js`.
- [ ] **Step 3: Write minimal implementation**
```js
// src/converters/video.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 });
},
});
}
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/video.test.js`
Expected: PASS (10 tests)
- [ ] **Step 5: Commit**
```bash
git add src/converters/video.js test/converters/video.test.js
git commit -m "feat(video): add ffmpeg-backed video converter for mp4/webm/mov/avi/mkv"
```
---
### Task 3: MIME types (`src/mime.js`)
**Files:**
- Modify: `src/mime.js`
- Test: `test/mime.test.js`
**Interfaces:**
- Consumes: `OUTPUT_MIME_TYPES`, `resolveInputFormat` (both already in `src/mime.js`).
- Produces: no new exports — extends existing ones. No `normalizeFormat` changes (verified: `file-type` returns `ext` identical to the declared format name for all 5 video formats).
- [ ] **Step 1: Write the failing test**
Append to `test/mime.test.js`:
```js
describe('outputMimeType — video', () => {
it('returns the correct MIME type for each video target format', () => {
expect(outputMimeType('mp4')).toBe('video/mp4');
expect(outputMimeType('webm')).toBe('video/webm');
expect(outputMimeType('mov')).toBe('video/quicktime');
expect(outputMimeType('avi')).toBe('video/x-msvideo');
expect(outputMimeType('mkv')).toBe('video/x-matroska');
});
});
describe('resolveInputFormat — video magic bytes', () => {
it('accepts a minimal mp4 (ftyp box) declared as mp4', async () => {
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-video.mp4');
await fs.writeFile(
fixturePath,
Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftyp'), Buffer.from('isom')])
);
const result = await resolveInputFormat(fixturePath, 'mp4');
expect(result).toEqual({ mime: 'video/mp4', valid: true });
await fs.unlink(fixturePath);
});
it('accepts a minimal mov (moov atom) declared as mov', async () => {
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-video.mov');
await fs.writeFile(fixturePath, Buffer.concat([Buffer.from([0, 0, 0, 0x08]), Buffer.from('moov')]));
const result = await resolveInputFormat(fixturePath, 'mov');
expect(result).toEqual({ mime: 'video/quicktime', valid: true });
await fs.unlink(fixturePath);
});
it('accepts a minimal avi (RIFF/AVI) declared as avi', async () => {
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-video.avi');
await fs.writeFile(
fixturePath,
Buffer.concat([Buffer.from('RIFF'), Buffer.from([0, 0, 0, 0]), Buffer.from('AVI ')])
);
const result = await resolveInputFormat(fixturePath, 'avi');
expect(result).toEqual({ mime: 'video/vnd.avi', valid: true });
await fs.unlink(fixturePath);
});
it('accepts a minimal webm (EBML DocType webm) declared as webm', async () => {
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-video.webm');
await fs.writeFile(
fixturePath,
Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x81, 0x42, 0x82, 0x84]), Buffer.from('webm')])
);
const result = await resolveInputFormat(fixturePath, 'webm');
expect(result).toEqual({ mime: 'video/webm', valid: true });
await fs.unlink(fixturePath);
});
it('accepts a minimal mkv (EBML DocType matroska) declared as mkv', async () => {
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-video.mkv');
await fs.writeFile(
fixturePath,
Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x81, 0x42, 0x82, 0x88]), Buffer.from('matroska')])
);
const result = await resolveInputFormat(fixturePath, 'mkv');
expect(result).toEqual({ mime: 'video/matroska', valid: true });
await fs.unlink(fixturePath);
});
it('rejects a webm file declared as mkv', async () => {
const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-video-mismatch.webm');
await fs.writeFile(
fixturePath,
Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x81, 0x42, 0x82, 0x84]), Buffer.from('webm')])
);
const result = await resolveInputFormat(fixturePath, 'mkv');
expect(result.valid).toBe(false);
await fs.unlink(fixturePath);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/mime.test.js`
Expected: FAIL — `outputMimeType('mp4')` throws (unknown format).
- [ ] **Step 3: Write minimal implementation**
In `src/mime.js`, extend `OUTPUT_MIME_TYPES` (add anywhere among the existing entries):
```js
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
mkv: 'video/x-matroska',
```
No changes to `normalizeFormat` or `resolveInputFormat``file-type`'s detected `ext` already matches the declared format for all 5.
- [ ] **Step 4: Run test to verify it passes**
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/mime.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/mime.js test/mime.test.js
git commit -m "feat(video): add MIME types for mp4/webm/mov/avi/mkv"
```
---
### Task 4: Wire into `app.js` and `worker.js`
**Files:**
- Modify: `src/app.js`
- Modify: `src/worker.js`
- Modify: `test/api/jobs.test.js`
- Modify: `test/worker.test.js`
**Interfaces:**
- Consumes: `registerVideoConverters` (Task 2), `config.videoJobTimeoutMs` (Task 1).
- Produces: no new exports — wires existing pieces into the two entry points.
- [ ] **Step 1: Write the failing tests**
Append to `test/api/jobs.test.js`:
```js
describe('POST /api/jobs — video', () => {
it('creates a pending job converting an mp4 upload to webm with a valid resolution', async () => {
const fixturePath = path.join(config.storageDir, 'clip.mp4');
await fs.writeFile(
fixturePath,
Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftyp'), Buffer.from('isom')])
);
const response = await request(app)
.post('/api/jobs')
.field('targetFormats', JSON.stringify(['webm']))
.field('qualities', JSON.stringify([720]))
.attach('files', fixturePath, 'clip.mp4');
expect(response.status).toBe(201);
expect(response.body.jobs[0].status).toBe('pending');
const job = await getJobByUuid(prisma, response.body.jobs[0].id);
expect(job.sourceFormat).toBe('mp4');
expect(job.targetFormat).toBe('webm');
expect(job.family).toBe('video');
expect(job.quality).toBe(720);
await fs.unlink(fixturePath);
});
it('creates a pending job with no quality value (original resolution)', async () => {
const fixturePath = path.join(config.storageDir, 'clip-original.mp4');
await fs.writeFile(
fixturePath,
Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftyp'), Buffer.from('isom')])
);
const response = await request(app)
.post('/api/jobs')
.field('targetFormats', JSON.stringify(['mkv']))
.attach('files', fixturePath, 'clip-original.mp4');
expect(response.status).toBe(201);
const job = await getJobByUuid(prisma, response.body.jobs[0].id);
expect(job.quality).toBeNull();
await fs.unlink(fixturePath);
});
it('rejects a resolution outside the fixed 480/720/1080 set', async () => {
const fixturePath = path.join(config.storageDir, 'bad-resolution.mp4');
await fs.writeFile(
fixturePath,
Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftyp'), Buffer.from('isom')])
);
const response = await request(app)
.post('/api/jobs')
.field('targetFormats', JSON.stringify(['webm']))
.field('qualities', JSON.stringify([360]))
.attach('files', fixturePath, 'bad-resolution.mp4');
expect(response.status).toBe(201);
expect(response.body.jobs[0].error).toMatch(/Invalid quality/);
await fs.unlink(fixturePath);
});
it('lists the other 4 video formats as targets for mp4, and never lists mp4 as its own target', async () => {
const response = await request(app).get('/api/formats').query({ source: 'mp4' });
expect(response.body.targets).toEqual(expect.arrayContaining(['webm', 'mov', 'avi', 'mkv']));
expect(response.body.targets).not.toContain('mp4');
});
});
```
Append to `test/worker.test.js`, inside `describe('processPendingJobs', ...)` (after the ebook timeout test), and add `import { registerVideoConverters } from '../src/converters/video.js';` near the other converter imports at the top of the file, plus a call to `registerVideoConverters();` inside the existing `beforeAll` alongside `registerImageConverters()`/`registerIcoConverter()`/`registerEbookConverter()`:
```js
it('converts a pending video job to done using config.videoJobTimeoutMs, not the default 60s timeout', async () => {
execFileMock.mockReset();
execFileMock.mockImplementation((file, args, options, callback) => {
writeFileSync(args[args.length - 1], 'fake converted video output');
callback(null, '', '');
});
const uuid = '22222222-2222-4222-8222-222222222222';
const inputFilePath = uploadPath(config, uuid, 'mp4');
await fs.writeFile(inputFilePath, 'fake mp4 content');
await createJob(prisma, {
uuid,
family: 'video',
sourceFormat: 'mp4',
targetFormat: 'webm',
originalFilename: 'clip.mp4',
inputPath: `${uuid}.mp4`,
inputMimeType: 'video/mp4',
inputSizeBytes: 17,
expiresAt: new Date(Date.now() + 3600 * 1000),
quality: 720,
});
await processPendingJobs(prisma, config);
const job = await getJobByUuid(prisma, uuid);
expect(job.status).toBe('done');
const [file, args, options] = execFileMock.mock.calls[0];
expect(file).toBe('ffmpeg');
expect(args).toEqual([
'-y', '-i', inputFilePath, '-c:v', 'libvpx-vp9', '-c:a', 'libopus', '-vf', 'scale=-2:720',
outputPath(config, uuid, 'webm'),
]);
expect(options).toEqual({ timeout: config.videoJobTimeoutMs });
expect(config.videoJobTimeoutMs).not.toBe(60000);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/api/jobs.test.js test/worker.test.js`
Expected: FAIL — every video conversion is reported as `Unsupported conversion`, and the worker test errors on `No converter registered for mp4 -> webm`.
- [ ] **Step 3: Write minimal implementation**
In `src/app.js`, add the import near the other converter imports:
```js
import { registerVideoConverters } from './converters/video.js';
```
Update `registerAllConverters`:
```js
function registerAllConverters() {
if (convertersRegistered) return;
registerImageConverters();
registerImageToPdfConverter();
registerDocumentConverters();
registerIcoConverter();
registerHeicConverter();
registerFontConverter();
registerDfontConverter();
registerEbookConverter();
registerArchiveConverters();
registerAudioConverters();
registerVideoConverters();
convertersRegistered = true;
}
```
Update `isValidQuality` (add the new branch before the final fallback return):
```js
function isValidQuality(targetFormat, quality) {
if (quality === null || quality === undefined) return true;
if (!Number.isInteger(quality)) return false;
if (targetFormat === 'gif' || targetFormat === 'ico' || targetFormat === 'tar') return false;
if (targetFormat === 'png') return quality >= 0 && quality <= 9;
if (['zip', 'tar.gz', 'tar.bz2', '7z', 'tar.7z'].includes(targetFormat)) return quality >= 0 && quality <= 9;
if (['wav', 'flac'].includes(targetFormat)) return false;
if (['mp3', 'ogg', 'aac', 'm4a'].includes(targetFormat)) return [128, 192, 256, 320].includes(quality);
if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(targetFormat)) return [480, 720, 1080].includes(quality);
return quality >= 1 && quality <= 100;
}
```
In `src/worker.js`, add the import near the other converter imports:
```js
import { registerVideoConverters } from './converters/video.js';
```
Update the `timeoutMs` selection in `processJob`:
```js
const timeoutMs =
entry.family === 'ebook' ? config.ebookJobTimeoutMs :
entry.family === 'video' ? config.videoJobTimeoutMs :
JOB_TIMEOUT_MS;
```
Update `main()`:
```js
registerImageConverters();
registerImageToPdfConverter();
registerDocumentConverters();
registerIcoConverter();
registerHeicConverter();
registerFontConverter();
registerDfontConverter();
registerEbookConverter();
registerArchiveConverters();
registerAudioConverters();
registerVideoConverters();
```
- [ ] **Step 4: Run test to verify it passes**
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/api/jobs.test.js test/worker.test.js`
Expected: PASS
- [ ] **Step 5: Run the full backend test suite**
Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run`
Expected: PASS, except the two pre-existing unrelated failures documented in `CLAUDE.md` (`test/cleanup.test.js` and `test/jobs/jobRepository.test.js`, both failing on `main` already).
- [ ] **Step 6: Commit**
```bash
git add src/app.js src/worker.js test/api/jobs.test.js test/worker.test.js
git commit -m "feat(video): wire video converters, resolution validation, and timeout into app.js/worker.js"
```
---
### Task 5: Frontend — video family, resolution control
**Files:**
- Modify: `frontend/src/data/formats.js`
- Modify: `frontend/src/utils/fileFamily.js`
- Modify: `frontend/src/components/FormatsGrid.jsx`
- Modify: `frontend/src/components/FileConfigCard.jsx`
- Modify: `frontend/src/components/FormatMarquee.jsx`
- Modify: `frontend/src/index.css`
- Modify: `frontend/src/locales/en.json`
- Modify: `frontend/src/locales/fr.json`
**Interfaces:**
- Consumes: `extensionOf` (existing, `frontend/src/utils/archiveExtensions.js``mp4`/`webm`/`mov`/`avi`/`mkv` are all simple single-dot extensions, so no change needed there).
There is no automated frontend test runner in this project (`frontend/package.json` has no `test` script) — every prior converter family's frontend work was verified by manual browser check, and this task follows the same convention.
- [ ] **Step 1: Add the `video` format family**
In `frontend/src/data/formats.js`, add a new entry to `FORMAT_FAMILIES`:
```js
{
key: 'video',
formats: ['mp4', 'webm', 'mov', 'avi', 'mkv'],
},
```
- [ ] **Step 2: Add the video family icon in both places it's defined**
In `frontend/src/utils/fileFamily.js`, add the import and map entry:
```js
import { Image, FileText, TextAa, BookOpen, Archive, MusicNotes, VideoCamera, File as FileIcon } from '@phosphor-icons/react';
```
```js
const FAMILY_ICONS = {
images: Image,
documents: FileText,
fonts: TextAa,
ebooks: BookOpen,
archives: Archive,
audio: MusicNotes,
video: VideoCamera,
};
```
In `frontend/src/components/FormatsGrid.jsx` (a separate, duplicated `FAMILY_ICONS` map — confirmed by reading the file):
```js
import { Image, FileText, TextAa, BookOpen, Archive, MusicNotes, VideoCamera } from '@phosphor-icons/react';
const FAMILY_ICONS = {
images: Image,
documents: FileText,
fonts: TextAa,
ebooks: BookOpen,
archives: Archive,
audio: MusicNotes,
video: VideoCamera,
};
```
- [ ] **Step 3: Add the `--color-family-video` CSS var**
In `frontend/src/index.css`, add one line to each of the two variable blocks (light `:root`, after `--color-family-audio`):
```css
--color-family-video: #38bdf8;
```
And to the dark `:root[data-theme='dark']` block, after its own `--color-family-audio`:
```css
--color-family-video: #7dd3fc;
```
- [ ] **Step 4: Add the resolution control to `FileConfigCard.jsx`**
Add the constants near `AUDIO_BITRATE_FORMATS`/`AUDIO_BITRATES`:
```js
const VIDEO_RESOLUTION_FORMATS = ['mp4', 'webm', 'mov', 'avi', 'mkv'];
const VIDEO_RESOLUTIONS = [480, 720, 1080];
```
Add a new conditional block alongside the existing `AUDIO_BITRATE_FORMATS` block:
```jsx
{VIDEO_RESOLUTION_FORMATS.includes(item.targetFormat) && (
<div className="chip-field">
<span>{t('quality.resolution')}</span>
<div className="chip-group" role="group" aria-label={t('quality.resolution')}>
<button
type="button"
className={`chip${item.quality === null ? ' chip-active' : ''}`}
aria-pressed={item.quality === null}
onClick={() => onQualityChange(index, null)}
>
{t('quality.original')}
</button>
{VIDEO_RESOLUTIONS.map((resolution) => (
<button
key={resolution}
type="button"
className={`chip${item.quality === resolution ? ' chip-active' : ''}`}
aria-pressed={item.quality === resolution}
onClick={() => onQualityChange(index, resolution)}
>
{resolution}p
</button>
))}
</div>
</div>
)}
```
- [ ] **Step 5: Add a video pair to the format marquee**
In `frontend/src/components/FormatMarquee.jsx`, add one entry to `PAIRS`:
```js
['mov', 'mp4'],
```
- [ ] **Step 6: Add the `formats.video`, `quality.resolution`, and `quality.original` translation keys**
In `frontend/src/locales/en.json`, add to the `formats` object:
```json
"video": "Video"
```
and to the `quality` object:
```json
"resolution": "Resolution",
"original": "Original"
```
In `frontend/src/locales/fr.json`, add to the `formats` object:
```json
"video": "Vidéo"
```
and to the `quality` object:
```json
"resolution": "Résolution",
"original": "Original"
```
- [ ] **Step 7: Manual verification**
Follow the project's own guidance on checking for an already-running dev server before starting a new one (`CLAUDE.md`'s manual end-to-end testing section). If none is running:
```bash
npm run dev --prefix frontend
```
In a browser: upload a small `.mp4` (any real one, or rename any small file with valid mp4 magic bytes — the browser upload path re-validates via `resolveInputFormat` server-side, so a plain renamed file without real ftyp bytes will be rejected with "File content does not match its extension"), confirm the target chips include `WEBM`, `MOV`, `AVI`, `MKV` (not `MP4`); confirm the resolution chip group (`Original`/`480p`/`720p`/`1080p`) appears for every video target; confirm the "Video" tile appears on the homepage formats grid with its own icon/color. Real end-to-end conversion requires `ffmpeg` actually installed and `FFMPEG_PATH` set wherever the worker runs — on this dev machine `ffmpeg` is not installed, so a full conversion cannot be verified locally; confirm at minimum that the job is accepted as `pending` and correctly fails with a clear ffmpeg-not-found error rather than an "Unsupported conversion" error (which would indicate a wiring bug, vs. a missing-binary environment limitation).
- [ ] **Step 8: Commit**
```bash
git add frontend/src/data/formats.js frontend/src/utils/fileFamily.js frontend/src/components/FormatsGrid.jsx frontend/src/components/FileConfigCard.jsx frontend/src/components/FormatMarquee.jsx frontend/src/index.css frontend/src/locales/en.json frontend/src/locales/fr.json
git commit -m "feat(video): add video format family, resolution control, and marquee pair"
```
---
## Self-Review Notes
- **Spec coverage:** every section of `docs/superpowers/specs/2026-08-01-video-conversion-design.md` maps to a task — codec map + registration (Task 2), resolution/quality reuse (Tasks 2, 4), timeout (Tasks 1, 4), MIME/detection (Task 3), registration wiring (Task 4), frontend (Task 5), testing (all tasks include their own, using the verified mocked-`execFile` and hand-crafted magic-byte approaches established during planning).
- **No real video fixtures needed anywhere** — confirmed during planning that `ffmpeg` is unavailable on this dev machine, and that the codebase's own precedent (`audio.test.js`, the ebook case in `worker.test.js`) already mocks `execFile` rather than shelling out for converter/worker tests; `mime.test.js` only needs magic-byte headers, not full valid media files, since `file-type` only reads the leading bytes.
- **Type/name consistency checked:** `VIDEO_FORMATS`, `registerVideoConverters`, `videoJobTimeoutMs`, `CODEC_ARGS` keys, and the `[480, 720, 1080]` resolution set are spelled identically everywhere they're referenced across tasks.
- **No placeholders:** every step has literal code, not a description of code; the one environment limitation honestly flagged (no local `ffmpeg` for true end-to-end manual verification) carries a concrete fallback check (job accepted as pending, fails with a binary-not-found error rather than an unsupported-conversion error), not a skipped step.