@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
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
import ffmpeg, { type FfmpegCommand } from 'fluent-ffmpeg';
|
|
7
|
+
import ffmpegStatic from 'ffmpeg-static';
|
|
8
|
+
import type { Core } from '@strapi/strapi';
|
|
9
|
+
import { file as fileUtils } from '@strapi/utils';
|
|
10
|
+
import type { OptimizationSettings } from '../constants';
|
|
11
|
+
import { clampMaxFfmpegThreads } from '../constants';
|
|
12
|
+
|
|
13
|
+
const { bytesToKbytes } = fileUtils;
|
|
14
|
+
|
|
15
|
+
let ffmpegPathConfigured = false;
|
|
16
|
+
|
|
17
|
+
const activeCommands = new Map<string, { command: FfmpegCommand; outputPath: string }>();
|
|
18
|
+
|
|
19
|
+
const configureFfmpegPath = () => {
|
|
20
|
+
if (ffmpegPathConfigured) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (ffmpegStatic) {
|
|
25
|
+
ffmpeg.setFfmpegPath(ffmpegStatic);
|
|
26
|
+
ffmpegPathConfigured = true;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const systemPath = execSync('which ffmpeg', { encoding: 'utf8' }).trim();
|
|
32
|
+
if (systemPath) {
|
|
33
|
+
ffmpeg.setFfmpegPath(systemPath);
|
|
34
|
+
ffmpegPathConfigured = true;
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// Fall through — fluent-ffmpeg will try PATH at runtime.
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
interface VideoMetadata {
|
|
42
|
+
width?: number;
|
|
43
|
+
height?: number;
|
|
44
|
+
duration?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const probeVideo = (inputPath: string): Promise<VideoMetadata> =>
|
|
48
|
+
new Promise((resolve, reject) => {
|
|
49
|
+
ffmpeg.ffprobe(inputPath, (error, metadata) => {
|
|
50
|
+
if (error) {
|
|
51
|
+
reject(error);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const videoStream = metadata.streams.find((stream) => stream.codec_type === 'video');
|
|
56
|
+
|
|
57
|
+
resolve({
|
|
58
|
+
width: videoStream?.width ?? undefined,
|
|
59
|
+
height: videoStream?.height ?? undefined,
|
|
60
|
+
duration: metadata.format.duration ?? undefined,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const buildScaleFilter = (
|
|
66
|
+
settings: OptimizationSettings,
|
|
67
|
+
metadata: VideoMetadata,
|
|
68
|
+
resizeMode: 'exact' | 'fit-within' = 'exact'
|
|
69
|
+
) => {
|
|
70
|
+
const targetWidth = settings.maxWidth;
|
|
71
|
+
const targetHeight = settings.maxHeight;
|
|
72
|
+
const sourceWidth = metadata.width ?? 0;
|
|
73
|
+
const sourceHeight = metadata.height ?? 0;
|
|
74
|
+
|
|
75
|
+
if (!targetWidth || !targetHeight || !sourceWidth || !sourceHeight) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (targetWidth === sourceWidth && targetHeight === sourceHeight) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (resizeMode === 'fit-within') {
|
|
84
|
+
if (sourceWidth <= targetWidth && sourceHeight <= targetHeight) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return `scale='min(${targetWidth},iw)':'min(${targetHeight},ih)':force_original_aspect_ratio=decrease`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return `scale=${targetWidth}:${targetHeight}`;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const buildVideoCodecOptions = (settings: OptimizationSettings, maxThreads: number) => {
|
|
95
|
+
if (settings.defaultFormat === 'webm' || settings.videoCodec === 'vp9') {
|
|
96
|
+
return {
|
|
97
|
+
videoCodec: 'libvpx-vp9',
|
|
98
|
+
outputOptions: [
|
|
99
|
+
'-crf',
|
|
100
|
+
String(settings.crf),
|
|
101
|
+
'-b:v',
|
|
102
|
+
'0',
|
|
103
|
+
'-row-mt',
|
|
104
|
+
maxThreads > 1 ? '1' : '0',
|
|
105
|
+
'-speed',
|
|
106
|
+
'4',
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
videoCodec: 'libx264',
|
|
113
|
+
outputOptions: [`-crf`, String(settings.crf), '-preset', settings.preset],
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const buildThreadOptions = (maxThreads: number) => {
|
|
118
|
+
const threads = clampMaxFfmpegThreads(maxThreads);
|
|
119
|
+
|
|
120
|
+
return ['-threads', String(threads)];
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const buildAudioOptions = (settings: OptimizationSettings) => {
|
|
124
|
+
if (settings.audioMode === 'remove') {
|
|
125
|
+
return { audioCodec: undefined as string | undefined, outputOptions: ['-an'] };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (settings.audioMode === 'compress') {
|
|
129
|
+
const codec = settings.defaultFormat === 'webm' ? 'libopus' : 'aac';
|
|
130
|
+
return {
|
|
131
|
+
audioCodec: codec,
|
|
132
|
+
outputOptions: ['-b:a', settings.audioBitrate],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { audioCodec: undefined as string | undefined, outputOptions: [] as string[] };
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export interface ProcessOptions {
|
|
140
|
+
jobId: string;
|
|
141
|
+
inputPath: string;
|
|
142
|
+
settings: OptimizationSettings;
|
|
143
|
+
resizeMode?: 'exact' | 'fit-within';
|
|
144
|
+
onProgress?: (progress: number, stage: string) => void;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface ProcessResult {
|
|
148
|
+
outputPath: string;
|
|
149
|
+
width?: number;
|
|
150
|
+
height?: number;
|
|
151
|
+
sizeInBytes: number;
|
|
152
|
+
ext: string;
|
|
153
|
+
mime: string;
|
|
154
|
+
name: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
|
158
|
+
cancel(jobId: string) {
|
|
159
|
+
const active = activeCommands.get(jobId);
|
|
160
|
+
|
|
161
|
+
if (!active) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
active.command.kill('SIGKILL');
|
|
167
|
+
} catch {
|
|
168
|
+
// Ignore kill errors.
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
if (fs.existsSync(active.outputPath)) {
|
|
173
|
+
fs.unlinkSync(active.outputPath);
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// Ignore cleanup errors.
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
activeCommands.delete(jobId);
|
|
180
|
+
return true;
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async process({
|
|
184
|
+
jobId,
|
|
185
|
+
inputPath,
|
|
186
|
+
settings,
|
|
187
|
+
resizeMode = 'exact',
|
|
188
|
+
onProgress,
|
|
189
|
+
}: ProcessOptions): Promise<ProcessResult> {
|
|
190
|
+
configureFfmpegPath();
|
|
191
|
+
|
|
192
|
+
const metadata = await probeVideo(inputPath);
|
|
193
|
+
const outputExt = settings.defaultFormat === 'webm' ? '.webm' : '.mp4';
|
|
194
|
+
const outputMime = settings.defaultFormat === 'webm' ? 'video/webm' : 'video/mp4';
|
|
195
|
+
const outputPath = join(tmpdir(), `video-optimizer-${randomUUID()}${outputExt}`);
|
|
196
|
+
|
|
197
|
+
const scaleFilter = buildScaleFilter(settings, metadata, resizeMode);
|
|
198
|
+
const globalSettings = await strapi
|
|
199
|
+
.plugin('video-optimizer')
|
|
200
|
+
.service('preference')
|
|
201
|
+
.getGlobalSettings();
|
|
202
|
+
const maxThreads = globalSettings.maxFfmpegThreads;
|
|
203
|
+
const videoOptions = buildVideoCodecOptions(settings, maxThreads);
|
|
204
|
+
const audioOptions = buildAudioOptions(settings);
|
|
205
|
+
const threadOptions = buildThreadOptions(maxThreads);
|
|
206
|
+
const duration = metadata.duration ?? 0;
|
|
207
|
+
|
|
208
|
+
await new Promise<void>((resolve, reject) => {
|
|
209
|
+
let command = ffmpeg(inputPath);
|
|
210
|
+
|
|
211
|
+
if (scaleFilter) {
|
|
212
|
+
command = command.videoFilters(scaleFilter);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
command = command
|
|
216
|
+
.format(settings.defaultFormat)
|
|
217
|
+
.videoCodec(videoOptions.videoCodec)
|
|
218
|
+
.outputOptions([
|
|
219
|
+
...threadOptions,
|
|
220
|
+
...videoOptions.outputOptions,
|
|
221
|
+
...audioOptions.outputOptions,
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
if (audioOptions.audioCodec) {
|
|
225
|
+
command = command.audioCodec(audioOptions.audioCodec);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
activeCommands.set(jobId, { command, outputPath });
|
|
229
|
+
|
|
230
|
+
command
|
|
231
|
+
.on('start', () => {
|
|
232
|
+
onProgress?.(5, 'encoding');
|
|
233
|
+
})
|
|
234
|
+
.on('progress', (progress) => {
|
|
235
|
+
if (!duration || !progress.timemark) {
|
|
236
|
+
onProgress?.(50, 'encoding');
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const parts = progress.timemark.split(':').map(Number);
|
|
241
|
+
const seconds =
|
|
242
|
+
(parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
|
|
243
|
+
const ratio = Math.min(1, seconds / duration);
|
|
244
|
+
onProgress?.(Math.round(5 + ratio * 90), 'encoding');
|
|
245
|
+
})
|
|
246
|
+
.on('end', () => {
|
|
247
|
+
onProgress?.(98, 'finalizing');
|
|
248
|
+
resolve();
|
|
249
|
+
})
|
|
250
|
+
.on('error', (error) => {
|
|
251
|
+
reject(error);
|
|
252
|
+
})
|
|
253
|
+
.save(outputPath);
|
|
254
|
+
}).finally(() => {
|
|
255
|
+
activeCommands.delete(jobId);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const outputMetadata = await probeVideo(outputPath);
|
|
259
|
+
const stats = fs.statSync(outputPath);
|
|
260
|
+
const baseName = inputPath.split('/').pop()?.replace(/\.[^.]+$/, '') ?? 'video';
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
outputPath,
|
|
264
|
+
width: outputMetadata.width,
|
|
265
|
+
height: outputMetadata.height,
|
|
266
|
+
sizeInBytes: stats.size,
|
|
267
|
+
ext: outputExt,
|
|
268
|
+
mime: outputMime,
|
|
269
|
+
name: `${baseName}${outputExt}`,
|
|
270
|
+
};
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
async cleanup(path: string) {
|
|
274
|
+
try {
|
|
275
|
+
if (fs.existsSync(path)) {
|
|
276
|
+
fs.unlinkSync(path);
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
// Ignore cleanup errors.
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
bytesToKbytes,
|
|
284
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { Core } from '@strapi/strapi';
|
|
2
|
+
import {
|
|
3
|
+
AUDIO_MODES,
|
|
4
|
+
FFMPEG_PRESETS,
|
|
5
|
+
OPTIMIZATION_CHOICES,
|
|
6
|
+
PLUGIN_ID,
|
|
7
|
+
VIDEO_CODECS,
|
|
8
|
+
VIDEO_FORMATS,
|
|
9
|
+
clampMaxConcurrentJobs,
|
|
10
|
+
codecForFormat,
|
|
11
|
+
type AudioMode,
|
|
12
|
+
type FfmpegPreset,
|
|
13
|
+
type OptimizationChoice,
|
|
14
|
+
type OptimizationSettings,
|
|
15
|
+
type ResolvedOptimization,
|
|
16
|
+
type VideoCodec,
|
|
17
|
+
type VideoFormat,
|
|
18
|
+
} from '../constants';
|
|
19
|
+
import type { PluginConfig } from '../config';
|
|
20
|
+
import { DEFAULT_PLUGIN_CONFIG, normalizePluginConfig } from '../config/defaults';
|
|
21
|
+
|
|
22
|
+
const GLOBAL_SETTINGS_KEY = 'global-settings';
|
|
23
|
+
const JOBS_STORE_KEY = 'jobs';
|
|
24
|
+
|
|
25
|
+
const userPreferenceKey = (userId: number) => `user-pref-${userId}`;
|
|
26
|
+
|
|
27
|
+
const isValidChoice = (choice: unknown): choice is OptimizationChoice =>
|
|
28
|
+
typeof choice === 'string' && OPTIMIZATION_CHOICES.includes(choice as OptimizationChoice);
|
|
29
|
+
|
|
30
|
+
const isValidFormat = (format: unknown): format is VideoFormat =>
|
|
31
|
+
typeof format === 'string' && VIDEO_FORMATS.includes(format as VideoFormat);
|
|
32
|
+
|
|
33
|
+
const isValidCodec = (codec: unknown): codec is VideoCodec =>
|
|
34
|
+
typeof codec === 'string' && VIDEO_CODECS.includes(codec as VideoCodec);
|
|
35
|
+
|
|
36
|
+
const isValidAudioMode = (mode: unknown): mode is AudioMode =>
|
|
37
|
+
typeof mode === 'string' && AUDIO_MODES.includes(mode as AudioMode);
|
|
38
|
+
|
|
39
|
+
const isValidPreset = (preset: unknown): preset is FfmpegPreset =>
|
|
40
|
+
typeof preset === 'string' && FFMPEG_PRESETS.includes(preset as FfmpegPreset);
|
|
41
|
+
|
|
42
|
+
const buildSettings = (
|
|
43
|
+
source: Partial<OptimizationSettings> | PluginConfig
|
|
44
|
+
): OptimizationSettings => {
|
|
45
|
+
const defaultFormat = isValidFormat(source.defaultFormat) ? source.defaultFormat : 'mp4';
|
|
46
|
+
const videoCodec = isValidCodec(source.videoCodec)
|
|
47
|
+
? source.videoCodec
|
|
48
|
+
: codecForFormat(defaultFormat);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
defaultFormat,
|
|
52
|
+
videoCodec,
|
|
53
|
+
crf: source.crf ?? 23,
|
|
54
|
+
preset: isValidPreset(source.preset) ? source.preset : 'medium',
|
|
55
|
+
maxWidth: source.maxWidth ?? 1920,
|
|
56
|
+
maxHeight: source.maxHeight ?? 1080,
|
|
57
|
+
audioMode: isValidAudioMode(source.audioMode) ? source.audioMode : 'compress',
|
|
58
|
+
audioBitrate: source.audioBitrate ?? '128k',
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
|
63
|
+
async getUserPreference(userId: number): Promise<VideoFormat | null> {
|
|
64
|
+
const stored = await strapi.store({
|
|
65
|
+
type: 'plugin',
|
|
66
|
+
name: PLUGIN_ID,
|
|
67
|
+
key: userPreferenceKey(userId),
|
|
68
|
+
}).get<{ defaultFormat?: VideoFormat }>();
|
|
69
|
+
|
|
70
|
+
if (stored?.defaultFormat && isValidFormat(stored.defaultFormat)) {
|
|
71
|
+
return stored.defaultFormat;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return null;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
async setUserPreference(userId: number, defaultFormat: VideoFormat) {
|
|
78
|
+
await strapi.store({
|
|
79
|
+
type: 'plugin',
|
|
80
|
+
name: PLUGIN_ID,
|
|
81
|
+
key: userPreferenceKey(userId),
|
|
82
|
+
}).set({ value: { defaultFormat } });
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async getGlobalSettings(): Promise<PluginConfig> {
|
|
86
|
+
const pluginConfig = strapi.plugin(PLUGIN_ID).config as Partial<PluginConfig>;
|
|
87
|
+
const stored = await strapi.store({
|
|
88
|
+
type: 'plugin',
|
|
89
|
+
name: PLUGIN_ID,
|
|
90
|
+
key: GLOBAL_SETTINGS_KEY,
|
|
91
|
+
}).get<Partial<PluginConfig>>();
|
|
92
|
+
|
|
93
|
+
return normalizePluginConfig(DEFAULT_PLUGIN_CONFIG, pluginConfig, stored ?? undefined);
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async ensureGlobalSettingsDefaults() {
|
|
97
|
+
const store = strapi.store({
|
|
98
|
+
type: 'plugin',
|
|
99
|
+
name: PLUGIN_ID,
|
|
100
|
+
key: GLOBAL_SETTINGS_KEY,
|
|
101
|
+
});
|
|
102
|
+
const stored = await store.get<Partial<PluginConfig>>();
|
|
103
|
+
|
|
104
|
+
if (!stored || Object.keys(stored).length === 0) {
|
|
105
|
+
const pluginConfig = strapi.plugin(PLUGIN_ID).config as Partial<PluginConfig>;
|
|
106
|
+
const defaults = normalizePluginConfig(DEFAULT_PLUGIN_CONFIG, pluginConfig);
|
|
107
|
+
await store.set({ value: defaults });
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async setGlobalSettings(settings: Partial<PluginConfig>) {
|
|
112
|
+
const current = await this.getGlobalSettings();
|
|
113
|
+
const next = { ...current, ...settings };
|
|
114
|
+
|
|
115
|
+
await strapi.store({
|
|
116
|
+
type: 'plugin',
|
|
117
|
+
name: PLUGIN_ID,
|
|
118
|
+
key: GLOBAL_SETTINGS_KEY,
|
|
119
|
+
}).set({ value: next });
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async resolveOptimization(file: {
|
|
123
|
+
optimizationChoice?: OptimizationChoice;
|
|
124
|
+
optimizationCustom?: Partial<OptimizationSettings>;
|
|
125
|
+
mime?: string;
|
|
126
|
+
}): Promise<ResolvedOptimization> {
|
|
127
|
+
const global = await this.getGlobalSettings();
|
|
128
|
+
|
|
129
|
+
if (!file.mime || !file.mime.startsWith('video/')) {
|
|
130
|
+
return { skip: true };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const choice = isValidChoice(file.optimizationChoice)
|
|
134
|
+
? file.optimizationChoice
|
|
135
|
+
: global.defaultChoice;
|
|
136
|
+
|
|
137
|
+
if (choice === 'original') {
|
|
138
|
+
return { skip: true };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (choice === 'global') {
|
|
142
|
+
return {
|
|
143
|
+
skip: false,
|
|
144
|
+
settings: buildSettings(global),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const custom = file.optimizationCustom ?? {};
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
skip: false,
|
|
152
|
+
settings: buildSettings({
|
|
153
|
+
defaultFormat: custom.defaultFormat ?? global.defaultFormat,
|
|
154
|
+
videoCodec: custom.videoCodec ?? global.videoCodec,
|
|
155
|
+
crf: custom.crf ?? global.crf,
|
|
156
|
+
preset: custom.preset ?? global.preset,
|
|
157
|
+
maxWidth: custom.maxWidth ?? global.maxWidth,
|
|
158
|
+
maxHeight: custom.maxHeight ?? global.maxHeight,
|
|
159
|
+
audioMode: custom.audioMode ?? global.audioMode,
|
|
160
|
+
audioBitrate: custom.audioBitrate ?? global.audioBitrate,
|
|
161
|
+
}),
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
getJobsStore() {
|
|
166
|
+
return strapi.store({
|
|
167
|
+
type: 'plugin',
|
|
168
|
+
name: PLUGIN_ID,
|
|
169
|
+
key: JOBS_STORE_KEY,
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
2
|
+
import type { OptimizationChoice, OptimizationSettings } from '../constants';
|
|
3
|
+
|
|
4
|
+
export interface UploadFilePreference {
|
|
5
|
+
assetId?: string;
|
|
6
|
+
fileName: string;
|
|
7
|
+
preference: {
|
|
8
|
+
choice: OptimizationChoice;
|
|
9
|
+
custom?: OptimizationSettings;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface OptimizerUploadContext {
|
|
14
|
+
preferences: UploadFilePreference[];
|
|
15
|
+
nextIndex: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const optimizerUploadContext = new AsyncLocalStorage<OptimizerUploadContext>();
|
|
19
|
+
|
|
20
|
+
let fallbackContext: OptimizerUploadContext | null = null;
|
|
21
|
+
|
|
22
|
+
let batchPreferences: UploadFilePreference[] | null = null;
|
|
23
|
+
|
|
24
|
+
export const registerUploadBatchPreferences = (preferences: UploadFilePreference[]) => {
|
|
25
|
+
batchPreferences = preferences;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const clearUploadBatchPreferences = () => {
|
|
29
|
+
batchPreferences = null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const normalizeFileName = (value: string) => value.trim().toLowerCase();
|
|
33
|
+
|
|
34
|
+
export const resolveUploadBatchPreference = (fileName: string, assetId?: string) => {
|
|
35
|
+
if (!batchPreferences?.length) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const normalizedTarget = normalizeFileName(fileName);
|
|
40
|
+
|
|
41
|
+
const match = batchPreferences.find((entry) => {
|
|
42
|
+
if (assetId && entry.assetId === assetId) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return normalizeFileName(entry.fileName) === normalizedTarget;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return match?.preference ?? null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const createUploadContext = (preferences: UploadFilePreference[]): OptimizerUploadContext => ({
|
|
53
|
+
preferences,
|
|
54
|
+
nextIndex: 0,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const setFallbackUploadPreferences = (preferences: UploadFilePreference[]) => {
|
|
58
|
+
fallbackContext = createUploadContext(preferences);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const clearFallbackUploadPreferences = () => {
|
|
62
|
+
fallbackContext = null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const getActivePreferences = () => {
|
|
66
|
+
const store = optimizerUploadContext.getStore();
|
|
67
|
+
|
|
68
|
+
if (store?.preferences.length) {
|
|
69
|
+
return store;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return fallbackContext;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const parseUploadPreferences = (body: Record<string, unknown> | undefined) => {
|
|
76
|
+
const raw = body?.videoOptimizerPreferences;
|
|
77
|
+
|
|
78
|
+
if (!raw) {
|
|
79
|
+
return [] as UploadFilePreference[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (typeof raw === 'string') {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(raw) as UploadFilePreference[];
|
|
85
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Array.isArray(raw) ? (raw as UploadFilePreference[]) : [];
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const findUploadPreference = (fileName: string, assetId?: string) => {
|
|
95
|
+
const store = getActivePreferences();
|
|
96
|
+
|
|
97
|
+
if (!store) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const match = store.preferences.find((entry) => {
|
|
102
|
+
if (assetId && entry.assetId === assetId) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return entry.fileName === fileName;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return match?.preference ?? null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const consumeUploadPreference = (fileName: string, assetId?: string) => {
|
|
113
|
+
const batchPreference = resolveUploadBatchPreference(fileName, assetId);
|
|
114
|
+
|
|
115
|
+
if (batchPreference) {
|
|
116
|
+
return batchPreference;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const store = getActivePreferences();
|
|
120
|
+
|
|
121
|
+
if (!store) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const matched = store.preferences.find((entry) => {
|
|
126
|
+
if (assetId && entry.assetId === assetId) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return normalizeFileName(entry.fileName) === normalizeFileName(fileName);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (matched) {
|
|
134
|
+
return matched.preference;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const indexed = store.preferences[store.nextIndex];
|
|
138
|
+
|
|
139
|
+
if (indexed) {
|
|
140
|
+
store.nextIndex += 1;
|
|
141
|
+
return indexed.preference;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return null;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const UPLOAD_ROUTE_SUFFIXES = ['/upload', '/upload/unstable/upload-file'];
|
|
148
|
+
|
|
149
|
+
export const isUploadRoute = (method: string, path: string) => {
|
|
150
|
+
if (method.toUpperCase() !== 'POST') {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const normalizedPath = path.replace(/\/+$/, '') || '/';
|
|
155
|
+
|
|
156
|
+
return UPLOAD_ROUTE_SUFFIXES.some(
|
|
157
|
+
(suffix) => normalizedPath === suffix || normalizedPath.endsWith(suffix)
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
interface StashedPreference {
|
|
162
|
+
choice: OptimizationChoice;
|
|
163
|
+
custom?: OptimizationSettings;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const pendingPreferencesByKey = new Map<string, StashedPreference>();
|
|
167
|
+
|
|
168
|
+
const buildPreferenceKey = (fileName: string, assetId?: string) =>
|
|
169
|
+
assetId ? `asset:${assetId}` : `file:${fileName}`;
|
|
170
|
+
|
|
171
|
+
export const stashUploadPreference = (
|
|
172
|
+
fileName: string,
|
|
173
|
+
assetId: string | undefined,
|
|
174
|
+
preference: StashedPreference
|
|
175
|
+
) => {
|
|
176
|
+
pendingPreferencesByKey.set(buildPreferenceKey(fileName, assetId), preference);
|
|
177
|
+
|
|
178
|
+
if (assetId) {
|
|
179
|
+
pendingPreferencesByKey.set(buildPreferenceKey(fileName), preference);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
pendingPreferencesByKey.set(`file:${fileName}`, preference);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const takePendingPreference = (fileName: string, assetId?: string) => {
|
|
186
|
+
const keys = [
|
|
187
|
+
assetId ? buildPreferenceKey(fileName, assetId) : null,
|
|
188
|
+
buildPreferenceKey(fileName),
|
|
189
|
+
`file:${fileName}`,
|
|
190
|
+
].filter(Boolean) as string[];
|
|
191
|
+
|
|
192
|
+
for (const key of keys) {
|
|
193
|
+
const preference = pendingPreferencesByKey.get(key);
|
|
194
|
+
|
|
195
|
+
if (preference) {
|
|
196
|
+
pendingPreferencesByKey.delete(key);
|
|
197
|
+
return preference;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return null;
|
|
202
|
+
};
|
package/strapi-admin.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { register } = require('esbuild-register/dist/node');
|
|
4
|
+
const { unregister } = register({ extensions: ['.js', '.jsx', '.ts', '.tsx'] });
|
|
5
|
+
const admin = require('./admin/src/index.ts');
|
|
6
|
+
unregister();
|
|
7
|
+
module.exports = admin.default || admin;
|
package/strapi-server.js
ADDED