@kimjansheden/payload-video-processor
v0.1.16
Published
Queued FFmpeg video variant processing for Payload CMS
Readme
@kimjansheden/payload-video-processor
Queued 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.
Features
- ⚙️ 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-cropfor 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.
Installation
pnpm add @kimjansheden/payload-video-processorPeer dependencies (payload, react, react-dom) must already exist in your
Payload project. The package bundles static FFmpeg/ffprobe binaries via
ffmpeg-static; if those are blocked on your platform, set FFMPEG_BIN to a
system ffmpeg binary (for example /opt/homebrew/bin/ffmpeg on macOS/Homebrew).
Quick start
Step 1: Register the plugin
Define presets and register the plugin in your payload.config.ts (or wherever you build your Payload config):
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 plugin
tries a preset named 1080, then hd1080, and finally falls back to the first
configured preset.
Set autoEnqueuePreset to force a specific preset name when auto-enqueueing.
Cropping behavior
Cropping is optional and configured per preset via enableCrop: true.
enableCroponly 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.
Type-safe presets (TypeScript)
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<PresetName> = {
presets,
queue: { redisUrl: process.env.REDIS_URL, concurrency: 1 },
autoEnqueue: true,
autoEnqueuePreset: "hd1080",
autoReplaceOriginal: true,
};Import patterns
ESM:
import videoPlugin from "@kimjansheden/payload-video-processor";CommonJS:
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.
Options reference
| Option | Type | Notes |
| --- | --- | --- |
| presets | Record<string, Preset> | 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. |
Step 2: Ensure the upload collection exposes path (local filesystem)
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 path
on disk). If your upload collection does not already provide a path, add a
read-only field and populate it from your upload staticDir + filename:
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;
},
],
},
};Step 3: Bundle the plugin options for the worker CLI
Provide a worker options module and bundle it to JS (the CLI needs a JS file). Example:
// src/videoPluginOptions.ts
export default videoOptions;tsup src/videoPluginOptions.ts --format esm --platform node --target es2022 --out-dir dist-config --minifyStep 4: Add a minimal Payload config for the worker (recommended)
When you pass --payload-config, the worker can initialize Payload locally and update documents via the local Node API.
// 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:
tsup worker/payload.worker.config.ts --format esm --platform node --target es2022 --out-dir dist-config --minifyStep 5: Start the worker
Start the worker in a separate process:
payload-video-worker \
--config ./dist-config/videoPluginOptions.js \
--payload-config ./dist-config/payload.worker.config.jsTo initialize Payload locally, ensure PAYLOAD_SECRET + DATABASE_URI (or MONGODB_URI) are set. If you
prefer the REST fallback, omit --payload-config and provide
PAYLOAD_REST_URL + PAYLOAD_ADMIN_TOKEN.
The CLI loads .env, .env.local, .env.development, and .env.production
automatically (unless you pass --no-default-env). Additional --env flags can
point to project-specific files.
Example (explicit env + static dir, useful in monorepos):
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/mediaPrefer a fully programmatic setup? Import createWorker directly and pass the
same options object you provide to the plugin.
Step 6: Use the Admin UI
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.
Recommended host project scripts (example)
Most projects bundle both the plugin options and a minimal Payload config for the worker:
{
"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"
}
}Example project (repo)
This repository also includes apps/example-payload, a CLI-only reference
project that demonstrates plugin configuration + worker processing without
shipping a full /admin UI app. See apps/example-payload/README.md.
Scripts
pnpm build– bundles ESM/CJS + admin assets viatsup.pnpm dev– watch mode build useful during plugin development.pnpm typecheck–tsc --noEmitfor type validation.pnpm test– unit tests for helper utilities using Vitest.pnpm worker– development helper that runs the compiled worker entry. (Application projects should prefer thepayload-video-workerCLI.)
Environment variables
| 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. |
Customising output paths
Provide a resolvePaths function to control where variants are written:
videoPlugin({
presets,
resolvePaths: ({ original, presetName }) => ({
dir: path.join("/data/videos", presetName),
filename: `${path.parse(original.filename).name}.${presetName}.mp4`,
url: `/videos/${presetName}/${path.parse(original.filename).name}.mp4`,
}),
});Worker CLI
The installed binary payload-video-worker bootstraps environment variables,
loads your exported plugin options, and starts the queue worker. It automatically
loads .env, .env.local, .env.development, and .env.production (unless you
pass --no-default-env). Supply --env to load additional files, --config to
point at the bundled options module, --payload-config when you want the worker
to initialise Payload locally, and --static-dir if your media folder is not
./public/media.
payload-video-worker \
--config ./dist-config/videoPluginOptions.js \
--payload-config ./dist-config/payload.worker.config.js \
--env .env \
--env cms/.envThe CLI sets common fallbacks (STATIC_DIR, PAYLOAD_CONFIG_PATH,
MONGODB_URI ← DATABASE_URI, default PAYLOAD_SECRET) before invoking
createWorker. It shuts down gracefully on SIGINT/SIGTERM.
Contributing
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.
