From a92556808a0e4161360a320cc1177c93e89ea4c0 Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Sat, 1 Aug 2026 21:53:56 +0200 Subject: [PATCH] docs(video): add implementation plan for video conversion feature Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-08-01-video-conversion.md | 821 ++++++++++++++++++ 1 file changed, 821 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-01-video-conversion.md diff --git a/docs/superpowers/plans/2026-08-01-video-conversion.md b/docs/superpowers/plans/2026-08-01-video-conversion.md new file mode 100644 index 0000000..21ca67a --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-video-conversion.md @@ -0,0 +1,821 @@ +# 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.