docs(video): add implementation plan for video conversion feature
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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:<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):
|
||||||
|
|
||||||
|
```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:<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**
|
||||||
|
|
||||||
|
```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) && (
|
||||||
|
<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`:
|
||||||
|
|
||||||
|
```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.
|
||||||
Reference in New Issue
Block a user