Files
convert/docs/superpowers/specs/2026-08-01-video-conversion-design.md
anthonyandClaude Sonnet 5 728247a2cf docs(video): refine spec with verified file-type detection and simplified converter
Verified exact file-type v22 magic-byte detection for all 5 formats against
real fixture buffers (no normalizeFormat aliases needed), and simplified the
converter's resolution-scaling gate to match audio.js's validation-boundary
pattern (app.js range-checks, converter trusts the value).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:51:50 +02:00

11 KiB
Raw Permalink Blame History

Video conversion (mp4, webm, mov, avi, mkv)

Date: 2026-08-01

Goal

Add a new video family: cross conversion between mp4, webm, mov, avi, mkv, all backed by ffmpeg (FFMPEG_PATH env var, already configured on o2switch and already used by src/converters/audio.js). Same-format pairs (mp4 -> mp4, etc.) are not registered — no resize-in-place use case was requested, so this follows the audio/image/document/font/ebook precedent of rejecting sourceFormat === targetFormat, unlike the archive family's deliberate exception.

Net registration: 5 formats × 4 other targets each = 20 pairs, family 'video'.

Video → audio-only or video → GIF conversions are explicitly out of scope for this feature — those are cross-family conversions with their own quality semantics and were not requested.

Architecture: fixed codec-per-target map, same shape as audio.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 });
        },
      });
    }
  }
}

This mirrors audio.js's overall shape (fixed per-target codec map, a flat register loop, quality passed straight through from the job row with no extra config threading), with one simplification: audio.js gates its -b:a push on BITRATE_CAPABLE_FORMATS.includes(targetFormat) because bitrate doesn't apply to lossless targets (wav, flac); resolution scaling applies uniformly to every video target, so no analogous per-target gate is needed here — quality != null alone is sufficient, since app.js's isValidQuality is what restricts the actual value set (480/720/1080) before the converter ever runs, same division of responsibility the audio family already uses (the converter trusts the value; app.js is the only place that range-checks it).

Codec choice per target container (fixed, no user choice — confirmed with the user)

target video codec audio codec
mp4 libx264 aac
mov libx264 aac
mkv libx264 aac
webm libvpx-vp9 libopus
avi mpeg4 libmp3lame

All five are standard ffmpeg built-in encoders (no separate binary/license concern beyond ffmpeg itself, which is already deployed via FFMPEG_PATH).

Resolution control: reuse quality, three fixed heights + "original"

No schema change — ConversionJob.quality (Int?) already exists and already flows app.jscreateJobworker.jsentry.convert(..., { quality }) unchanged, same as every other family's per-format quality reuse (PNG compression level, archive compression level, audio bitrate).

Allowed values: 480, 720, 1080, or null/omitted (original resolution, no -vf scale argument added — passthrough).

-vf scale=-2:<height> scales to the requested height while computing width automatically (-2 forces an even width, required by most video codecs including libx264/vp9).

isValidQuality(targetFormat, quality) in src/app.js gains:

if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(targetFormat)) return [480, 720, 1080].includes(quality);

placed alongside the existing format-specific branches. The existing early return (if (quality === null || quality === undefined) return true;) already covers the "original" case — no extra branch needed for that.

Timeout: new VIDEO_JOB_TIMEOUT_MS, default 300000 (5 min)

Video transcoding is slower than every other conversion family currently handled by the default 60s (JOB_TIMEOUT_MS in worker.js). Follows the exact precedent of ebookJobTimeoutMs:

src/config.js:

videoJobTimeoutMs: Number(process.env.VIDEO_JOB_TIMEOUT_MS ?? 300000),

src/worker.js, processJob:

const timeoutMs =
  entry.family === 'ebook' ? config.ebookJobTimeoutMs :
  entry.family === 'video' ? config.videoJobTimeoutMs :
  JOB_TIMEOUT_MS;

No change to MAX_FILE_SIZE_MB — it stays a single global upload-size limit for all file types, as today; the user can raise it via .env/.env.local if needed, with no video-specific override.

src/mime.js

OUTPUT_MIME_TYPES additions:

mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
mkv: 'video/x-matroska',

resolveInputFormat/normalizeFormat: verified directly against the installed file-type v22 source (source/index.js, source/detectors/ebml.js) and confirmed with hand-crafted minimal fixture buffers run through fileTypeFromBuffer:

format detection ext returned mime returned
mp4 ftyp box at offset 4 (ISO base media) mp4 video/mp4
mov free/mdat/moov/wide atom at offset 4, or ftyp with brand qt mov video/quicktime
avi RIFF + AVI at offset 8 avi video/vnd.avi
webm EBML DocType webm webm video/webm
mkv EBML DocType matroska mkv video/matroska

