@frkntmbs/strapi-plugin-video-optimizer 1.0.0 → 1.0.2
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 +1 -1
- package/README.md +59 -1
- package/admin/src/components/BridgeProviders.tsx +3 -0
- package/admin/src/components/MediaLibraryCardActionsBridge.tsx +16 -0
- package/admin/src/components/OptimizationBenefitWarning.tsx +52 -0
- package/admin/src/components/UploadEnhancerBridge.tsx +13 -0
- package/admin/src/components/upload/PendingAssetStep.tsx +14 -0
- package/admin/src/translations/en.json +2 -0
- package/admin/src/translations/tr.json +2 -0
- package/admin/src/utils/evaluateOptimizationBenefit.ts +113 -0
- package/admin/src/utils/initMediaLibraryCardActions.ts +8 -2
- package/admin/src/utils/initUploadEnhancer.ts +9 -4
- package/admin/src/utils/mediaLibraryCardStore.ts +8 -2
- package/admin/src/utils/mediaLibraryQueryBridge.ts +4 -0
- package/admin/src/utils/probeVideoDimensions.ts +22 -3
- package/admin/src/utils/uploadAssetStore.ts +48 -7
- package/dist/admin/{SettingsPage-D6e536P0.mjs → SettingsPage-CiTCB9pJ.mjs} +1 -1
- package/dist/admin/{SettingsPage-CN2fR83m.js → SettingsPage-CzelpJtC.js} +1 -1
- package/dist/admin/{en-CsHicGzL.mjs → en-Cv305Xlb.mjs} +2 -0
- package/dist/admin/{en-CqM903j3.js → en-YfXcNqYi.js} +2 -0
- package/dist/admin/{index-rAmxCQz6.mjs → index-B_Qy06R1.mjs} +190 -17
- package/dist/admin/{index-DOuHOS2G.js → index-CCnMe2zb.js} +190 -17
- package/dist/admin/{index-BjWoS0YU.js → index-CcmEXX7b.js} +1 -1
- package/dist/admin/{index-Cs_uiChW.mjs → index-ClPTEo43.mjs} +2 -2
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/admin/{tr-Y0-ANilh.mjs → tr-CEXm27JX.mjs} +2 -0
- package/dist/admin/{tr-muzHkdC4.js → tr-Dos6G22t.js} +2 -0
- package/package.json +1 -1
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -23,6 +23,8 @@ Strapi's Media Library uploads videos as-is unless you add custom server logic.
|
|
|
23
23
|
|
|
24
24
|
Encoding runs **asynchronously in the background** — the original file appears in the Media Library immediately, and FFmpeg replaces it when the job completes.
|
|
25
25
|
|
|
26
|
+
> **Server notice:** Video encoding is CPU-intensive. Large files can consume significant server resources. Use `maxConcurrentJobs` and `maxFfmpegThreads` on small VPS hosts. This plugin is recommended for server/VPS environments where FFmpeg is available.
|
|
27
|
+
|
|
26
28
|
Upload UX mirrors [`strapi-plugin-image-optimizer`](https://github.com/frkntmbs/strapi-plugin-image-optimizer); image processing is replaced with FFmpeg-based video encoding.
|
|
27
29
|
|
|
28
30
|
## Screenshots
|
|
@@ -104,8 +106,40 @@ flowchart LR
|
|
|
104
106
|
- [Strapi](https://strapi.io) **5.x**
|
|
105
107
|
- Node.js **20–24**
|
|
106
108
|
- `@strapi/plugin-upload` (included with Strapi)
|
|
109
|
+
- **FFmpeg** — required for video encoding (see [FFmpeg requirement](#ffmpeg-requirement) below)
|
|
110
|
+
|
|
111
|
+
## FFmpeg requirement
|
|
112
|
+
|
|
113
|
+
This plugin requires an FFmpeg executable at runtime. Resolution order:
|
|
114
|
+
|
|
115
|
+
1. [`ffmpeg-static`](https://www.npmjs.com/package/ffmpeg-static) — installed as an npm dependency and used when available (may pull platform-specific FFmpeg binaries into `node_modules`)
|
|
116
|
+
2. **System FFmpeg** — if `ffmpeg-static` is unavailable, the plugin falls back to an `ffmpeg` binary on the host `PATH`
|
|
117
|
+
|
|
118
|
+
You are responsible for ensuring your FFmpeg installation and use comply with the applicable **LGPL/GPL** license terms.
|
|
119
|
+
|
|
120
|
+
### Install FFmpeg on the host (recommended for Docker/production)
|
|
121
|
+
|
|
122
|
+
**macOS**
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
brew install ffmpeg
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Ubuntu / Debian**
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
sudo apt update && sudo apt install ffmpeg
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Docker**
|
|
107
135
|
|
|
108
|
-
FFmpeg
|
|
136
|
+
Install FFmpeg in your Strapi application image, for example:
|
|
137
|
+
|
|
138
|
+
```dockerfile
|
|
139
|
+
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Or use a base image that already includes FFmpeg.
|
|
109
143
|
|
|
110
144
|
## Installation
|
|
111
145
|
|
|
@@ -165,6 +199,8 @@ All options can be set in `config/plugins.ts` (defaults) and overridden from the
|
|
|
165
199
|
|
|
166
200
|
### Server resource tuning
|
|
167
201
|
|
|
202
|
+
Large videos can consume significant CPU and memory during encoding. On small VPS hosts, keep concurrency low:
|
|
203
|
+
|
|
168
204
|
| Setting | Weak VPS suggestion | Notes |
|
|
169
205
|
|---------|---------------------|-------|
|
|
170
206
|
| `maxConcurrentJobs` | `1` | Only one video encodes at a time |
|
|
@@ -215,6 +251,14 @@ Configure settings for a single video:
|
|
|
215
251
|
|
|
216
252
|
While a video is queued or encoding, hover the card and click the **stop** button to cancel the job. If the file was deleted during encoding, the job is cancelled automatically.
|
|
217
253
|
|
|
254
|
+
## Test results
|
|
255
|
+
|
|
256
|
+
Real-world encode runs on a Strapi 5 project with default plugin settings (`maxConcurrentJobs: 1`, `maxFfmpegThreads: 2`). Results vary by source file, output format, and server CPU.
|
|
257
|
+
|
|
258
|
+
> **Note:** Optimization controls encoding — it does **not** guarantee a smaller file. Re-encoding an already compressed source at the same resolution may increase size. For size reduction, raise CRF, lower resolution, or stay on H.264 instead of switching to WebM.
|
|
259
|
+
|
|
260
|
+
_Test results will be added here after benchmark runs (e.g. 4K → 1920 scaling)._
|
|
261
|
+
|
|
218
262
|
## Permissions
|
|
219
263
|
|
|
220
264
|
Global settings are protected by admin permissions:
|
|
@@ -277,6 +321,20 @@ npx yalc add --link @frkntmbs/strapi-plugin-video-optimizer && npm install
|
|
|
277
321
|
npm run develop
|
|
278
322
|
```
|
|
279
323
|
|
|
324
|
+
## Legal note
|
|
325
|
+
|
|
326
|
+
This plugin's **source code** is licensed under [MIT](LICENSE).
|
|
327
|
+
|
|
328
|
+
Video encoding relies on **FFmpeg**, which is licensed under LGPL/GPL. By default, the plugin uses the [`ffmpeg-static`](https://www.npmjs.com/package/ffmpeg-static) npm package, which may install platform-specific FFmpeg binaries as a transitive dependency. When `ffmpeg-static` is unavailable, the plugin uses the FFmpeg executable available on the host system.
|
|
329
|
+
|
|
330
|
+
Please make sure your FFmpeg installation and use comply with the applicable LGPL/GPL license terms.
|
|
331
|
+
|
|
332
|
+
## Disclaimer
|
|
333
|
+
|
|
334
|
+
This is a **community plugin** and is not an official Strapi plugin.
|
|
335
|
+
|
|
336
|
+
Strapi is a trademark of Strapi Solutions SAS.
|
|
337
|
+
|
|
280
338
|
## License
|
|
281
339
|
|
|
282
340
|
[MIT](LICENSE)
|
|
@@ -28,6 +28,9 @@ const enMessages: Record<string, string> = {
|
|
|
28
28
|
[`${PLUGIN_ID}.choice.custom`]: 'Custom',
|
|
29
29
|
[`${PLUGIN_ID}.choice.custom.description`]:
|
|
30
30
|
'Configure format and quality settings specifically for this video.',
|
|
31
|
+
[`${PLUGIN_ID}.optimization.warning.title`]: 'This video may already be well optimized',
|
|
32
|
+
[`${PLUGIN_ID}.optimization.warning.description`]:
|
|
33
|
+
'Re-encoding may not reduce file size and can even make it larger. Consider Keep original, or raise CRF / lower resolution if you need a smaller output.',
|
|
31
34
|
[`${PLUGIN_ID}.settings.global.defaultFormat`]: 'Output format',
|
|
32
35
|
[`${PLUGIN_ID}.settings.global.videoCodec`]: 'Video codec',
|
|
33
36
|
[`${PLUGIN_ID}.settings.global.crf`]: 'CRF (quality)',
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from '@strapi/design-system';
|
|
10
10
|
import { Cross, Sparkle, Stop } from '@strapi/icons';
|
|
11
11
|
import { useIntl } from 'react-intl';
|
|
12
|
+
import { OptimizationBenefitWarning } from './OptimizationBenefitWarning';
|
|
12
13
|
import { OptimizationChoicePicker } from './OptimizationChoicePicker';
|
|
13
14
|
import { OptimizationCustomForm } from './OptimizationVideoFields';
|
|
14
15
|
import { getTranslationKey } from '../pluginId';
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
closeMediaLibraryEditor,
|
|
22
23
|
createCustomForMediaLibraryFile,
|
|
23
24
|
getEditingMediaLibraryDimensions,
|
|
25
|
+
getEditingMediaLibrarySizeBytes,
|
|
24
26
|
getEditingMediaLibraryFileId,
|
|
25
27
|
getEditingMediaLibraryFileName,
|
|
26
28
|
getMediaLibraryCards,
|
|
@@ -49,6 +51,10 @@ export const MediaLibraryCardActionsBridge = () => {
|
|
|
49
51
|
subscribeMediaLibraryCards,
|
|
50
52
|
getEditingMediaLibraryDimensions
|
|
51
53
|
);
|
|
54
|
+
const editingSizeBytes = useSyncExternalStore(
|
|
55
|
+
subscribeMediaLibraryCards,
|
|
56
|
+
getEditingMediaLibrarySizeBytes
|
|
57
|
+
);
|
|
52
58
|
const draftPreference = useSyncExternalStore(
|
|
53
59
|
subscribeMediaLibraryCards,
|
|
54
60
|
getMediaLibraryDraftPreference
|
|
@@ -142,6 +148,15 @@ export const MediaLibraryCardActionsBridge = () => {
|
|
|
142
148
|
{editingFileName}
|
|
143
149
|
</Typography>
|
|
144
150
|
)}
|
|
151
|
+
<OptimizationBenefitWarning
|
|
152
|
+
choice={draftPreference.choice}
|
|
153
|
+
metadata={{
|
|
154
|
+
width: editingDimensions?.width,
|
|
155
|
+
height: editingDimensions?.height,
|
|
156
|
+
sizeBytes: editingSizeBytes,
|
|
157
|
+
}}
|
|
158
|
+
customSettings={resolvedCustom ?? undefined}
|
|
159
|
+
/>
|
|
145
160
|
<OptimizationChoicePicker
|
|
146
161
|
value={draftPreference.choice}
|
|
147
162
|
onChange={setMediaLibraryDraftChoice}
|
|
@@ -212,6 +227,7 @@ export const MediaLibraryCardActionsBridge = () => {
|
|
|
212
227
|
openMediaLibraryEditor(card.fileId, card.fileName, {
|
|
213
228
|
width: card.width,
|
|
214
229
|
height: card.height,
|
|
230
|
+
sizeBytes: card.sizeBytes,
|
|
215
231
|
});
|
|
216
232
|
}}
|
|
217
233
|
>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Box, Typography } from '@strapi/design-system';
|
|
2
|
+
import { useIntl } from 'react-intl';
|
|
3
|
+
import { getTranslationKey, type OptimizationChoice, type OptimizationSettings } from '../pluginId';
|
|
4
|
+
import {
|
|
5
|
+
evaluateOptimizationBenefit,
|
|
6
|
+
type VideoSourceMetadata,
|
|
7
|
+
} from '../utils/evaluateOptimizationBenefit';
|
|
8
|
+
import { getGlobalSettings } from '../utils/uploadAssetStore';
|
|
9
|
+
|
|
10
|
+
interface OptimizationBenefitWarningProps {
|
|
11
|
+
choice: OptimizationChoice;
|
|
12
|
+
metadata: VideoSourceMetadata;
|
|
13
|
+
customSettings?: OptimizationSettings;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const OptimizationBenefitWarning = ({
|
|
17
|
+
choice,
|
|
18
|
+
metadata,
|
|
19
|
+
customSettings,
|
|
20
|
+
}: OptimizationBenefitWarningProps) => {
|
|
21
|
+
const { formatMessage } = useIntl();
|
|
22
|
+
const showWarning = evaluateOptimizationBenefit(
|
|
23
|
+
choice,
|
|
24
|
+
metadata,
|
|
25
|
+
getGlobalSettings(),
|
|
26
|
+
customSettings
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (!showWarning) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Box
|
|
35
|
+
padding={4}
|
|
36
|
+
hasRadius
|
|
37
|
+
background="warning100"
|
|
38
|
+
borderColor="warning200"
|
|
39
|
+
borderStyle="solid"
|
|
40
|
+
borderWidth="1px"
|
|
41
|
+
>
|
|
42
|
+
<Typography variant="omega" fontWeight="bold" textColor="warning700">
|
|
43
|
+
{formatMessage({ id: getTranslationKey('optimization.warning.title') })}
|
|
44
|
+
</Typography>
|
|
45
|
+
<Box paddingTop={1}>
|
|
46
|
+
<Typography variant="pi" textColor="warning700">
|
|
47
|
+
{formatMessage({ id: getTranslationKey('optimization.warning.description') })}
|
|
48
|
+
</Typography>
|
|
49
|
+
</Box>
|
|
50
|
+
</Box>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { Cross, Sparkle } from '@strapi/icons';
|
|
11
11
|
import { useIntl } from 'react-intl';
|
|
12
12
|
import { AssetOptimizationLabel } from './AssetOptimizationLabel';
|
|
13
|
+
import { OptimizationBenefitWarning } from './OptimizationBenefitWarning';
|
|
13
14
|
import { OptimizationChoicePicker } from './OptimizationChoicePicker';
|
|
14
15
|
import { OptimizationCustomForm } from './OptimizationVideoFields';
|
|
15
16
|
import { getTranslationKey } from '../pluginId';
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
getDraftPreference,
|
|
21
22
|
getEditingAssetId,
|
|
22
23
|
getSourceDimensionsForAsset,
|
|
24
|
+
getSourceMetadataForAsset,
|
|
23
25
|
getUploadAssetCards,
|
|
24
26
|
getUploadDialogElement,
|
|
25
27
|
openAssetEditor,
|
|
@@ -43,6 +45,7 @@ export const UploadEnhancerBridge = () => {
|
|
|
43
45
|
|
|
44
46
|
const editingCard = cards.find((card) => card.assetId === editingAssetId);
|
|
45
47
|
const sourceDimensions = editingAssetId ? getSourceDimensionsForAsset(editingAssetId) : undefined;
|
|
48
|
+
const sourceMetadata = editingAssetId ? getSourceMetadataForAsset(editingAssetId) : {};
|
|
46
49
|
const resolvedCustom =
|
|
47
50
|
editingAssetId && draftPreference.choice === 'custom'
|
|
48
51
|
? resolveCustomSettingsForAsset(editingAssetId, draftPreference.custom)
|
|
@@ -132,6 +135,16 @@ export const UploadEnhancerBridge = () => {
|
|
|
132
135
|
{editingCard.assetName}
|
|
133
136
|
</Typography>
|
|
134
137
|
)}
|
|
138
|
+
<OptimizationBenefitWarning
|
|
139
|
+
choice={draftPreference.choice}
|
|
140
|
+
metadata={{
|
|
141
|
+
width: editingCard?.width ?? sourceMetadata.width ?? sourceDimensions?.width,
|
|
142
|
+
height: editingCard?.height ?? sourceMetadata.height ?? sourceDimensions?.height,
|
|
143
|
+
sizeBytes: editingCard?.sizeBytes ?? sourceMetadata.sizeBytes,
|
|
144
|
+
durationSeconds: editingCard?.durationSeconds ?? sourceMetadata.durationSeconds,
|
|
145
|
+
}}
|
|
146
|
+
customSettings={resolvedCustom ?? undefined}
|
|
147
|
+
/>
|
|
135
148
|
<OptimizationChoicePicker
|
|
136
149
|
value={draftPreference.choice}
|
|
137
150
|
onChange={setDraftChoice}
|
|
@@ -3,12 +3,14 @@ import { Box, Flex, Typography } from '@strapi/design-system';
|
|
|
3
3
|
import { Sparkle } from '@strapi/icons';
|
|
4
4
|
import { useIntl } from 'react-intl';
|
|
5
5
|
import { AssetOptimizationLabel } from '../AssetOptimizationLabel';
|
|
6
|
+
import { OptimizationBenefitWarning } from '../OptimizationBenefitWarning';
|
|
6
7
|
import { OptimizationChoicePicker } from '../OptimizationChoicePicker';
|
|
7
8
|
import { OptimizationCustomForm } from '../OptimizationVideoFields';
|
|
8
9
|
import {
|
|
9
10
|
createCustomForAsset,
|
|
10
11
|
getAssetPreference,
|
|
11
12
|
getSourceDimensionsForAsset,
|
|
13
|
+
getSourceMetadataForAsset,
|
|
12
14
|
resolveCustomSettingsForAsset,
|
|
13
15
|
type UploadAssetEntry,
|
|
14
16
|
} from '../../utils/uploadAssetStore';
|
|
@@ -28,6 +30,7 @@ export const PendingAssetStep = ({
|
|
|
28
30
|
const { formatMessage } = useIntl();
|
|
29
31
|
|
|
30
32
|
const sourceDimensions = getSourceDimensionsForAsset(asset.assetId);
|
|
33
|
+
const sourceMetadata = getSourceMetadataForAsset(asset.assetId);
|
|
31
34
|
const resolvedCustom = resolveCustomSettingsForAsset(asset.assetId, preference.custom);
|
|
32
35
|
|
|
33
36
|
const handleChoiceChange = (choice: AssetOptimizationPreference['choice']) => {
|
|
@@ -54,6 +57,17 @@ export const PendingAssetStep = ({
|
|
|
54
57
|
{asset.assetName}
|
|
55
58
|
</Typography>
|
|
56
59
|
|
|
60
|
+
<OptimizationBenefitWarning
|
|
61
|
+
choice={preference.choice}
|
|
62
|
+
metadata={{
|
|
63
|
+
width: asset.width ?? sourceMetadata.width ?? sourceDimensions?.width,
|
|
64
|
+
height: asset.height ?? sourceMetadata.height ?? sourceDimensions?.height,
|
|
65
|
+
sizeBytes: asset.sizeBytes ?? sourceMetadata.sizeBytes,
|
|
66
|
+
durationSeconds: asset.durationSeconds ?? sourceMetadata.durationSeconds,
|
|
67
|
+
}}
|
|
68
|
+
customSettings={preference.choice === 'custom' ? resolvedCustom : undefined}
|
|
69
|
+
/>
|
|
70
|
+
|
|
57
71
|
<OptimizationChoicePicker value={preference.choice} onChange={handleChoiceChange} />
|
|
58
72
|
|
|
59
73
|
{preference.choice === 'custom' && (
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
"choice.global.description": "Uses the global optimization profile configured in Settings.",
|
|
46
46
|
"choice.custom": "Custom",
|
|
47
47
|
"choice.custom.description": "Configure format and quality settings specifically for this video.",
|
|
48
|
+
"optimization.warning.title": "This video may already be well optimized",
|
|
49
|
+
"optimization.warning.description": "Re-encoding may not reduce file size and can even make it larger. Consider Keep original, or raise CRF / lower resolution if you need a smaller output.",
|
|
48
50
|
"upload.optimization.label": "Optimization",
|
|
49
51
|
"upload.button.label": "Optimization settings",
|
|
50
52
|
"upload.modal.title": "Video optimization",
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
"choice.global.description": "Settings altındaki global optimizasyon profilini kullanır.",
|
|
46
46
|
"choice.custom": "Özel",
|
|
47
47
|
"choice.custom.description": "Bu video için format ve kalite ayarlarını ayrıca yapılandırın.",
|
|
48
|
+
"optimization.warning.title": "Bu video zaten iyi optimize edilmiş olabilir",
|
|
49
|
+
"optimization.warning.description": "Yeniden encode dosya boyutunu düşürmeyebilir, hatta büyütebilir. Olduğu gibi bırakmayı düşünün; daha küçük çıktı için CRF değerini yükseltin veya çözünürlüğü düşürün.",
|
|
48
50
|
"upload.optimization.label": "Optimizasyon",
|
|
49
51
|
"upload.button.label": "Optimizasyon ayarları",
|
|
50
52
|
"upload.modal.title": "Video optimizasyonu",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GlobalOptimizationSettings,
|
|
3
|
+
OptimizationChoice,
|
|
4
|
+
OptimizationSettings,
|
|
5
|
+
} from '../pluginId';
|
|
6
|
+
|
|
7
|
+
export interface VideoSourceMetadata {
|
|
8
|
+
width?: number;
|
|
9
|
+
height?: number;
|
|
10
|
+
sizeBytes?: number;
|
|
11
|
+
durationSeconds?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const willResize = (
|
|
15
|
+
sourceWidth: number,
|
|
16
|
+
sourceHeight: number,
|
|
17
|
+
targetWidth: number,
|
|
18
|
+
targetHeight: number,
|
|
19
|
+
resizeMode: 'exact' | 'fit-within'
|
|
20
|
+
) => {
|
|
21
|
+
if (targetWidth === sourceWidth && targetHeight === sourceHeight) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (resizeMode === 'fit-within') {
|
|
26
|
+
return sourceWidth > targetWidth || sourceHeight > targetHeight;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return true;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const bitrateMbps = (sizeBytes: number, durationSeconds: number) =>
|
|
33
|
+
(sizeBytes * 8) / durationSeconds / 1_000_000;
|
|
34
|
+
|
|
35
|
+
const maxBitrateMbpsForResolution = (maxDimension: number) => {
|
|
36
|
+
if (maxDimension <= 720) {
|
|
37
|
+
return 1.5;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (maxDimension <= 1080) {
|
|
41
|
+
return 2.5;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (maxDimension <= 1440) {
|
|
45
|
+
return 5;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return 8;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const isBitrateAlreadyEfficient = (metadata: VideoSourceMetadata) => {
|
|
52
|
+
const { width, height, sizeBytes, durationSeconds } = metadata;
|
|
53
|
+
|
|
54
|
+
if (!width || !height || !sizeBytes || !durationSeconds || durationSeconds <= 0) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const maxDimension = Math.max(width, height);
|
|
59
|
+
const bitrate = bitrateMbps(sizeBytes, durationSeconds);
|
|
60
|
+
|
|
61
|
+
return bitrate <= maxBitrateMbpsForResolution(maxDimension);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const evaluateOptimizationBenefit = (
|
|
65
|
+
choice: OptimizationChoice,
|
|
66
|
+
metadata: VideoSourceMetadata,
|
|
67
|
+
globalSettings: GlobalOptimizationSettings,
|
|
68
|
+
customSettings?: OptimizationSettings
|
|
69
|
+
): boolean => {
|
|
70
|
+
if (choice === 'original') {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { width, height } = metadata;
|
|
75
|
+
|
|
76
|
+
if (!width || !height) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (choice === 'global') {
|
|
81
|
+
const wouldResize = willResize(
|
|
82
|
+
width,
|
|
83
|
+
height,
|
|
84
|
+
globalSettings.maxWidth,
|
|
85
|
+
globalSettings.maxHeight,
|
|
86
|
+
'fit-within'
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (wouldResize) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return isBitrateAlreadyEfficient(metadata) || Boolean(metadata.sizeBytes);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (choice === 'custom' && customSettings) {
|
|
97
|
+
const wouldResizeCustom = willResize(
|
|
98
|
+
width,
|
|
99
|
+
height,
|
|
100
|
+
customSettings.maxWidth,
|
|
101
|
+
customSettings.maxHeight,
|
|
102
|
+
'exact'
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (wouldResizeCustom) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return isBitrateAlreadyEfficient(metadata) || Boolean(metadata.sizeBytes);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return false;
|
|
113
|
+
};
|
|
@@ -200,8 +200,14 @@ const collectCardActions = (uploadAssets: UploadAssetRecord[]): MediaLibraryCard
|
|
|
200
200
|
entries.push({
|
|
201
201
|
fileId,
|
|
202
202
|
fileName: asset?.name ?? card.querySelector('[id$="-title"]')?.textContent?.trim() ?? '',
|
|
203
|
-
width: dimensions?.width,
|
|
204
|
-
height: dimensions?.height,
|
|
203
|
+
width: dimensions?.width ?? asset?.width,
|
|
204
|
+
height: dimensions?.height ?? asset?.height,
|
|
205
|
+
sizeBytes:
|
|
206
|
+
typeof asset?.sizeInBytes === 'number'
|
|
207
|
+
? asset.sizeInBytes
|
|
208
|
+
: typeof asset?.size === 'number'
|
|
209
|
+
? Math.round(asset.size * 1024)
|
|
210
|
+
: undefined,
|
|
205
211
|
optimizeHost,
|
|
206
212
|
cancelHost,
|
|
207
213
|
});
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
setGlobalSettings,
|
|
12
12
|
setUploadAssetCards,
|
|
13
13
|
setUploadDialogElement,
|
|
14
|
-
|
|
14
|
+
updateAssetMetadata,
|
|
15
15
|
type UploadAssetEntry,
|
|
16
16
|
} from './uploadAssetStore';
|
|
17
17
|
import { isVideoFileName } from '../pluginId';
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
ensureVideoElementDimensions,
|
|
21
21
|
findUploadFilesInDialog,
|
|
22
22
|
matchUploadFile,
|
|
23
|
-
|
|
23
|
+
probeVideoFileMetadata,
|
|
24
24
|
} from './probeVideoDimensions';
|
|
25
25
|
|
|
26
26
|
const pendingDimensionProbes = new Set<string>();
|
|
@@ -312,12 +312,17 @@ const queueDimensionProbe = (
|
|
|
312
312
|
const file = matchUploadFile(files, assetName);
|
|
313
313
|
|
|
314
314
|
if (file) {
|
|
315
|
-
|
|
315
|
+
const metadata = await probeVideoFileMetadata(file);
|
|
316
|
+
|
|
317
|
+
if (metadata) {
|
|
318
|
+
updateAssetMetadata(assetId, metadata);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
316
321
|
}
|
|
317
322
|
}
|
|
318
323
|
|
|
319
324
|
if (dimensions) {
|
|
320
|
-
|
|
325
|
+
updateAssetMetadata(assetId, dimensions);
|
|
321
326
|
}
|
|
322
327
|
} finally {
|
|
323
328
|
pendingDimensionProbes.delete(assetId);
|
|
@@ -12,6 +12,7 @@ export interface MediaLibraryCardEntry {
|
|
|
12
12
|
fileName: string;
|
|
13
13
|
width?: number;
|
|
14
14
|
height?: number;
|
|
15
|
+
sizeBytes?: number;
|
|
15
16
|
optimizeHost: HTMLElement;
|
|
16
17
|
cancelHost: HTMLElement;
|
|
17
18
|
}
|
|
@@ -23,6 +24,7 @@ const listeners = new Set<() => void>();
|
|
|
23
24
|
let editingFileId: number | null = null;
|
|
24
25
|
let editingFileName: string | null = null;
|
|
25
26
|
let editingDimensions: { width?: number; height?: number } | null = null;
|
|
27
|
+
let editingSizeBytes: number | undefined;
|
|
26
28
|
let draftPreference: AssetOptimizationPreference | null = null;
|
|
27
29
|
let enqueueInFlight = false;
|
|
28
30
|
let cancelInFlight = new Set<number>();
|
|
@@ -96,6 +98,8 @@ export const getEditingMediaLibraryFileName = () => editingFileName;
|
|
|
96
98
|
|
|
97
99
|
export const getEditingMediaLibraryDimensions = () => editingDimensions;
|
|
98
100
|
|
|
101
|
+
export const getEditingMediaLibrarySizeBytes = () => editingSizeBytes;
|
|
102
|
+
|
|
99
103
|
export const getMediaLibraryDraftPreference = (): AssetOptimizationPreference => {
|
|
100
104
|
return draftPreference ?? STABLE_EMPTY_DRAFT;
|
|
101
105
|
};
|
|
@@ -107,11 +111,12 @@ export const isMediaLibraryCancelInFlight = (fileId: number) => cancelInFlight.h
|
|
|
107
111
|
export const openMediaLibraryEditor = (
|
|
108
112
|
fileId: number,
|
|
109
113
|
fileName: string,
|
|
110
|
-
|
|
114
|
+
metadata?: { width?: number; height?: number; sizeBytes?: number }
|
|
111
115
|
) => {
|
|
112
116
|
editingFileId = fileId;
|
|
113
117
|
editingFileName = fileName;
|
|
114
|
-
editingDimensions =
|
|
118
|
+
editingDimensions = metadata ? { width: metadata.width, height: metadata.height } : null;
|
|
119
|
+
editingSizeBytes = metadata?.sizeBytes;
|
|
115
120
|
draftPreference = createDefaultPreference();
|
|
116
121
|
|
|
117
122
|
if (draftPreference.choice === 'custom') {
|
|
@@ -125,6 +130,7 @@ export const closeMediaLibraryEditor = () => {
|
|
|
125
130
|
editingFileId = null;
|
|
126
131
|
editingFileName = null;
|
|
127
132
|
editingDimensions = null;
|
|
133
|
+
editingSizeBytes = undefined;
|
|
128
134
|
draftPreference = null;
|
|
129
135
|
notify();
|
|
130
136
|
};
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
export const probeVideoFileDimensions = (
|
|
2
2
|
file: File
|
|
3
3
|
): Promise<{ width: number; height: number } | undefined> =>
|
|
4
|
+
probeVideoFileMetadata(file).then((metadata) =>
|
|
5
|
+
metadata?.width && metadata?.height
|
|
6
|
+
? { width: metadata.width, height: metadata.height }
|
|
7
|
+
: undefined
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export const probeVideoFileMetadata = (
|
|
11
|
+
file: File
|
|
12
|
+
): Promise<
|
|
13
|
+
{ width: number; height: number; durationSeconds?: number; sizeBytes: number } | undefined
|
|
14
|
+
> =>
|
|
4
15
|
new Promise((resolve) => {
|
|
5
16
|
const url = URL.createObjectURL(file);
|
|
6
17
|
const video = document.createElement('video');
|
|
@@ -13,12 +24,20 @@ export const probeVideoFileDimensions = (
|
|
|
13
24
|
};
|
|
14
25
|
|
|
15
26
|
video.onloadedmetadata = () => {
|
|
16
|
-
const
|
|
27
|
+
const metadata =
|
|
17
28
|
video.videoWidth > 0 && video.videoHeight > 0
|
|
18
|
-
? {
|
|
29
|
+
? {
|
|
30
|
+
width: video.videoWidth,
|
|
31
|
+
height: video.videoHeight,
|
|
32
|
+
durationSeconds:
|
|
33
|
+
Number.isFinite(video.duration) && video.duration > 0
|
|
34
|
+
? video.duration
|
|
35
|
+
: undefined,
|
|
36
|
+
sizeBytes: file.size,
|
|
37
|
+
}
|
|
19
38
|
: undefined;
|
|
20
39
|
cleanup();
|
|
21
|
-
resolve(
|
|
40
|
+
resolve(metadata);
|
|
22
41
|
};
|
|
23
42
|
|
|
24
43
|
video.onerror = () => {
|