@kimjansheden/payload-video-processor 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,14 +24,16 @@ pnpm add @kimjansheden/payload-video-processor
24
24
  Peer dependencies (`payload`, `react`, `react-dom`) must already exist in your
25
25
  Payload project. The package bundles static FFmpeg/ffprobe binaries via
26
26
  `ffmpeg-static`; if those are blocked on your platform, set `FFMPEG_BIN` to a
27
- system ffmpeg binary.
27
+ system ffmpeg binary (for example `/opt/homebrew/bin/ffmpeg` on macOS/Homebrew).
28
28
 
29
29
  ## Quick start
30
30
 
31
- 1. Define presets and register the plugin in your `payload.config.ts`:
31
+ ### Step 1: Register the plugin
32
+
33
+ Define presets and register the plugin in your `payload.config.ts` (or wherever you build your Payload config):
32
34
 
33
35
  ```ts
34
- import { buildConfig } from "payload/config";
36
+ import { buildConfig } from "payload";
35
37
  import { mongooseAdapter } from "@payloadcms/db-mongodb";
36
38
  import videoPlugin from "@kimjansheden/payload-video-processor";
37
39
 
@@ -54,13 +56,16 @@ const videoOptions = {
54
56
  // Auto-enqueue a preset when a new video is uploaded.
55
57
  autoEnqueue: true,
56
58
  // Optional: override the default preset used on create.
57
- autoEnqueuePreset: "hd1080",
59
+ autoEnqueuePreset: "hd720",
58
60
  // Optional: replace the original with the auto-generated variant.
59
61
  autoReplaceOriginal: true,
60
62
  };
61
63
 
62
64
  export default buildConfig({
63
- db: mongooseAdapter({ url: process.env.MONGODB_URI ?? "" }),
65
+ // This plugin works with both DATABASE_URI and MONGODB_URI; the worker CLI maps DATABASE_URI -> MONGODB_URI.
66
+ db: mongooseAdapter({
67
+ url: process.env.DATABASE_URI ?? process.env.MONGODB_URI ?? "",
68
+ }),
64
69
  collections: [
65
70
  /* … */
66
71
  ],
@@ -68,11 +73,23 @@ export default buildConfig({
68
73
  });
69
74
  ```
70
75
 
76
+ 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.
77
+
71
78
  When `autoEnqueue` is `true`, the plugin
72
79
  tries a preset named `1080`, then `hd1080`, and finally falls back to the first
73
80
  configured preset.
74
81
  Set `autoEnqueuePreset` to force a specific preset name when auto-enqueueing.
75
82
 
83
+ ### Cropping behavior
84
+
85
+ Cropping is optional and configured per preset via `enableCrop: true`.
86
+
87
+ - `enableCrop` only exposes crop controls in the Admin UI.
88
+ - Cropping is **opt-in per enqueue**: the generated variant is not cropped unless
89
+ the editor explicitly enables “Apply crop for this enqueue”.
90
+ - If cropping is not enabled, no crop parameters are sent to the worker and the
91
+ full frame is preserved.
92
+
76
93
  ### Type-safe presets (TypeScript)
77
94
 
