32 KiB
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 exceptsourceFormat === targetFormat(same rule asaudio/image/document/font/ebook— unlikearchive, which is a deliberate exception). - Codec map (fixed, no user choice):
mp4/mov/mkv→libx264video +aacaudio;webm→libvpx-vp9video +libopusaudio;avi→mpeg4video +libmp3lameaudio. - Resolution control reuses
ConversionJob.quality(Int?, no schema change): allowed values480,720,1080, ornull(original, no scale filter).app.js'sisValidQualityis the only place that range-checks the value; the converter itself applies-vf scale=-2:<height>wheneverquality != null, trusting the upstream check (same division of responsibilityaudio.jsuses). - New
VIDEO_JOB_TIMEOUT_MSconfig var, default300000, read the same wayEBOOK_JOB_TIMEOUT_MSis. - No video → audio-only or video → GIF conversions — out of scope, cross-family concerns not requested.
ffmpegis not installed on this dev machine (confirmed:ffmpeg -version→ command not found), so all backend converter/worker tests mocknode:child_process'sexecFile, never invoking a real binary — same approachaudio.test.jsand the ebook case inworker.test.jsalready use.file-typev22's detection for all 5 formats was verified against hand-crafted magic-byte buffers (see spec) — every format's detectedextequals its own name, so nonormalizeFormatalias 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, default300000, fromprocess.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):
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:
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
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:
registerfrom./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
// 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
// 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
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 insrc/mime.js). -
Produces: no new exports — extends existing ones. No
normalizeFormatchanges (verified:file-typereturnsextidentical to the declared format name for all 5 video formats). -
Step 1: Write the failing test
Append to test/mime.test.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):
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
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:
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():
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:
import { registerVideoConverters } from './converters/video.js';
Update registerAllConverters:
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):
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:
import { registerVideoConverters } from './converters/video.js';
Update the timeoutMs selection in processJob:
const timeoutMs =
entry.family === 'ebook' ? config.ebookJobTimeoutMs :
entry.family === 'video' ? config.videoJobTimeoutMs :
JOB_TIMEOUT_MS;
Update main():
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
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/mkvare 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
videoformat family
In frontend/src/data/formats.js, add a new entry to FORMAT_FAMILIES:
{
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:
import { Image, FileText, TextAa, BookOpen, Archive, MusicNotes, VideoCamera, File as FileIcon } from '@phosphor-icons/react';
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):
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-videoCSS var
In frontend/src/index.css, add one line to each of the two variable blocks (light :root, after --color-family-audio):
--color-family-video: #38bdf8;
And to the dark :root[data-theme='dark'] block, after its own --color-family-audio:
--color-family-video: #7dd3fc;
- Step 4: Add the resolution control to
FileConfigCard.jsx
Add the constants near AUDIO_BITRATE_FORMATS/AUDIO_BITRATES:
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:
{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:
['mov', 'mp4'],
- Step 6: Add the
formats.video,quality.resolution, andquality.originaltranslation keys
In frontend/src/locales/en.json, add to the formats object:
"video": "Video"
and to the quality object:
"resolution": "Resolution",
"original": "Original"
In frontend/src/locales/fr.json, add to the formats object:
"video": "Vidéo"
and to the quality object:
"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:
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
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.mdmaps 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-execFileand hand-crafted magic-byte approaches established during planning). - No real video fixtures needed anywhere — confirmed during planning that
ffmpegis unavailable on this dev machine, and that the codebase's own precedent (audio.test.js, the ebook case inworker.test.js) already mocksexecFilerather than shelling out for converter/worker tests;mime.test.jsonly needs magic-byte headers, not full valid media files, sincefile-typeonly reads the leading bytes. - Type/name consistency checked:
VIDEO_FORMATS,registerVideoConverters,videoJobTimeoutMs,CODEC_ARGSkeys, 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
ffmpegfor 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.