Queued FFmpeg video variant processing for Payload CMS
npm install @kimjansheden/payload-video-processorQueued FFmpeg-based video variant generation for Payload CMS. The plugin mirrors
Payload's image size workflow for videos by adding a variants[] array to video
collections and exposing an Admin UI that lets editors enqueue transcoding jobs.
Designed to be plug-and-play: no custom endpoints, no extra UI code in the
consumer project.
- βοΈ Queue backed by BullMQ + Redis so processing runs outside the web process.
- ποΈ Configurable presets that append FFmpeg arguments per output variant.
- βοΈ Optional crop UI powered by react-easy-crop for frame-accurate crops.
- π§° Admin field handles enqueueing, previewing, replacing, and deleting variants without extra glue code.
- π Outputs written next to the original upload (or via custom path resolver).
- π Worker CLI can bootstrap Payload locally or fall back to REST APIs.
``bash`
pnpm add @kimjansheden/payload-video-processor
Peer dependencies (payload, react, react-dom) must already exist in yourffmpeg-static
Payload project. The package bundles static FFmpeg/ffprobe binaries via; if those are blocked on your platform, set FFMPEG_BIN to a/opt/homebrew/bin/ffmpeg
system ffmpeg binary (for example on macOS/Homebrew).
Define presets and register the plugin in your payload.config.ts (or wherever you build your Payload config):
`ts
import { buildConfig } from "payload";
import { mongooseAdapter } from "@payloadcms/db-mongodb";
import videoPlugin from "@kimjansheden/payload-video-processor";
const videoOptions = {
presets: {
mobile360: {
label: "360p Mobile",
args: ["-vf", "scale=-2:360", "-crf", "32"],
},
hd720: {
label: "720p HD",
args: ["-vf", "scale=-2:720", "-crf", "24"],
enableCrop: true,
},
},
queue: {
redisUrl: process.env.REDIS_URL,
concurrency: 1,
},
// Auto-enqueue a preset when a new video is uploaded.
autoEnqueue: true,
// Optional: override the default preset used on create.
autoEnqueuePreset: "hd720",
// Optional: replace the original with the auto-generated variant.
autoReplaceOriginal: true,
};
export default buildConfig({
// This plugin works with both DATABASE_URI and MONGODB_URI; the worker CLI maps DATABASE_URI -> MONGODB_URI.
db: mongooseAdapter({
url: process.env.DATABASE_URI ?? process.env.MONGODB_URI ?? "",
}),
collections: [
/ β¦ /
],
plugins: [videoPlugin(videoOptions)],
});
`
Recommended host pattern: export the options object from src/videoPluginOptions.ts and import it in both your Payload config and worker/payload.worker.config.ts, so presets/queue settings stay in one place.
When autoEnqueue is true, the plugin1080
tries a preset named , then hd1080, and finally falls back to the firstautoEnqueuePreset
configured preset.
Set to force a specific preset name when auto-enqueueing.
Cropping is optional and configured per preset via enableCrop: true.
- enableCrop only exposes crop controls in the Admin UI.
- Cropping is opt-in per enqueue: the generated variant is not cropped unless
the editor explicitly enables βApply crop for this enqueueβ.
- If cropping is not enabled, no crop parameters are sent to the worker and the
full frame is preserved.
`ts
import videoPlugin, { type VideoPluginOptions } from "@kimjansheden/payload-video-processor";
const presets = {
mobile360: { label: "360p Mobile", args: ["-vf", "scale=-2:360"] },
hd1080: { label: "Full HD 1080p", args: ["-vf", "scale=-2:1080"] },
} satisfies VideoPluginOptions["presets"];
type PresetName = keyof typeof presets;
const options: VideoPluginOptions
presets,
queue: { redisUrl: process.env.REDIS_URL, concurrency: 1 },
autoEnqueue: true,
autoEnqueuePreset: "hd1080",
autoReplaceOriginal: true,
};
`
ESM:
`ts`
import videoPlugin from "@kimjansheden/payload-video-processor";
CommonJS:
`ts`
const videoPlugin = require("@kimjansheden/payload-video-processor").default;
For the worker options module, either export default (ESM) or use
module.exports = options (CommonJS), then point the CLI at the built file.
| Option | Type | Notes |
| --- | --- | --- |
| presets | Record | Required. Keys become preset names. |queue
| | QueueConfig | Optional queue name/redis URL/concurrency. |autoEnqueue
| | boolean | true uses autoEnqueuePreset or the default fallback. |autoEnqueuePreset
| | string | Must match a preset key, not the label. |autoReplaceOriginal
| | boolean | Only applies to auto-enqueued jobs. |access
| | AccessControl | Optional access control hooks. |resolvePaths
| | (args) => ResolvePathsResult | Override output directory/filename/URL. |
The worker needs to read the original upload from disk. For local filesystem
storage, the worker reads the original file path from doc.path (absolute pathpath
on disk). If your upload collection does not already provide a , add astaticDir
read-only field and populate it from your upload + filename:
`ts
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { CollectionConfig } from "payload";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
const staticDir =
process.env.STATIC_DIR ?? path.resolve(dirname, "../../public/media");
export const Media: CollectionConfig = {
slug: "media",
upload: {
staticDir,
mimeTypes: ["video/mp4", "video/webm", "video/quicktime"],
},
fields: [
{
name: "path",
type: "text",
admin: { readOnly: true, position: "sidebar" },
},
],
hooks: {
afterRead: [
({ doc }) => {
if (doc && typeof doc.filename === "string") {
doc.path = path.join(staticDir, doc.filename);
}
return doc;
},
],
},
};
`
Provide a worker options module and bundle it to JS (the CLI needs a JS file). Example:
`ts`
// src/videoPluginOptions.ts
export default videoOptions;
`bash`
tsup src/videoPluginOptions.ts --format esm --platform node --target es2022 --out-dir dist-config --minify
When you pass --payload-config, the worker can initialize Payload locally and update documents via the local Node API.
`ts
// worker/payload.worker.config.ts
import { mongooseAdapter } from "@payloadcms/db-mongodb";
import { buildConfig } from "payload";
import videoPlugin from "@kimjansheden/payload-video-processor";
import { Media } from "../src/collections/Media";
import videoPluginOptions from "../src/videoPluginOptions";
export default buildConfig({
telemetry: false,
secret: process.env.PAYLOAD_SECRET || "dev-secret",
db: mongooseAdapter({
url: process.env.MONGODB_URI || process.env.DATABASE_URI || "",
}),
plugins: [videoPlugin(videoPluginOptions)],
collections: [Media],
});
`
Bundle it:
`bash`
tsup worker/payload.worker.config.ts --format esm --platform node --target es2022 --out-dir dist-config --minify
Start the worker in a separate process:
`bash`
payload-video-worker \
--config ./dist-config/videoPluginOptions.js \
--payload-config ./dist-config/payload.worker.config.js
To initialize Payload locally, ensure PAYLOAD_SECRET + DATABASE_URI (or MONGODB_URI) are set. If you--payload-config
prefer the REST fallback, omit and providePAYLOAD_REST_URL + PAYLOAD_ADMIN_TOKEN.
The CLI loads .env, .env.local, .env.development, and .env.production--no-default-env
automatically (unless you pass ). Additional --env flags can
point to project-specific files.
Example (explicit env + static dir, useful in monorepos):
`bash`
FFMPEG_BIN=/opt/homebrew/bin/ffmpeg payload-video-worker \
--no-default-env \
--config ./dist-config/videoPluginOptions.js \
--payload-config ./dist-config/payload.worker.config.js \
--env .env \
--env .env.development \
--static-dir ./public/media
Prefer a fully programmatic setup? Import createWorker directly and pass the
same options object you provide to the plugin.
In the Admin UI a "Video processing" panel appears on any upload collection that accepts video/* mime types. Editors can enqueue presets, preview variants, replace the original file with a processed version, or delete unwanted variants without writing custom endpoints.
Most projects bundle both the plugin options and a minimal Payload config for the worker:
`jsonc`
{
"scripts": {
"bundle:video-plugin-options": "tsup src/videoPluginOptions.ts --format esm --platform node --target es2022 --out-dir dist-config --minify",
"bundle:payload-worker-config": "tsup worker/payload.worker.config.ts --format esm --platform node --target es2022 --out-dir dist-config --minify",
"video:worker": "pnpm bundle:payload-worker-config && pnpm bundle:video-plugin-options && payload-video-worker --config ./dist-config/videoPluginOptions.js --payload-config ./dist-config/payload.worker.config.js",
"video:worker:dev": "pnpm bundle:payload-worker-config && pnpm bundle:video-plugin-options && FFMPEG_BIN=/opt/homebrew/bin/ffmpeg payload-video-worker --no-default-env --config ./dist-config/videoPluginOptions.js --payload-config ./dist-config/payload.worker.config.js --env .env --env .env.development --static-dir ./public/media"
}
}
This repository also includes apps/example-payload, a CLI-only reference/admin
project that demonstrates plugin configuration + worker processing without
shipping a full UI app. See apps/example-payload/README.md.
- pnpm build β bundles ESM/CJS + admin assets via tsup.pnpm dev
- β watch mode build useful during plugin development.pnpm typecheck
- β tsc --noEmit for type validation.pnpm test
- β unit tests for helper utilities using Vitest.pnpm worker
- β development helper that runs the compiled worker entry.payload-video-worker
(Application projects should prefer the CLI.)
| Variable | Purpose |
| ------------------------------------------- | ---------------------------------------------------------------------------- |
| REDIS_URL | Default Redis connection string for queue + worker. |FFMPEG_BIN
| | Optional path to a system ffmpeg binary (overrides ffmpeg-static). |STATIC_DIR
| | Base media directory for the worker (used when resolving paths). |PAYLOAD_SECRET
| / DATABASE_URI / MONGODB_URI | Required to bootstrap the Payload local API from the worker. |PAYLOAD_REST_URL
| + PAYLOAD_ADMIN_TOKEN | REST fallback when local init is not possible. |PAYLOAD_PUBLIC_URL
| / PAYLOAD_SERVER_URL | Alternative base URL for REST fallback if PAYLOAD_REST_URL is not set. |PAYLOAD_CONFIG_PATH
| | Absolute/relative path to the host payload.config.ts for worker bootstrap. |
Provide a resolvePaths function to control where variants are written:
`ts${path.parse(original.filename).name}.${presetName}.mp4
videoPlugin({
presets,
resolvePaths: ({ original, presetName }) => ({
dir: path.join("/data/videos", presetName),
filename: ,/videos/${presetName}/${path.parse(original.filename).name}.mp4
url: ,`
}),
});
The installed binary payload-video-worker bootstraps environment variables,.env
loads your exported plugin options, and starts the queue worker. It automatically
loads , .env.local, .env.development, and .env.production (unless you--no-default-env
pass ). Supply --env to load additional files, --config to--payload-config
point at the bundled options module, when you want the worker--static-dir
to initialise Payload locally, and if your media folder is not./public/media.
`bash`
payload-video-worker \
--config ./dist-config/videoPluginOptions.js \
--payload-config ./dist-config/payload.worker.config.js \
--env .env \
--env cms/.env
The CLI sets common fallbacks (STATIC_DIR, PAYLOAD_CONFIG_PATH,MONGODB_URI β DATABASE_URI, default PAYLOAD_SECRET) before invokingcreateWorker. It shuts down gracefully on SIGINT/SIGTERM`.
Contributions are welcome. Please open an issue or PR with a clear description
of the change and how to test it. If you add features, include a short README
note so onboarding stays accurate.