78
95
  ```ts
@@ -81,7 +98,7 @@ import videoPlugin, { type VideoPluginOptions } from "@kimjansheden/payload-vide
81
98
  const presets = {
82
99
  mobile360: { label: "360p Mobile", args: ["-vf", "scale=-2:360"] },
83
100
  hd1080: { label: "Full HD 1080p", args: ["-vf", "scale=-2:1080"] },
84
- } as const;
101
+ } satisfies VideoPluginOptions["presets"];
85
102
 
86
103
  type PresetName = keyof typeof presets;
87
104
 
@@ -123,8 +140,53 @@ For the worker options module, either export default (ESM) or use
123
140
  | `access` | `AccessControl` | Optional access control hooks. |
124
141
  | `resolvePaths` | `(args) => ResolvePathsResult` | Override output directory/filename/URL. |
125
142
 
126
- 1. Provide a worker options module and bundle it to JS (the CLI needs a JS file).
127
- Example setup:
143
+ ### Step 2: Ensure the upload collection exposes `path` (local filesystem)
144
+
145
+ The worker needs to read the original upload from disk. For local filesystem
146
+ storage, the worker reads the original file path from `doc.path` (absolute path
147
+ on disk). If your upload collection does not already provide a `path`, add a
148
+ read-only field and populate it from your upload `staticDir` + `filename`:
149
+
150
+ ```ts
151
+ import path from "node:path";
152
+ import { fileURLToPath } from "node:url";
153
+ import type { CollectionConfig } from "payload";
154
+
155
+ const filename = fileURLToPath(import.meta.url);
156
+ const dirname = path.dirname(filename);
157
+
158
+ const staticDir =
159
+ process.env.STATIC_DIR ?? path.resolve(dirname, "../../public/media");
160
+
161
+ export const Media: CollectionConfig = {
162
+ slug: "media",
163
+ upload: {
164
+ staticDir,
165
+ mimeTypes: ["video/mp4", "video/webm", "video/quicktime"],
166
+ },
167
+ fields: [
168
+ {
169
+ name: "path",
170
+ type: "text",
171
+ admin: { readOnly: true, position: "sidebar" },
172
+ },
173
+ ],
174
+ hooks: {
175
+ afterRead: [
176
+ ({ doc }) => {
177
+ if (doc && typeof doc.filename === "string") {
178
+ doc.path = path.join(staticDir, doc.filename);
179
+ }
180
+ return doc;
181
+ },
182
+ ],
183
+ },
184
+ };
185
+ ```
186
+
187
+ ### Step 3: Bundle the plugin options for the worker CLI
188
+
189
+ Provide a worker options module and bundle it to JS (the CLI needs a JS file). Example:
128
190
 
129
191
  ```ts
130
192
  // src/videoPluginOptions.ts
@@ -135,16 +197,47 @@ export default videoOptions;
135
197
  tsup src/videoPluginOptions.ts --format esm --platform node --target es2022 --out-dir dist-config --minify
136
198
  ```
137
199
 
138
- 1. Start a worker in a separate process:
200
+ ### Step 4: Add a minimal Payload config for the worker (recommended)
201
+
202
+ When you pass `--payload-config`, the worker can initialize Payload locally and update documents via the local Node API.
203
+
204
+ ```ts
205
+ // worker/payload.worker.config.ts
206
+ import { mongooseAdapter } from "@payloadcms/db-mongodb";
207
+ import { buildConfig } from "payload";
208
+ import videoPlugin from "@kimjansheden/payload-video-processor";
209
+
210
+ import { Media } from "../src/collections/Media";
211
+ import videoPluginOptions from "../src/videoPluginOptions";
212
+
213
+ export default buildConfig({
214
+ telemetry: false,
215
+ secret: process.env.PAYLOAD_SECRET || "dev-secret",
216
+ db: mongooseAdapter({
217
+ url: process.env.MONGODB_URI || process.env.DATABASE_URI || "",
218
+ }),
219
+ plugins: [videoPlugin(videoPluginOptions)],
220
+ collections: [Media],
221
+ });
222
+ ```
223
+
224
+ Bundle it:
225
+
226
+ ```bash
227
+ tsup worker/payload.worker.config.ts --format esm --platform node --target es2022 --out-dir dist-config --minify
228
+ ```
229
+
230
+ ### Step 5: Start the worker
231
+
232
+ Start the worker in a separate process:
139
233
 
140
234
  ```bash
141
235
  payload-video-worker \
142
236
  --config ./dist-config/videoPluginOptions.js \
