@frkntmbs/strapi-plugin-video-optimizer 1.0.0
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/LICENSE +21 -0
- package/README.md +286 -0
- package/admin/custom.d.ts +8 -0
- package/admin/src/buildVersion.ts +3 -0
- package/admin/src/components/AssetOptimizationLabel.tsx +61 -0
- package/admin/src/components/BridgeProviders.tsx +123 -0
- package/admin/src/components/MediaLibraryCacheBridge.tsx +24 -0
- package/admin/src/components/MediaLibraryCardActionsBridge.tsx +249 -0
- package/admin/src/components/MediaLibraryJobWatcher.tsx +136 -0
- package/admin/src/components/MediaLibraryProgressBridge.tsx +97 -0
- package/admin/src/components/OptimizationChoicePicker.tsx +65 -0
- package/admin/src/components/OptimizationResizeFields.tsx +120 -0
- package/admin/src/components/OptimizationVideoFields.tsx +217 -0
- package/admin/src/components/UploadEnhancerBridge.tsx +205 -0
- package/admin/src/components/upload/PendingAssetStep.tsx +97 -0
- package/admin/src/defaultGlobalSettings.ts +32 -0
- package/admin/src/hooks/useDefaultOptimizationMode.ts +24 -0
- package/admin/src/hooks/useUploadWithOptimizer.ts +45 -0
- package/admin/src/index.ts +84 -0
- package/admin/src/pages/SettingsPage.tsx +208 -0
- package/admin/src/pluginId.ts +79 -0
- package/admin/src/translations/en.json +74 -0
- package/admin/src/translations/tr.json +74 -0
- package/admin/src/utils/adminFetch.ts +57 -0
- package/admin/src/utils/captureQueryClient.ts +34 -0
- package/admin/src/utils/debugMediaLibraryProgress.ts +70 -0
- package/admin/src/utils/extractAssetDimensions.ts +22 -0
- package/admin/src/utils/initJobPoller.ts +173 -0
- package/admin/src/utils/initMediaLibraryCardActions.ts +308 -0
- package/admin/src/utils/initMediaLibraryProgress.ts +219 -0
- package/admin/src/utils/initUploadEnhancer.ts +447 -0
- package/admin/src/utils/invalidateMediaLibrary.ts +203 -0
- package/admin/src/utils/jobProgressStore.ts +113 -0
- package/admin/src/utils/mediaLibraryCardMatch.ts +414 -0
- package/admin/src/utils/mediaLibraryCardStore.ts +223 -0
- package/admin/src/utils/mediaLibraryQueryBridge.ts +113 -0
- package/admin/src/utils/mediaLibraryRoute.ts +9 -0
- package/admin/src/utils/optimizationFields.ts +17 -0
- package/admin/src/utils/probeVideoDimensions.ts +94 -0
- package/admin/src/utils/uploadAssetStore.ts +670 -0
- package/admin/tsconfig.json +8 -0
- package/dist/admin/SettingsPage-CN2fR83m.js +150 -0
- package/dist/admin/SettingsPage-D6e536P0.mjs +150 -0
- package/dist/admin/en-CqM903j3.js +77 -0
- package/dist/admin/en-CsHicGzL.mjs +77 -0
- package/dist/admin/index-BjWoS0YU.js +2542 -0
- package/dist/admin/index-Cs_uiChW.mjs +2541 -0
- package/dist/admin/index-DOuHOS2G.js +8799 -0
- package/dist/admin/index-rAmxCQz6.mjs +8781 -0
- package/dist/admin/index.js +4 -0
- package/dist/admin/index.mjs +4 -0
- package/dist/admin/tr-Y0-ANilh.mjs +77 -0
- package/dist/admin/tr-muzHkdC4.js +77 -0
- package/dist/server/index.js +1538 -0
- package/dist/server/index.mjs +1533 -0
- package/package.json +100 -0
- package/server/index.js +1 -0
- package/server/src/bootstrap.ts +377 -0
- package/server/src/buildVersion.ts +1 -0
- package/server/src/config/defaults.ts +91 -0
- package/server/src/config/index.ts +51 -0
- package/server/src/constants.ts +83 -0
- package/server/src/controllers/index.ts +7 -0
- package/server/src/controllers/job.ts +102 -0
- package/server/src/controllers/preference.ts +206 -0
- package/server/src/index.ts +15 -0
- package/server/src/register.ts +19 -0
- package/server/src/routes/index.ts +103 -0
- package/server/src/services/index.ts +9 -0
- package/server/src/services/job-queue.ts +663 -0
- package/server/src/services/optimizer.ts +284 -0
- package/server/src/services/preference.ts +172 -0
- package/server/src/utils/request-context.ts +7 -0
- package/server/src/utils/upload-preferences-context.ts +202 -0
- package/server/tsconfig.json +8 -0
- package/strapi-admin.js +7 -0
- package/strapi-server.js +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@frkntmbs/strapi-plugin-video-optimizer",
|
|
3
|
+
"publishConfig": {
|
|
4
|
+
"access": "public"
|
|
5
|
+
},
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
"description": "Per-video optimization controls for the Strapi Media Library upload flow with async FFmpeg processing.",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"strapi",
|
|
11
|
+
"strapi-plugin",
|
|
12
|
+
"plugin",
|
|
13
|
+
"video",
|
|
14
|
+
"optimizer",
|
|
15
|
+
"ffmpeg",
|
|
16
|
+
"upload",
|
|
17
|
+
"media-library",
|
|
18
|
+
"h264",
|
|
19
|
+
"webm"
|
|
20
|
+
],
|
|
21
|
+
"author": "frkntmbs",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/frkntmbs/strapi-plugin-video-optimizer.git"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/frkntmbs/strapi-plugin-video-optimizer/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/frkntmbs/strapi-plugin-video-optimizer#readme",
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"admin",
|
|
33
|
+
"server",
|
|
34
|
+
"strapi-admin.js",
|
|
35
|
+
"strapi-server.js",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "strapi-plugin build",
|
|
41
|
+
"verify": "strapi-plugin verify",
|
|
42
|
+
"prepublishOnly": "npm run build && npm run verify",
|
|
43
|
+
"watch": "strapi-plugin watch",
|
|
44
|
+
"watch:link": "strapi-plugin watch:link"
|
|
45
|
+
},
|
|
46
|
+
"exports": {
|
|
47
|
+
"./strapi-admin": {
|
|
48
|
+
"source": "./admin/src/index.ts",
|
|
49
|
+
"import": "./dist/admin/index.mjs",
|
|
50
|
+
"require": "./dist/admin/index.js",
|
|
51
|
+
"default": "./dist/admin/index.js"
|
|
52
|
+
},
|
|
53
|
+
"./strapi-server": {
|
|
54
|
+
"source": "./server/index.js",
|
|
55
|
+
"import": "./dist/server/index.mjs",
|
|
56
|
+
"require": "./dist/server/index.js",
|
|
57
|
+
"default": "./dist/server/index.js"
|
|
58
|
+
},
|
|
59
|
+
"./package.json": "./package.json"
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"ffmpeg-static": "^5.2.0",
|
|
63
|
+
"fluent-ffmpeg": "^2.1.3"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@strapi/sdk-plugin": "^6.1.1",
|
|
67
|
+
"@strapi/strapi": "^5.48.0",
|
|
68
|
+
"@strapi/typescript-utils": "^5.48.0",
|
|
69
|
+
"@tanstack/react-query": "^5.64.0",
|
|
70
|
+
"@types/fluent-ffmpeg": "^2.1.27",
|
|
71
|
+
"esbuild-register": "^3.6.0",
|
|
72
|
+
"react": "^18.3.1",
|
|
73
|
+
"react-dom": "^18.3.1",
|
|
74
|
+
"react-intl": "^6.6.2",
|
|
75
|
+
"react-router-dom": "^6.30.3",
|
|
76
|
+
"styled-components": "^6.1.0",
|
|
77
|
+
"typescript": "^5.7.0",
|
|
78
|
+
"yalc": "^1.0.0-pre.53"
|
|
79
|
+
},
|
|
80
|
+
"peerDependencies": {
|
|
81
|
+
"@strapi/design-system": "^2.0.0",
|
|
82
|
+
"@strapi/icons": "^2.0.0",
|
|
83
|
+
"@strapi/strapi": "^5.0.0",
|
|
84
|
+
"@strapi/utils": "^5.0.0",
|
|
85
|
+
"react": "^17.0.0 || ^18.0.0",
|
|
86
|
+
"react-dom": "^17.0.0 || ^18.0.0",
|
|
87
|
+
"react-router-dom": "^6.0.0",
|
|
88
|
+
"styled-components": "^6.0.0"
|
|
89
|
+
},
|
|
90
|
+
"engines": {
|
|
91
|
+
"node": ">=20.0.0 <=24.x.x",
|
|
92
|
+
"npm": ">=6.0.0"
|
|
93
|
+
},
|
|
94
|
+
"strapi": {
|
|
95
|
+
"name": "video-optimizer",
|
|
96
|
+
"displayName": "Video Optimizer",
|
|
97
|
+
"description": "Per-video optimization controls for Media Library uploads with async FFmpeg processing",
|
|
98
|
+
"kind": "plugin"
|
|
99
|
+
}
|
|
100
|
+
}
|
package/server/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './src/index.ts';
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import type { Core } from '@strapi/strapi';
|
|
2
|
+
import { SERVER_BUILD_MARKER } from './buildVersion';
|
|
3
|
+
import {
|
|
4
|
+
AUDIO_MODES,
|
|
5
|
+
FFMPEG_PRESETS,
|
|
6
|
+
PLUGIN_ID,
|
|
7
|
+
VIDEO_CODECS,
|
|
8
|
+
VIDEO_FORMATS,
|
|
9
|
+
codecForFormat,
|
|
10
|
+
formatForCodec,
|
|
11
|
+
isVideoMime,
|
|
12
|
+
type OptimizationChoice,
|
|
13
|
+
type OptimizationSettings,
|
|
14
|
+
} from './constants';
|
|
15
|
+
import { uploadContext } from './utils/request-context';
|
|
16
|
+
import {
|
|
17
|
+
clearFallbackUploadPreferences,
|
|
18
|
+
clearUploadBatchPreferences,
|
|
19
|
+
consumeUploadPreference,
|
|
20
|
+
createUploadContext,
|
|
21
|
+
isUploadRoute,
|
|
22
|
+
optimizerUploadContext,
|
|
23
|
+
parseUploadPreferences,
|
|
24
|
+
registerUploadBatchPreferences,
|
|
25
|
+
resolveUploadBatchPreference,
|
|
26
|
+
setFallbackUploadPreferences,
|
|
27
|
+
stashUploadPreference,
|
|
28
|
+
takePendingPreference,
|
|
29
|
+
} from './utils/upload-preferences-context';
|
|
30
|
+
|
|
31
|
+
const isValidChoice = (choice: unknown): choice is OptimizationChoice =>
|
|
32
|
+
typeof choice === 'string' && ['original', 'global', 'custom'].includes(choice);
|
|
33
|
+
|
|
34
|
+
const isValidFormat = (format: unknown): format is OptimizationSettings['defaultFormat'] =>
|
|
35
|
+
typeof format === 'string' && VIDEO_FORMATS.includes(format as OptimizationSettings['defaultFormat']);
|
|
36
|
+
|
|
37
|
+
const isValidCodec = (codec: unknown): codec is OptimizationSettings['videoCodec'] =>
|
|
38
|
+
typeof codec === 'string' && VIDEO_CODECS.includes(codec as OptimizationSettings['videoCodec']);
|
|
39
|
+
|
|
40
|
+
const isValidAudioMode = (mode: unknown): mode is OptimizationSettings['audioMode'] =>
|
|
41
|
+
typeof mode === 'string' && AUDIO_MODES.includes(mode as OptimizationSettings['audioMode']);
|
|
42
|
+
|
|
43
|
+
const isValidPreset = (preset: unknown): preset is OptimizationSettings['preset'] =>
|
|
44
|
+
typeof preset === 'string' && FFMPEG_PRESETS.includes(preset as OptimizationSettings['preset']);
|
|
45
|
+
|
|
46
|
+
const normalizeCustomSettings = (
|
|
47
|
+
custom: unknown,
|
|
48
|
+
global: OptimizationSettings
|
|
49
|
+
): OptimizationSettings | undefined => {
|
|
50
|
+
if (!custom || typeof custom !== 'object') {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const value = custom as Partial<OptimizationSettings>;
|
|
55
|
+
|
|
56
|
+
if (!isValidFormat(value.defaultFormat) && !isValidCodec(value.videoCodec)) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const defaultFormat = isValidFormat(value.defaultFormat)
|
|
61
|
+
? value.defaultFormat
|
|
62
|
+
: isValidCodec(value.videoCodec)
|
|
63
|
+
? formatForCodec(value.videoCodec)
|
|
64
|
+
: global.defaultFormat;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
defaultFormat,
|
|
68
|
+
videoCodec: isValidCodec(value.videoCodec) ? value.videoCodec : codecForFormat(defaultFormat),
|
|
69
|
+
crf: typeof value.crf === 'number' ? value.crf : Number(value.crf ?? global.crf),
|
|
70
|
+
preset: isValidPreset(value.preset) ? value.preset : global.preset,
|
|
71
|
+
maxWidth:
|
|
72
|
+
typeof value.maxWidth === 'number' && value.maxWidth >= 1
|
|
73
|
+
? value.maxWidth
|
|
74
|
+
: global.maxWidth,
|
|
75
|
+
maxHeight:
|
|
76
|
+
typeof value.maxHeight === 'number' && value.maxHeight >= 1
|
|
77
|
+
? value.maxHeight
|
|
78
|
+
: global.maxHeight,
|
|
79
|
+
audioMode: isValidAudioMode(value.audioMode) ? value.audioMode : global.audioMode,
|
|
80
|
+
audioBitrate:
|
|
81
|
+
typeof value.audioBitrate === 'string' ? value.audioBitrate : String(value.audioBitrate ?? global.audioBitrate),
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const configToSettings = (config: {
|
|
86
|
+
defaultFormat: OptimizationSettings['defaultFormat'];
|
|
87
|
+
videoCodec: OptimizationSettings['videoCodec'];
|
|
88
|
+
crf: number;
|
|
89
|
+
preset: OptimizationSettings['preset'];
|
|
90
|
+
maxWidth: number;
|
|
91
|
+
maxHeight: number;
|
|
92
|
+
audioMode: OptimizationSettings['audioMode'];
|
|
93
|
+
audioBitrate: string;
|
|
94
|
+
}): OptimizationSettings => ({
|
|
95
|
+
defaultFormat: config.defaultFormat,
|
|
96
|
+
videoCodec: config.videoCodec,
|
|
97
|
+
crf: config.crf,
|
|
98
|
+
preset: config.preset,
|
|
99
|
+
maxWidth: config.maxWidth,
|
|
100
|
+
maxHeight: config.maxHeight,
|
|
101
|
+
audioMode: config.audioMode,
|
|
102
|
+
audioBitrate: config.audioBitrate,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const runWithUser = <T>(userId: number | undefined, fn: () => Promise<T>) => {
|
|
106
|
+
if (userId === undefined) {
|
|
107
|
+
return fn();
|
|
108
|
+
}
|
|
109
|
+
return uploadContext.run({ userId }, fn);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const applyPreferenceToEntity = (
|
|
113
|
+
entity: Record<string, unknown>,
|
|
114
|
+
fileInfo: Record<string, unknown>,
|
|
115
|
+
mime: string,
|
|
116
|
+
globalDefaults: OptimizationSettings
|
|
117
|
+
) => {
|
|
118
|
+
if (!isVideoMime(mime)) {
|
|
119
|
+
const batchPreference = resolveUploadBatchPreference(
|
|
120
|
+
String(entity.name ?? fileInfo.name ?? ''),
|
|
121
|
+
typeof fileInfo.optimizerAssetId === 'string' ? fileInfo.optimizerAssetId : undefined
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
if (!batchPreference) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const fileName = String(entity.name ?? fileInfo.name ?? '');
|
|
130
|
+
const assetId =
|
|
131
|
+
typeof fileInfo.optimizerAssetId === 'string' ? fileInfo.optimizerAssetId : undefined;
|
|
132
|
+
|
|
133
|
+
const preference = consumeUploadPreference(fileName, assetId);
|
|
134
|
+
|
|
135
|
+
const applyPreference = (choice: OptimizationChoice, custom?: unknown) => {
|
|
136
|
+
entity.optimizationChoice = choice;
|
|
137
|
+
|
|
138
|
+
const normalizedCustom =
|
|
139
|
+
choice === 'custom' ? normalizeCustomSettings(custom, globalDefaults) : undefined;
|
|
140
|
+
|
|
141
|
+
if (normalizedCustom) {
|
|
142
|
+
entity.optimizationCustom = normalizedCustom;
|
|
143
|
+
} else {
|
|
144
|
+
delete entity.optimizationCustom;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (assetId) {
|
|
148
|
+
entity.optimizerAssetId = assetId;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
stashUploadPreference(fileName, assetId, { choice, custom: normalizedCustom });
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
if (preference) {
|
|
155
|
+
applyPreference(preference.choice, preference.custom);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (fileInfo.optimizationChoice && isValidChoice(fileInfo.optimizationChoice)) {
|
|
160
|
+
applyPreference(fileInfo.optimizationChoice, fileInfo.optimizationCustom);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const enqueueVideoJobs = async (
|
|
165
|
+
strapi: Core.Strapi,
|
|
166
|
+
files: Array<Record<string, unknown>>,
|
|
167
|
+
globalDefaults: OptimizationSettings
|
|
168
|
+
) => {
|
|
169
|
+
const preferenceService = strapi.plugin(PLUGIN_ID).service('preference');
|
|
170
|
+
const jobQueue = strapi.plugin(PLUGIN_ID).service('job-queue');
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
for (const file of files) {
|
|
174
|
+
if (!isVideoMime(file.mime)) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const fileName = String(file.name ?? '');
|
|
179
|
+
const assetId =
|
|
180
|
+
typeof file.optimizerAssetId === 'string' ? file.optimizerAssetId : undefined;
|
|
181
|
+
|
|
182
|
+
const batchPreference = resolveUploadBatchPreference(fileName, assetId);
|
|
183
|
+
const pending = takePendingPreference(fileName, assetId);
|
|
184
|
+
const optimizationChoice =
|
|
185
|
+
batchPreference?.choice ??
|
|
186
|
+
pending?.choice ??
|
|
187
|
+
(isValidChoice(file.optimizationChoice) ? file.optimizationChoice : undefined);
|
|
188
|
+
const optimizationCustom =
|
|
189
|
+
(batchPreference?.choice === 'custom'
|
|
190
|
+
? normalizeCustomSettings(batchPreference.custom, globalDefaults)
|
|
191
|
+
: undefined) ??
|
|
192
|
+
pending?.custom ??
|
|
193
|
+
(file.optimizationCustom as Partial<OptimizationSettings> | undefined);
|
|
194
|
+
|
|
195
|
+
const resolved = await preferenceService.resolveOptimization({
|
|
196
|
+
optimizationChoice,
|
|
197
|
+
optimizationCustom,
|
|
198
|
+
mime: file.mime as string,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (resolved.skip || !resolved.settings || !file.id) {
|
|
202
|
+
strapi.log.debug(
|
|
203
|
+
`[video-optimizer] Skipping file ${file.id} (${fileName}): choice=${optimizationChoice ?? 'default'}, skip=${resolved.skip}`
|
|
204
|
+
);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
strapi.log.info(
|
|
209
|
+
`[video-optimizer] Queueing optimization for file ${file.id} (${fileName}) choice=${optimizationChoice ?? 'default'} → ${resolved.settings.defaultFormat}`
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
await jobQueue.enqueue({
|
|
213
|
+
fileId: Number(file.id),
|
|
214
|
+
settings: resolved.settings,
|
|
215
|
+
choice: optimizationChoice,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
} finally {
|
|
219
|
+
clearUploadBatchPreferences();
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const wrapUploadController = (
|
|
224
|
+
strapi: Core.Strapi,
|
|
225
|
+
handler: (ctx: { request: { body?: Record<string, unknown> } }) => Promise<unknown>
|
|
226
|
+
) => {
|
|
227
|
+
return async (ctx: { request: { body?: Record<string, unknown> } }) => {
|
|
228
|
+
const preferences = parseUploadPreferences(ctx.request.body);
|
|
229
|
+
|
|
230
|
+
if (preferences.length === 0) {
|
|
231
|
+
return handler(ctx);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
registerUploadBatchPreferences(preferences);
|
|
235
|
+
|
|
236
|
+
strapi.log.info(
|
|
237
|
+
`[video-optimizer] Controller preferences (${preferences.length}, ${SERVER_BUILD_MARKER}): ${preferences
|
|
238
|
+
.map((entry) => {
|
|
239
|
+
const format =
|
|
240
|
+
entry.preference.choice === 'custom'
|
|
241
|
+
? entry.preference.custom?.defaultFormat ?? '?'
|
|
242
|
+
: entry.preference.choice;
|
|
243
|
+
return `${entry.fileName}=${format}`;
|
|
244
|
+
})
|
|
245
|
+
.join(', ')}`
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
return optimizerUploadContext.run(createUploadContext(preferences), () => handler(ctx));
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export default async ({ strapi }: { strapi: Core.Strapi }) => {
|
|
253
|
+
const uploadService = strapi.plugin('upload').service('upload');
|
|
254
|
+
const globalDefaults = configToSettings(strapi.plugin(PLUGIN_ID).config as ReturnType<
|
|
255
|
+
typeof import('./config').default.default
|
|
256
|
+
>);
|
|
257
|
+
|
|
258
|
+
strapi.log.info(`[video-optimizer] Server bootstrap loaded (${SERVER_BUILD_MARKER})`);
|
|
259
|
+
|
|
260
|
+
const adminUploadController = strapi.plugin('upload').controllers['admin-upload'] as
|
|
261
|
+
| Record<string, (ctx: { request: { body?: Record<string, unknown> } }) => Promise<unknown>>
|
|
262
|
+
| undefined;
|
|
263
|
+
|
|
264
|
+
if (adminUploadController?.uploadFiles) {
|
|
265
|
+
const originalUploadFiles = adminUploadController.uploadFiles.bind(adminUploadController);
|
|
266
|
+
adminUploadController.uploadFiles = wrapUploadController(strapi, originalUploadFiles);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (adminUploadController?.unstable_uploadFile) {
|
|
270
|
+
const originalUnstableUpload = adminUploadController.unstable_uploadFile.bind(adminUploadController);
|
|
271
|
+
adminUploadController.unstable_uploadFile = wrapUploadController(strapi, originalUnstableUpload);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
strapi.server.use(async (ctx, next) => {
|
|
275
|
+
if (!isUploadRoute(ctx.method, ctx.path)) {
|
|
276
|
+
return next();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const preferences = parseUploadPreferences(ctx.request.body as Record<string, unknown>);
|
|
280
|
+
|
|
281
|
+
if (preferences.length === 0) {
|
|
282
|
+
return next();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
registerUploadBatchPreferences(preferences);
|
|
286
|
+
|
|
287
|
+
strapi.log.debug(
|
|
288
|
+
`[video-optimizer] Upload preferences received (${preferences.length} file(s)) on ${ctx.path}: ${preferences
|
|
289
|
+
.map((entry) => {
|
|
290
|
+
const format =
|
|
291
|
+
entry.preference.choice === 'custom'
|
|
292
|
+
? entry.preference.custom?.defaultFormat ?? '?'
|
|
293
|
+
: entry.preference.choice;
|
|
294
|
+
return `${entry.fileName}=${format}`;
|
|
295
|
+
})
|
|
296
|
+
.join(', ')}`
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
setFallbackUploadPreferences(preferences);
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
return await optimizerUploadContext.run(createUploadContext(preferences), () => next());
|
|
303
|
+
} finally {
|
|
304
|
+
clearFallbackUploadPreferences();
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const originalFormatFileInfo = uploadService.formatFileInfo.bind(uploadService);
|
|
309
|
+
uploadService.formatFileInfo = async (
|
|
310
|
+
fileProps: { filename: string; type: string; size: number },
|
|
311
|
+
fileInfo: Record<string, unknown> = {},
|
|
312
|
+
metas: Record<string, unknown> = {}
|
|
313
|
+
) => {
|
|
314
|
+
const entity = await originalFormatFileInfo(fileProps, fileInfo, metas);
|
|
315
|
+
applyPreferenceToEntity(entity as Record<string, unknown>, fileInfo, fileProps.type, globalDefaults);
|
|
316
|
+
return entity;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const originalUpload = uploadService.upload.bind(uploadService);
|
|
320
|
+
uploadService.upload = async (args, opts) => {
|
|
321
|
+
const result = await runWithUser(opts?.user?.id, () => originalUpload(args, opts));
|
|
322
|
+
const files = Array.isArray(result) ? result : [result];
|
|
323
|
+
|
|
324
|
+
if (files.length) {
|
|
325
|
+
setImmediate(() => {
|
|
326
|
+
void enqueueVideoJobs(strapi, files as Array<Record<string, unknown>>, globalDefaults).catch(
|
|
327
|
+
(error: unknown) => {
|
|
328
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
329
|
+
strapi.log.error(`[video-optimizer] Failed to enqueue upload jobs: ${message}`);
|
|
330
|
+
}
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return result;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const originalReplace = uploadService.replace.bind(uploadService);
|
|
339
|
+
uploadService.replace = async (id, args, opts) => {
|
|
340
|
+
const result = await runWithUser(opts?.user?.id, () => originalReplace(id, args, opts));
|
|
341
|
+
const files = Array.isArray(result) ? result : [result];
|
|
342
|
+
|
|
343
|
+
if (files.length) {
|
|
344
|
+
setImmediate(() => {
|
|
345
|
+
void enqueueVideoJobs(strapi, files as Array<Record<string, unknown>>, globalDefaults).catch(
|
|
346
|
+
(error: unknown) => {
|
|
347
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
348
|
+
strapi.log.error(`[video-optimizer] Failed to enqueue replace jobs: ${message}`);
|
|
349
|
+
}
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return result;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const jobQueue = strapi.plugin(PLUGIN_ID).service('job-queue');
|
|
358
|
+
|
|
359
|
+
const originalRemove = uploadService.remove.bind(uploadService);
|
|
360
|
+
uploadService.remove = async (file: { id: number | string }) => {
|
|
361
|
+
await jobQueue.cancelJobsForFile(Number(file.id));
|
|
362
|
+
return originalRemove(file);
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
strapi.eventHub.on('media.delete', ({ media }: { media?: { id?: number | string } }) => {
|
|
366
|
+
const fileId = Number(media?.id);
|
|
367
|
+
|
|
368
|
+
if (!Number.isFinite(fileId) || fileId <= 0) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
void jobQueue.cancelJobsForFile(fileId);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await strapi.plugin(PLUGIN_ID).service('preference').ensureGlobalSettingsDefaults();
|
|
376
|
+
await jobQueue.clearJobsOnStartup();
|
|
377
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SERVER_BUILD_MARKER = 'test-8';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUDIO_MODES,
|
|
3
|
+
FFMPEG_PRESETS,
|
|
4
|
+
OPTIMIZATION_CHOICES,
|
|
5
|
+
VIDEO_CODECS,
|
|
6
|
+
VIDEO_FORMATS,
|
|
7
|
+
clampMaxConcurrentJobs,
|
|
8
|
+
clampMaxFfmpegThreads,
|
|
9
|
+
codecForFormat,
|
|
10
|
+
type AudioMode,
|
|
11
|
+
type FfmpegPreset,
|
|
12
|
+
type OptimizationChoice,
|
|
13
|
+
type VideoCodec,
|
|
14
|
+
type VideoFormat,
|
|
15
|
+
} from '../constants';
|
|
16
|
+
import type { PluginConfig } from './index';
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
|
|
19
|
+
defaultChoice: 'original',
|
|
20
|
+
defaultFormat: 'mp4',
|
|
21
|
+
videoCodec: 'h264',
|
|
22
|
+
crf: 23,
|
|
23
|
+
preset: 'medium',
|
|
24
|
+
maxWidth: 1920,
|
|
25
|
+
maxHeight: 1080,
|
|
26
|
+
audioMode: 'compress',
|
|
27
|
+
audioBitrate: '128k',
|
|
28
|
+
maxConcurrentJobs: 1,
|
|
29
|
+
maxFfmpegThreads: 2,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const isValidChoice = (choice: unknown): choice is OptimizationChoice =>
|
|
33
|
+
typeof choice === 'string' && OPTIMIZATION_CHOICES.includes(choice as OptimizationChoice);
|
|
34
|
+
|
|
35
|
+
const isValidFormat = (format: unknown): format is VideoFormat =>
|
|
36
|
+
typeof format === 'string' && VIDEO_FORMATS.includes(format as VideoFormat);
|
|
37
|
+
|
|
38
|
+
const isValidCodec = (codec: unknown): codec is VideoCodec =>
|
|
39
|
+
typeof codec === 'string' && VIDEO_CODECS.includes(codec as VideoCodec);
|
|
40
|
+
|
|
41
|
+
const isValidAudioMode = (mode: unknown): mode is AudioMode =>
|
|
42
|
+
typeof mode === 'string' && AUDIO_MODES.includes(mode as AudioMode);
|
|
43
|
+
|
|
44
|
+
const isValidPreset = (preset: unknown): preset is FfmpegPreset =>
|
|
45
|
+
typeof preset === 'string' && FFMPEG_PRESETS.includes(preset as FfmpegPreset);
|
|
46
|
+
|
|
47
|
+
export const normalizePluginConfig = (
|
|
48
|
+
...sources: Array<Partial<PluginConfig> | undefined>
|
|
49
|
+
): PluginConfig => {
|
|
50
|
+
const merged = Object.assign({}, DEFAULT_PLUGIN_CONFIG, ...sources.filter(Boolean));
|
|
51
|
+
|
|
52
|
+
const defaultFormat = isValidFormat(merged.defaultFormat)
|
|
53
|
+
? merged.defaultFormat
|
|
54
|
+
: DEFAULT_PLUGIN_CONFIG.defaultFormat;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
defaultChoice: isValidChoice(merged.defaultChoice)
|
|
58
|
+
? merged.defaultChoice
|
|
59
|
+
: DEFAULT_PLUGIN_CONFIG.defaultChoice,
|
|
60
|
+
defaultFormat,
|
|
61
|
+
videoCodec: isValidCodec(merged.videoCodec)
|
|
62
|
+
? merged.videoCodec
|
|
63
|
+
: codecForFormat(defaultFormat),
|
|
64
|
+
crf:
|
|
65
|
+
typeof merged.crf === 'number' && merged.crf >= 0 && merged.crf <= 51
|
|
66
|
+
? merged.crf
|
|
67
|
+
: DEFAULT_PLUGIN_CONFIG.crf,
|
|
68
|
+
preset: isValidPreset(merged.preset) ? merged.preset : DEFAULT_PLUGIN_CONFIG.preset,
|
|
69
|
+
maxWidth:
|
|
70
|
+
typeof merged.maxWidth === 'number' && merged.maxWidth >= 1
|
|
71
|
+
? merged.maxWidth
|
|
72
|
+
: DEFAULT_PLUGIN_CONFIG.maxWidth,
|
|
73
|
+
maxHeight:
|
|
74
|
+
typeof merged.maxHeight === 'number' && merged.maxHeight >= 1
|
|
75
|
+
? merged.maxHeight
|
|
76
|
+
: DEFAULT_PLUGIN_CONFIG.maxHeight,
|
|
77
|
+
audioMode: isValidAudioMode(merged.audioMode)
|
|
78
|
+
? merged.audioMode
|
|
79
|
+
: DEFAULT_PLUGIN_CONFIG.audioMode,
|
|
80
|
+
audioBitrate:
|
|
81
|
+
typeof merged.audioBitrate === 'string' && merged.audioBitrate.trim()
|
|
82
|
+
? merged.audioBitrate
|
|
83
|
+
: DEFAULT_PLUGIN_CONFIG.audioBitrate,
|
|
84
|
+
maxConcurrentJobs: clampMaxConcurrentJobs(
|
|
85
|
+
merged.maxConcurrentJobs ?? DEFAULT_PLUGIN_CONFIG.maxConcurrentJobs
|
|
86
|
+
),
|
|
87
|
+
maxFfmpegThreads: clampMaxFfmpegThreads(
|
|
88
|
+
merged.maxFfmpegThreads ?? DEFAULT_PLUGIN_CONFIG.maxFfmpegThreads
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AudioMode,
|
|
3
|
+
FfmpegPreset,
|
|
4
|
+
OptimizationChoice,
|
|
5
|
+
VideoCodec,
|
|
6
|
+
VideoFormat,
|
|
7
|
+
} from '../constants';
|
|
8
|
+
import { MAX_CONCURRENT_JOBS_LIMIT, MAX_FFMPEG_THREADS_LIMIT, clampMaxConcurrentJobs, clampMaxFfmpegThreads } from '../constants';
|
|
9
|
+
|
|
10
|
+
import { DEFAULT_PLUGIN_CONFIG } from './defaults';
|
|
11
|
+
|
|
12
|
+
export interface PluginConfig {
|
|
13
|
+
defaultChoice: OptimizationChoice;
|
|
14
|
+
defaultFormat: VideoFormat;
|
|
15
|
+
videoCodec: VideoCodec;
|
|
16
|
+
crf: number;
|
|
17
|
+
preset: FfmpegPreset;
|
|
18
|
+
maxWidth: number;
|
|
19
|
+
maxHeight: number;
|
|
20
|
+
audioMode: AudioMode;
|
|
21
|
+
audioBitrate: string;
|
|
22
|
+
maxConcurrentJobs: number;
|
|
23
|
+
maxFfmpegThreads: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default {
|
|
27
|
+
default: (): PluginConfig => ({ ...DEFAULT_PLUGIN_CONFIG }),
|
|
28
|
+
validator(config: Partial<PluginConfig>) {
|
|
29
|
+
if (config.crf !== undefined && (config.crf < 0 || config.crf > 51)) {
|
|
30
|
+
throw new Error('crf must be between 0 and 51');
|
|
31
|
+
}
|
|
32
|
+
if (config.maxWidth !== undefined && config.maxWidth < 1) {
|
|
33
|
+
throw new Error('maxWidth must be at least 1');
|
|
34
|
+
}
|
|
35
|
+
if (config.maxHeight !== undefined && config.maxHeight < 1) {
|
|
36
|
+
throw new Error('maxHeight must be at least 1');
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
config.maxConcurrentJobs !== undefined &&
|
|
40
|
+
clampMaxConcurrentJobs(config.maxConcurrentJobs) !== config.maxConcurrentJobs
|
|
41
|
+
) {
|
|
42
|
+
throw new Error(`maxConcurrentJobs must be between 1 and ${MAX_CONCURRENT_JOBS_LIMIT}`);
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
config.maxFfmpegThreads !== undefined &&
|
|
46
|
+
clampMaxFfmpegThreads(config.maxFfmpegThreads) !== config.maxFfmpegThreads
|
|
47
|
+
) {
|
|
48
|
+
throw new Error(`maxFfmpegThreads must be between 1 and ${MAX_FFMPEG_THREADS_LIMIT}`);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|