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

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 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/mkvlibx264 video + aac audio; webmlibvpx-vp9 video + libopus audio; avimpeg4 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):

  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: 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

// 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 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:

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 resolveInputFormatfile-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.jsmp4/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:

  {
    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-video CSS 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, and quality.original translation 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.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.