143
- --env .env
237
+ --payload-config ./dist-config/payload.worker.config.js
144
238
  ```
145
239
 
146
- If you want the worker to initialize Payload locally, also pass
147
- `--payload-config` and ensure `PAYLOAD_SECRET` + `MONGODB_URI` are set. If you
240
+ To initialize Payload locally, ensure `PAYLOAD_SECRET` + `DATABASE_URI` (or `MONGODB_URI`) are set. If you
148
241
  prefer the REST fallback, omit `--payload-config` and provide
149
242
  `PAYLOAD_REST_URL` + `PAYLOAD_ADMIN_TOKEN`.
150
243
 
@@ -152,13 +245,45 @@ The CLI loads `.env`, `.env.local`, `.env.development`, and `.env.production`
152
245
  automatically (unless you pass `--no-default-env`). Additional `--env` flags can
153
246
  point to project-specific files.
154
247
 
248
+ Example (explicit env + static dir, useful in monorepos):
249
+
250
+ ```bash
251
+ FFMPEG_BIN=/opt/homebrew/bin/ffmpeg payload-video-worker \
252
+ --no-default-env \
253
+ --config ./dist-config/videoPluginOptions.js \
254
+ --payload-config ./dist-config/payload.worker.config.js \
255
+ --env .env \
256
+ --env .env.development \
257
+ --static-dir ./public/media
258
+ ```
259
+
155
260
  Prefer a fully programmatic setup? Import `createWorker` directly and pass the
156
261
  same options object you provide to the plugin.
157
262
 
158
- 1. In the Admin UI a "Video processing" panel appears on any upload collection
159
- that accepts `video/*` mime types. Editors can enqueue presets, preview
160
- variants, replace the original file with a processed version, or delete
161
- unwanted variants without writing custom endpoints.
263
+ ### Step 6: Use the Admin UI
264
+
265
+ 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.
266
+
267
+ ### Recommended host project scripts (example)
268
+
269
+ Most projects bundle both the plugin options and a minimal Payload config for the worker:
270
+
271
+ ```jsonc
272
+ {
273
+ "scripts": {
274
+ "bundle:video-plugin-options": "tsup src/videoPluginOptions.ts --format esm --platform node --target es2022 --out-dir dist-config --minify",
275
+ "bundle:payload-worker-config": "tsup worker/payload.worker.config.ts --format esm --platform node --target es2022 --out-dir dist-config --minify",
276
+ "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",
277
+ "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"
278
+ }
279
+ }
280
+ ```
281
+
282
+ ## Example project (repo)
283
+
284
+ This repository also includes `apps/example-payload`, a **CLI-only** reference
285
+ project that demonstrates plugin configuration + worker processing without
286
+ shipping a full `/admin` UI app. See `apps/example-payload/README.md`.
162
287
 
163
288
  ## Scripts
164
289
 
@@ -176,7 +301,7 @@ same options object you provide to the plugin.
176
301
  | `REDIS_URL` | Default Redis connection string for queue + worker. |
177
302
  | `FFMPEG_BIN` | Optional path to a system ffmpeg binary (overrides `ffmpeg-static`). |
178
303
  | `STATIC_DIR` | Base media directory for the worker (used when resolving paths). |
179
- | `PAYLOAD_SECRET` / `MONGODB_URI` | Required to bootstrap the Payload local API from the worker. |
304
+ | `PAYLOAD_SECRET` / `DATABASE_URI` / `MONGODB_URI` | Required to bootstrap the Payload local API from the worker. |
180
305
  | `PAYLOAD_REST_URL` + `PAYLOAD_ADMIN_TOKEN` | REST fallback when local init is not possible. |
181
306
  | `PAYLOAD_PUBLIC_URL` / `PAYLOAD_SERVER_URL` | Alternative base URL for REST fallback if `PAYLOAD_REST_URL` is not set. |
182
307
  | `PAYLOAD_CONFIG_PATH` | Absolute/relative path to the host `payload.config.ts` for worker bootstrap. |
@@ -128,7 +128,7 @@ var mergeReadOnlyFields = (current, doc) => {
128
128
  var VideoField = (props) => {
129
129
  const { useEffect, useMemo, useState, useCallback, useRef } = React__default.default;
130
130
  const { field } = props;
131
- const { id, lastUpdateTime, setData, data: formData } = ui.useDocumentInfo();
131
+ const { id, setData, data: formData } = ui.useDocumentInfo();
132
132
  const formModified = ui.useFormModified();
133
133
  const custom = field.custom ?? (isVideoVariantFieldConfig(props) ? props : void 0);
134
134
  const presets = custom?.presets ?? {};
@@ -144,13 +144,14 @@ var VideoField = (props) => {
144
144
  return value;
145
145
  }, [id]);
146
146
  const [EasyCrop, setEasyCrop] = useState(null);
147
+ const [applyCrop, setApplyCrop] = useState(false);
147
148
  useEffect(() => {
148
- if (typeof window !== "undefined" && !EasyCrop) {
149
+ if (typeof window !== "undefined" && applyCrop && !EasyCrop) {
149
150
  import('react-easy-crop').then((module) => {
150
151
  setEasyCrop(() => module.default);
151
152
  });
152
153
  }
153
- }, [EasyCrop]);
154
+ }, [EasyCrop, applyCrop]);
154
155
  const [selectedPreset, setSelectedPreset] = useState(
155
156
  presetNames[0]
156
157
  );
@@ -170,13 +171,27 @@ var VideoField = (props) => {
170
171
  const [zoom, setZoom] = useState(1);
171
172
  const [cropSelection, setCropSelection] = useState(DEFAULT_CROP);
172
173
  const expectedPresetRef = useRef(null);
174
+ const formDataRef = useRef(formData);
175
+ const formModifiedRef = useRef(formModified);
173
176
  const sleep = useCallback(
174
177
  (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
175
178
  []
176
179
  );
180
+ useEffect(() => {
181
+ formDataRef.current = formData;
182
+ }, [formData]);
183
+ useEffect(() => {
184
+ formModifiedRef.current = formModified;
185
+ }, [formModified]);
177
186
  useEffect(() => {
178
187
  expectedPresetRef.current = expectedPreset;
179
188
  }, [expectedPreset]);
189
+ useEffect(() => {
190
+ setApplyCrop(false);
191
+ setCropSelection(DEFAULT_CROP);
192
+ setCropState({ x: 0, y: 0 });
193
+ setZoom(1);
194
+ }, [selectedPreset]);
180
195
  useEffect(() => {
181
196
  if (!processingStatus) return;
182
197
  const statusJobId = typeof processingStatus.jobId === "string" ? processingStatus.jobId.trim() : "";
@@ -467,7 +482,7 @@ var VideoField = (props) => {
467
482
  return null;
468
483
  }
469
484
  setDocData(nextDoc);
470
- const nextFormData = formModified ? mergeReadOnlyFields(formData, nextDoc) : nextDoc;
485
+ const nextFormData = formModifiedRef.current ? mergeReadOnlyFields(formDataRef.current, nextDoc) : nextDoc;
471
486
  setData(nextFormData);
472
487
  setProcessingStatus(nextDoc.videoProcessingStatus ?? null);
473
488
  const docVariants = Array.isArray(nextDoc.variants) ? nextDoc.variants : [];
@@ -481,10 +496,10 @@ var VideoField = (props) => {
481
496
  } finally {
482
497
  setLoadingDoc(false);
483
498
  }
484
- }, [apiBase, custom, docId, formData, formModified, setData]);
499
+ }, [apiBase, custom, docId, setData]);
485
500
  useEffect(() => {
486
501
  void fetchDocument();
487
- }, [fetchDocument, jobStatus?.state, lastUpdateTime]);
502
+ }, [fetchDocument]);
488
503
  const enqueue = useCallback(async () => {
489
504
  if (!custom || !docId || !selectedPreset) return;
490
505
  try {
@@ -495,16 +510,23 @@ var VideoField = (props) => {
495
510
  const data = await sendEnqueueRequest({
496
511
  documentId: docId,
497
512
  presetName: selectedPreset,
498
- crop: allowCrop ? cropSelection : void 0
513
+ crop: allowCrop && applyCrop ? cropSelection : void 0
499
514
  });
500
515
  setJobStatus(data);
501
516
  setPollingJobId(String(data.id));
517
+ if (allowCrop && applyCrop) {
518
+ setApplyCrop(false);
519
+ setCropSelection(DEFAULT_CROP);
520
+ setCropState({ x: 0, y: 0 });
521
+ setZoom(1);
522
+ }
502
523
  } catch (enqueueError) {
503
524
  setError(
504
525
  enqueueError instanceof Error ? enqueueError.message : "Failed to enqueue job."
505
526
  );
506
527
  }
507
528
  }, [
529
+ applyCrop,
508
530
  custom,
509
531
  cropSelection,
510
532
  docId,
@@ -599,19 +621,24 @@ var VideoField = (props) => {
599
621
  );
600
622
  useEffect(() => {
601
623
  if (!cropEnabled) {
624
+ setApplyCrop(false);
602
625
  setCropSelection(DEFAULT_CROP);
603
626
  setCropState({ x: 0, y: 0 });
604
627
  setZoom(1);
605
628
  }
606
629
  }, [cropEnabled]);
607
- const handleCropComplete = useCallback((area) => {
608
- setCropSelection({
609
- width: area.width / 100,
610
- height: area.height / 100,
611
- x: area.x / 100,
612
- y: area.y / 100
613
- });
614
- }, []);
630
+ const handleCropComplete = useCallback(
631
+ (area) => {
632
+ if (!applyCrop) return;
633
+ setCropSelection({
634
+ width: area.width / 100,
635
+ height: area.height / 100,
636
+ x: area.x / 100,
637
+ y: area.y / 100
638
+ });
639
+ },
640
+ [applyCrop]
641
+ );
615
642
  const handleTogglePreview = useCallback((key) => {
616
643
  setPreviewKey((current) => current === key ? null : key);
617
644
  }, []);
@@ -778,46 +805,67 @@ var VideoField = (props) => {
778
805
  ] }),
779
806
  cropEnabled && docData?.url ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-3", children: [
780
807
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs font-medium uppercase tracking-wide text-slate-500", children: "Crop" }),
781
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "video-crop-wrapper", children: EasyCrop ? /* @__PURE__ */ jsxRuntime.jsx(
782
- EasyCrop,
783
- {
784
- video: docData.url,
785
- crop: cropState,
786
- zoom,
787
- rotation: 0,
788
- aspect: 4 / 3,
789
- minZoom: 1,
790
- maxZoom: 3,
791
- cropShape: "rect",
792
- zoomSpeed: 1,
793
- restrictPosition: true,
794
- mediaProps: {},
795
- cropperProps: {},
796
- style: {},
797
- classes: {},
798
- keyboardStep: 1,
799
- onCropChange: setCropState,
800
- onZoomChange: setZoom,
801
- onCropComplete: handleCropComplete,
802
- objectFit: "contain",
803
- showGrid: true
804
- }
805
- ) : /* @__PURE__ */ jsxRuntime.jsx("div", { children: "Loading cropper..." }) }),
806
808
  /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "flex items-center gap-2 text-xs text-slate-600", children: [
807
- "Zoom",
808
809
  /* @__PURE__ */ jsxRuntime.jsx(
809
810
  "input",
810
811
  {
811
- type: "range",
812
- min: 1,
813
- max: 3,
814
- step: 0.1,
815
- value: zoom,
816
- onChange: (event) => setZoom(Number(event.target.value)),
817
- className: "w-48"
812
+ type: "checkbox",
813
+ checked: applyCrop,
814
+ onChange: (event) => {
815
+ const nextApplyCrop = event.target.checked;
816
+ setApplyCrop(nextApplyCrop);
817
+ if (!nextApplyCrop) {
818
+ setCropSelection(DEFAULT_CROP);
819
+ setCropState({ x: 0, y: 0 });
820
+ setZoom(1);
821
+ }
822
+ }
818
823
  }
819
- )
820
- ] })
824
+ ),
825
+ "Apply crop for this enqueue"
826
+ ] }),
827
+ applyCrop ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
828
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "video-crop-wrapper", children: EasyCrop ? /* @__PURE__ */ jsxRuntime.jsx(
829
+ EasyCrop,
830
+ {
831
+ video: docData.url,
832
+ crop: cropState,
833
+ zoom,
834
+ rotation: 0,
835
+ aspect: 4 / 3,
836
+ minZoom: 1,
837
+ maxZoom: 3,
838
+ cropShape: "rect",
839
+ zoomSpeed: 1,
840
+ restrictPosition: true,
841
+ mediaProps: {},
842
+ cropperProps: {},
843
+ style: {},
844
+ classes: {},
845
+ keyboardStep: 1,
846
+ onCropChange: setCropState,
847
+ onZoomChange: setZoom,
848
+ onCropComplete: handleCropComplete,
849
+ objectFit: "contain",
850
+ showGrid: true
851
+ }
852
+ ) : /* @__PURE__ */ jsxRuntime.jsx("div", { children: "Loading cropper..." }) }),
853
+ /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "flex items-center gap-2 text-xs text-slate-600", children: [
854
+ "Zoom",
855
+ /* @__PURE__ */ jsxRuntime.jsx(
856
+ "input",
857
+ {
858
+ type: "range",
859
+ min: 1,
860
+ max: 3,
861
+ step: 0.1,
862
+ value: zoom,
863
+ onChange: (event) => setZoom(Number(event.target.value)),
864
+ className: "w-48"
865
+ }
866
+ )
867
+ ] })
868
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-slate-500", children: "Cropping is off by default. Enable it to crop the generated variant." })
821
869
  ] }) : null,
822
870
  posterUrl ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-2 text-xs text-slate-600", children: [
823
871
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-semibold uppercase tracking-wide text-slate-500", children: "Poster" }),