All five ext values are identical to their declared format name, so no normalizeFormat alias is needed for any of them (unlike tar.gz/tar.bz2/tar.7z in the archive family). Note file-type's detected mime for avi (video/vnd.avi) and mkv (video/matroska) differ from this feature's own OUTPUT_MIME_TYPES values (video/x-msvideo, video/x-matroska) — this is not a bug: the detected mime is only ever stored as inputMimeType (informational), while OUTPUT_MIME_TYPES independently drives the download Content-Type header; the two are never compared to each other anywhere in the codebase (confirmed by reading app.js/worker.js/mime.jsresolveInputFormat's valid check compares ext, never mime).

Registration wiring

src/app.js and src/worker.js: add import { registerVideoConverters } from './converters/video.js' and one call inside registerAllConverters() / main(), alongside the existing eight (image, imageToPdf, document, ico, heic, font, dfont, ebook, archive, audio).

Frontend

  • frontend/src/data/formats.js: new entry { key: 'video', formats: ['mp4', 'webm', 'mov', 'avi', 'mkv'] }.
  • frontend/src/utils/fileFamily.js and frontend/src/components/FormatsGrid.jsx (both maintain their own duplicated FAMILY_ICONS map, confirmed by reading both files): add video: VideoCamera using the VideoCamera icon from @phosphor-icons/react (already a dependency).
  • frontend/src/index.css: new --color-family-video CSS var in both the light and dark variable blocks (alongside the existing --color-family-audio etc.), a distinct color not already used by another family.
  • frontend/src/components/FileConfigCard.jsx: new constant VIDEO_RESOLUTION_FORMATS = ['mp4', 'webm', 'mov', 'avi', 'mkv'] and a chip-group control (same pattern as AUDIO_BITRATE_FORMATS), options 480p, 720p, 1080p, and Original (mapping to quality: null).
  • frontend/src/pages/HomePage.jsx: no DEFAULT_QUALITY entry needed for video targets — default is "Original" (quality: null), which is already defaultQualityFor's fallback behavior when a format has no entry in DEFAULT_QUALITY.
  • frontend/src/components/FormatMarquee.jsx: add one new pair to PAIRS, e.g. ['mov', 'mp4'].
  • frontend/src/locales/en.json / fr.json: add formats.video ("Video" / "Vidéo") and quality.resolution ("Resolution" / "Résolution") keys.

No DB/schema changes anywhere in this feature.

Testing

test/converters/video.test.js, same convention as test/converters/audio.test.js — confirmed by reading that file: it mocks node:child_process's execFile entirely (vi.mock('node:child_process', ...)) and asserts on the exact args array passed, rather than invoking a real ffmpeg binary or using real media fixtures. ffmpeg is not installed on this dev machine (confirmed: ffmpeg -version → command not found), so this mocked approach is also the only one that works locally, not just the established convention. Tests: registration (all 20 pairs registered, all 5 same-format pairs return null via resolve), codec args per target (one assertion per row of the codec table), -vf scale=-2:<height> appended only when quality is one of 480/720/1080 and omitted otherwise, FFMPEG_PATH env var respected, timeoutMs forwarded, and a rejected execFile callback propagating as a rejected promise.

test/mime.test.js: cases for the 5 new OUTPUT_MIME_TYPES entries, plus resolveInputFormat cases for all 5 formats using hand-crafted minimal magic-byte buffers (verified against the installed file-type v22 via a throwaway script — exact bytes, written directly in the test with fs.writeFile then deleted, same pattern the existing mime.test.js already uses for .txt/.md):

// mp4: [box size][ftyp][isom]
Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftyp'), Buffer.from('isom')])
// mov: [box size][moov]
Buffer.concat([Buffer.from([0, 0, 0, 0x08]), Buffer.from('moov')])
// avi: RIFF + size(4, arbitrary) + 'AVI '
Buffer.concat([Buffer.from('RIFF'), Buffer.from([0, 0, 0, 0]), Buffer.from('AVI ')])
// webm: EBML id(4) + len(1)=0x81 + DocType id(2)=0x42,0x82 + len(1)=0x84 + 'webm'
Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x81, 0x42, 0x82, 0x84]), Buffer.from('webm')])
// mkv: same but DocType payload 'matroska' (8 bytes), len byte 0x88
Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x81, 0x42, 0x82, 0x88]), Buffer.from('matroska')])

test/api/jobs.test.js: valid resolution values accepted, out-of-range resolution values rejected, family: 'video' recorded on the created job.

Frontend: manual browser check per CLAUDE.md's existing convention (no automated frontend test runner) — upload an .mp4, confirm target chips show WEBM/MOV/AVI/MKV (not MP4), confirm the resolution chip group (480p/720p/1080p/Original) appears, confirm the "Video" tile appears on the homepage formats grid with its own icon/color. Real end-to-end conversion requires the backend server and worker restarted to pick up the new converter code, per CLAUDE.md's stale-worker caveat.