# 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:` 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: 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) && (
{t('quality.resolution')}
{VIDEO_RESOLUTIONS.map((resolution) => ( ))}
)} ``` - [ ] **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.