@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.
Files changed (29) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +59 -1
  3. package/admin/src/components/BridgeProviders.tsx +3 -0
  4. package/admin/src/components/MediaLibraryCardActionsBridge.tsx +16 -0
  5. package/admin/src/components/OptimizationBenefitWarning.tsx +52 -0
  6. package/admin/src/components/UploadEnhancerBridge.tsx +13 -0
  7. package/admin/src/components/upload/PendingAssetStep.tsx +14 -0
  8. package/admin/src/translations/en.json +2 -0
  9. package/admin/src/translations/tr.json +2 -0
  10. package/admin/src/utils/evaluateOptimizationBenefit.ts +113 -0
  11. package/admin/src/utils/initMediaLibraryCardActions.ts +8 -2
  12. package/admin/src/utils/initUploadEnhancer.ts +9 -4
  13. package/admin/src/utils/mediaLibraryCardStore.ts +8 -2
  14. package/admin/src/utils/mediaLibraryQueryBridge.ts +4 -0
  15. package/admin/src/utils/probeVideoDimensions.ts +22 -3
  16. package/admin/src/utils/uploadAssetStore.ts +48 -7
  17. package/dist/admin/{SettingsPage-D6e536P0.mjs → SettingsPage-CiTCB9pJ.mjs} +1 -1
  18. package/dist/admin/{SettingsPage-CN2fR83m.js → SettingsPage-CzelpJtC.js} +1 -1
  19. package/dist/admin/{en-CsHicGzL.mjs → en-Cv305Xlb.mjs} +2 -0
  20. package/dist/admin/{en-CqM903j3.js → en-YfXcNqYi.js} +2 -0
  21. package/dist/admin/{index-rAmxCQz6.mjs → index-B_Qy06R1.mjs} +190 -17
  22. package/dist/admin/{index-DOuHOS2G.js → index-CCnMe2zb.js} +190 -17
  23. package/dist/admin/{index-BjWoS0YU.js → index-CcmEXX7b.js} +1 -1
  24. package/dist/admin/{index-Cs_uiChW.mjs → index-ClPTEo43.mjs} +2 -2
  25. package/dist/admin/index.js +1 -1
  26. package/dist/admin/index.mjs +1 -1
  27. package/dist/admin/{tr-Y0-ANilh.mjs → tr-CEXm27JX.mjs} +2 -0
  28. package/dist/admin/{tr-muzHkdC4.js → tr-Dos6G22t.js} +2 -0
  29. package/package.json +1 -1
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026
3
+ Copyright (c) 2026 frkntmbs
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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 is bundled via [`ffmpeg-static`](https://www.npmjs.com/package/ffmpeg-static). If unavailable, the plugin falls back to a system `ffmpeg` binary on `PATH`.
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
- updateAssetDimensions,
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
- probeVideoFileDimensions,
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
- dimensions = await probeVideoFileDimensions(file);
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
- updateAssetDimensions(assetId, dimensions);
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
- dimensions?: { width?: number; height?: number }
114
+ metadata?: { width?: number; height?: number; sizeBytes?: number }
111
115
  ) => {
112
116
  editingFileId = fileId;
113
117
  editingFileName = fileName;
114
- editingDimensions = dimensions ?? null;
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
  };
@@ -10,6 +10,10 @@ export interface UploadAssetRecord {
10
10
  url?: string;
11
11
  mime?: string;
12
12
  ext?: string;
13
+ width?: number;
14
+ height?: number;
15
+ size?: number;
16
+ sizeInBytes?: number;
13
17
  }
14
18
 
15
19
  let queryClientRef: QueryClient | null = null;
@@ -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 dimensions =
27
+ const metadata =
17
28
  video.videoWidth > 0 && video.videoHeight > 0
18
- ? { width: video.videoWidth, height: video.videoHeight }
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(dimensions);
40
+ resolve(metadata);
22
41
  };
23
42
 
24
43
  video.onerror = () => {