You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 99ab991
Browse filesBrowse the repository at this point in the historyBrowse files
Docs: Update client-side media docs for WP 7.1 release plan
The feature shipping target moved from 7.0 to 7.1 (per #76756). Bring
the architecture explanation and how-to guide up to date with the
post-7.0-cycle state of the code:
- Reposition introductions around 7.1 with 7.0 as the groundwork cycle.
- HEIC/HEIF: document the canvas-based fallback path (createImageBitmap,
HTMLImageElement+OffscreenCanvas, HEIC container parsing + WebCodecs
VideoDecoder), JPEG companion file, and isHeicCanvasSupported() gate.
- AVIF: note the wp_prevent_unsupported_mime_type_uploads bypass when
generate_sub_sizes=false (#76371).
- Batch thumbnail generation via image.copyMemory() / thumbnailImage()
(#76979).
- Sub-size deduplication via image_size: string|string[] on the sideload
route (#77036).
- Single VIPS instance via promise caching (#76780).
- Build-output trimming: remove vips-jxl.wasm (#76639), skip non-min
worker (#76615), skip WASM-inlined sourcemaps (#75993).
- COI: <img> excluded from cross-origin attribute injection (#76618).
- Loosened feature-detection thresholds: 2 cores, 3g (#76616).
- Post-saving lock fix: Save Draft now respects the lock (#76973).
- convert_format declared as boolean on sideload route (#77565).
- Fix incorrect REST index settings list (only image_sizes and
image_size_threshold are exposed).
- Remove references to client_side_supported_mime_types filter
(PR #76549 was closed unmerged); state the supported MIME set is
fixed at CLIENT_SIDE_SUPPORTED_MIME_TYPES.
Refs #75111.
Copy file name to clipboardExpand all lines: docs/explanations/architecture/client-side-media.md
+61-24Lines changed: 61 additions & 24 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,7 +2,7 @@
2
2
3
3
## Introduction
4
4
5
-
Client-side media processing is a capability in WordPress that handles image compression, resizing, format conversion, rotation, and thumbnail generation directly in the user's browser using WebAssembly, rather than on the server.
5
+
Client-side media processing is a capability shipping in WordPress 7.1 that handles image compression, resizing, format conversion, rotation, and thumbnail generation directly in the user's browser using WebAssembly, rather than on the server. Most of the underlying infrastructure landed during the 7.0 development cycle when the feature graduated from a Gutenberg experiment; the 7.1 cycle adds HEIC support, AVIF end-to-end uploads, performance improvements, and finishes hardening before the public release.
6
6
7
7
Key benefits include:
8
8
@@ -103,13 +103,15 @@ The `uploadItem()` operation uploads the (optionally transcoded) image to the se
103
103
104
104
After the upload completes, the server responds with `missing_image_sizes` — a list of thumbnail sizes that still need to be generated. The `generateThumbnails()` operation creates sideload items for each missing size.
105
105
106
+
Sizes are deduplicated by their effective output dimensions before processing ([#77036](https://github.com/WordPress/gutenberg/pull/77036)). When a theme registers an image size with the same width/height/crop as a built-in size (for example, Twenty Eleven's `large` matches WordPress core's `medium_large` at 768×1024), the client generates one physical file and tells the server to register it under both names by passing an array to the sideload route's `image_size` parameter. This matches how the server-side path handles duplicate dimensions and avoids producing extra files with `-1` suffixes.
107
+
106
108
For images that exceed the `big_image_size_threshold` (default: 2560px), a scaled version is also generated and sideloaded.
107
109
108
110
If the original image requires EXIF rotation (orientation ≠ 1), a rotated version is generated and sideloaded as well.
109
111
110
112
### 5. Sideload sub-sizes
111
113
112
-
Each thumbnail is processed client-side (resize/crop → optional format conversion) and then uploaded to the server via the sideload endpoint (`POST /wp/v2/media/{id}/sideload`). The server stores each sub-size in the attachment metadata.
114
+
Each thumbnail is processed client-side (resize/crop → optional format conversion) and then uploaded to the server via the sideload endpoint (`POST /wp/v2/media/{id}/sideload`). The server stores each sub-size in the attachment metadata, registering it under each size name supplied in `image_size`.
113
115
114
116
To prevent race conditions, sideload uploads to the same post are serialized — if one item is being sideloaded, other items targeting the same post are paused until the sideload completes.
115
117
@@ -142,16 +144,18 @@ The quality value is passed through to the vips worker during resize and crop op
142
144
143
145
## WASM module loading
144
146
145
-
The WASM binary (~3.8 MB) is loaded lazily — only when the first image needs processing:
147
+
The WASM worker bundle (~13 MB minified, ~10 MB after build-output trimming) is loaded lazily — only when the first image needs processing:
146
148
147
-
1.**Loader discovery**: `@wordpress/vips/loader` is a minimal module that returns a dynamic `import( '@wordpress/vips/worker' )`. This allows WordPress's script module system to discover the dependency without loading the heavy WASM binary upfront.
149
+
1.**Loader discovery**: `@wordpress/vips/loader` is a minimal module that returns a dynamic `import( '@wordpress/vips/worker' )`. This lets WordPress's script module system discover the dependency without pulling the heavy WASM binary into the initial editor bundle.
148
150
149
-
2.**Worker creation**: When processing is first needed, a Web Worker is created from an inline blob URL. The WASM modules (`vips.wasm` and `vips-jxl.wasm`) are inlined as base64 data URLs at build time, eliminating separate file downloads and WASM MIME type issues.
151
+
2.**Worker creation**: When processing is first needed, a Web Worker is created from an inline blob URL. The WASM modules (`vips.wasm` and `vips-heif.wasm`) are inlined as base64 data URLs at build time, eliminating separate file downloads and WASM MIME type issues. The unused `vips-jxl.wasm` module was removed in [#76639](https://github.com/WordPress/gutenberg/pull/76639) (saving ~3.1 MB), and the unminified worker plus its source map are skipped at build time ([#76615](https://github.com/WordPress/gutenberg/pull/76615), [#75993](https://github.com/WordPress/gutenberg/pull/75993)) since they provide no debugging value for inlined base64 WASM.
150
152
151
-
3.**Instance reuse**: The vips instance is created once and reused across all operations. The worker is kept alive until the upload queue is empty, at which point `terminateVipsWorker()` is called to free memory.
153
+
3.**Single instance**: `getVips()` caches a *promise* rather than a fully-initialized instance, so concurrent first-time calls share one initialization rather than racing to create multiple vips instances ([#76780](https://github.com/WordPress/gutenberg/pull/76780)). The worker is kept alive until the upload queue is empty, at which point `terminateVipsWorker()` is called to free memory.
152
154
153
155
4.**Memory management**: Emscripten's `setAutoDeleteLater(true)` is configured for automatic cleanup. Each operation also calls a manual cleanup function after completion. Operations are tracked via an `inProgressOperations` set, allowing mid-operation cancellation via a progress callback kill switch.
154
156
157
+
5.**Batch thumbnail generation**: The `batchResizeImage()` path decodes the source image once with `newFromBuffer()`, then calls `image.copyMemory()` to materialize the pixels in WASM memory. Each sub-size is generated by calling `thumbnailImage()` against the in-memory copy and writing directly to the output format ([#76979](https://github.com/WordPress/gutenberg/pull/76979)). This avoids re-decoding the source for every thumbnail — a 60× improvement per the [libvips discussion](https://github.com/libvips/libvips/discussions/4585) the change is based on. The path falls back to per-thumbnail processing if the batch operation fails (for example, animated GIFs).
158
+
155
159
## Cross-origin isolation
156
160
157
161
Client-side media processing requires `SharedArrayBuffer` for WASM threading, which browsers only expose in [cross-origin isolated](https://developer.mozilla.org/en-US/docs/Web/API/Window/crossOriginIsolated) contexts.
Cross-origin isolation requires that cross-origin resources include proper CORS attributes. WordPress handles this at two levels:
181
185
182
-
**Server-side** (PHP output buffer): The `wp_add_crossorigin_attributes()` function uses `WP_HTML_Tag_Processor` to add `crossorigin="anonymous"` to `<audio>`, `<img>`, `<link>`, `<script>`, `<video>`, and `<source>` tags that load cross-origin URLs.
186
+
**Server-side** (PHP output buffer): The `wp_add_crossorigin_attributes()` function uses `WP_HTML_Tag_Processor` to add `crossorigin="anonymous"` to `<audio>`, `<link>`, `<script>`, `<video>`, and `<source>` tags that load cross-origin URLs.
183
187
184
188
**Client-side** (JavaScript MutationObserver): A MutationObserver in `packages/block-editor/src/hooks/cross-origin-isolation.js` monitors the DOM for dynamically added elements and adds `crossorigin="anonymous"` attributes at runtime.
185
189
190
+
> **Note:**`<img>` was intentionally removed from the mutated-element list in [#76618](https://github.com/WordPress/gutenberg/pull/76618). Adding `crossorigin="anonymous"` to images that link to external hosts without CORS headers (a common case for the Image block linking to a third-party URL) caused those previews to break. With the move to Document-Isolation-Policy, image elements no longer need the attribute for client-side processing to function — DIP doesn't require `<img>` resources to be CORS-enabled.
191
+
186
192
### Browser support
187
193
188
194
Client-side media processing is limited to Chromium-based browsers that support `Document-Isolation-Policy`:
@@ -198,18 +204,28 @@ Browsers that do not support DIP fall back automatically to server-side processi
198
204
199
205
## Feature detection
200
206
201
-
Before enabling client-side processing, the browser's capabilities are checked (in `packages/upload-media/src/feature-detection.ts`). All checks must pass; failure at any point causes a transparent fallback to server-side processing.
207
+
Before enabling client-side processing, the browser's capabilities are checked in `packages/upload-media/src/feature-detection.ts`. All checks must pass; failure at any point causes a transparent fallback to server-side processing.
208
+
209
+
| Check | Threshold | Reason |
210
+
| --- | --- | --- |
211
+
| WebAssembly available | — | Required for wasm-vips |
212
+
| SharedArrayBuffer available | — | Required for WASM threading (implies Document-Isolation-Policy is active) |
213
+
| Web Worker available | — | Required for the off-main-thread vips worker |
214
+
| Device memory | > 2 GB | WASM image processing can hold the full image in memory plus working buffers; very low-memory devices can OOM |
215
+
| Hardware concurrency | ≥ 2 CPU cores | WASM workers can monopolize a core for tens of seconds during encode |
216
+
| Network connection | not `2g`/`slow-2g`, no Save-Data | Avoid the ~13 MB worker download on connections that can't bear it |
217
+
| CSP allows `blob:` workers | — | Required for inline worker creation; verified by attempting to construct a worker from a blob URL |
202
218
203
-
| Check | Reason |
204
-
| --- | --- |
205
-
| WebAssembly available | Required for wasm-vips |
206
-
| SharedArrayBuffer available | Required for WASM threading (implies Document-Isolation-Policy is active) |
These thresholds were tuned in [#76616](https://github.com/WordPress/gutenberg/pull/76616): the CPU minimum was lowered from 4 to 2 cores, and `3g` connections are now allowed. The device-memory and Save-Data gates are unchanged.
208
220
209
-
The PHP-side feature flag (`wp_client_side_media_processing_enabled` filter) is also checked before any JavaScript feature detection runs. Browser-level checks (device memory, network conditions, credentialless iframe support) are gated server-side by limiting DIP to Chromium 137+, which implies a modern, capable device.
221
+
The PHP-side feature flag (`wp_client_side_media_processing_enabled` filter) is also checked before any JavaScript feature detection runs. The Document-Isolation-Policy header is only sent on Chromium 137+, which short-circuits the JavaScript path on browsers that don't support cross-origin isolation through DIP.
222
+
223
+
A separate `isHeicCanvasSupported()` check (`createImageBitmap` + `OffscreenCanvas`) gates the HEIC fallback path. This runs independently of full VIPS support so Safari (which can't run the WASM pipeline but can decode HEIC natively) can still handle iPhone photos in the browser.
210
224
211
225
## Supported formats
212
226
227
+
The following formats are processed in the WASM/vips pipeline (`CLIENT_SIDE_SUPPORTED_MIME_TYPES`):
@@ -219,10 +235,22 @@ The PHP-side feature flag (`wp_client_side_media_processing_enabled` filter) is
219
235
| GIF | Yes | Yes | No | Yes | Yes |
220
236
221
237
Notes:
222
-
- HEIC/HEIF is not supported due to trademark/licensing concerns.
223
238
- AVIF encoding uses `effort: 2` to balance encoding speed with quality.
224
239
- Animated GIF and WebP images preserve all frames during processing.
225
240
- PNG-to-JPEG conversion is skipped when the PNG has transparency.
241
+
- AVIF uploads bypass the server's `wp_prevent_unsupported_mime_type_uploads` check when `generate_sub_sizes=false`, so a host without server-side AVIF support can still accept client-processed AVIF files.
242
+
243
+
### HEIC/HEIF
244
+
245
+
HEIC/HEIF is handled via a canvas-based fallback path rather than wasm-vips, since the HEVC codec used by HEIC has patent/licensing restrictions that prevent shipping a decoder in the browser bundle.
246
+
247
+
When a HEIC file is uploaded, the client tries three decoding strategies in order:
248
+
249
+
1.**`createImageBitmap()`** — Works in Safari (which can decode HEIC via macOS platform codecs) and any future browser that adds HEIC to its image pipeline.
250
+
2.**OS-licensed image decoders via `HTMLImageElement` → `OffscreenCanvas`** — A second-tier path for browsers with platform HEIC support.
251
+
3.**HEIC container parsing + WebCodecs `VideoDecoder`** — Chrome 107+ on macOS can decode HEVC bitstreams via VideoToolbox even without HEIC image support. The client parses the ISOBMFF container, extracts the HEVC tiles, and re-assembles them on a canvas.
252
+
253
+
The decoded image is exported as a JPEG (`.jpg`, MIME `image/jpeg`) and uploaded with the original HEIC kept as a companion file in `$metadata['original']`. The server is responsible for sub-size generation in this path (`generate_sub_sizes: true`, `convert_format: true`), so the `image_editor_output_format` mapping is preserved for the JPEG sub-sizes.
226
254
227
255
## Fallback behavior
228
256
@@ -243,9 +271,10 @@ Client-side media processing extends the WordPress REST API in several ways:
|`generate_sub_sizes`|`POST /wp/v2/media`| boolean |`true`| When `false`, the server skips thumbnail generation. The client will generate and sideload thumbnails. |
247
-
|`convert_format`|`POST /wp/v2/media`, `POST /wp/v2/media/{id}/sideload`| boolean |`true`| When `false`, the server skips format conversion (via `image_editor_output_format` filter). |
248
-
|`replace_file`|`POST /wp/v2/media/{id}/sideload`| boolean |`false`| When `true`, replaces the attachment's main file with the sideloaded file, updating the MIME type and metadata and deleting the old file. |
274
+
|`generate_sub_sizes`|`POST /wp/v2/media`, `POST /wp/v2/media/{id}/sideload`| boolean |`true`| When `false`, the server skips thumbnail generation. The client generates and sideloads thumbnails itself. |
275
+
|`convert_format`|`POST /wp/v2/media`, `POST /wp/v2/media/{id}/sideload`| boolean |`true`| When `false`, the server skips format conversion via the `image_editor_output_format` filter. Declared as a boolean on the sideload route since [#77565](https://github.com/WordPress/gutenberg/pull/77565) so REST coerces `multipart/form-data` string values correctly. |
276
+
|`replace_file`|`POST /wp/v2/media/{id}/sideload`| boolean |`false`| When `true`, replaces the attachment's main file with the sideloaded file, updating the MIME type and metadata and deleting the old file. Used for the HEIC → JPEG companion path. |
277
+
|`image_size`|`POST /wp/v2/media/{id}/sideload`| string \| string[]| — | The image size name (e.g., `thumbnail`, `medium`, `scaled`, `original`). Accepts an array of names since [#77036](https://github.com/WordPress/gutenberg/pull/77036) so a single physical file can be registered under multiple sizes that share dimensions. |
249
278
250
279
When `generate_sub_sizes` is `false`, the following server-side filters are also temporarily disabled:
@@ -282,13 +311,12 @@ The endpoint requires `edit_post` and `upload_files` capabilities. It reads the
282
311
283
312
### REST index media settings
284
313
285
-
When client-side processing is enabled, the REST API root index (`GET /`) includes additional media settings (for users with `upload_files` capability):
314
+
When client-side processing is enabled, the REST API root index (`GET /`) is augmented (for users with `upload_files` capability) with:
315
+
316
+
-`image_sizes` — All registered image sizes with dimensions and crop settings, derived from `wp_get_registered_image_subsizes()`.
317
+
-`image_size_threshold` — The current `big_image_size_threshold` filter value.
286
318
287
-
-`image_sizes` — All registered image sizes with dimensions and crop settings.
288
-
-`image_size_threshold` — The big image size threshold value.
289
-
-`image_output_formats` — Format conversion map (respects `image_editor_output_format` filter).
-`clientSideSupportedMimeTypes` — Array of MIME types eligible for client-side processing (respects `client_side_supported_mime_types` filter).
319
+
Other server-side filters (`image_editor_output_format`, `image_save_progressive`) are read at upload time on the server rather than exposed via the index. The set of MIME types eligible for client-side processing is fixed at `CLIENT_SIDE_SUPPORTED_MIME_TYPES` (JPEG, PNG, GIF, WebP, AVIF) — there is no public filter for this list.
292
320
293
321
## Concurrency
294
322
@@ -302,3 +330,12 @@ The upload store enforces two separate concurrency limits to balance performance
302
330
When a concurrency limit is reached, new items wait in the queue. As operations complete, pending items are automatically dequeued and processed.
303
331
304
332
Additionally, sideload uploads to the same WordPress post are serialized to prevent race conditions in attachment metadata updates. If one sideload is in progress for a post, other sideloads targeting the same post are paused until the first completes.
333
+
334
+
## Post-save locking
335
+
336
+
While uploads are in flight, the editor takes a post-saving lock so the user can't publish or save a draft that references attachments which haven't finished sideloading. Both the legacy upload path and the client-side path share this behavior:
337
+
338
+
-`useUploadSaveLock` watches the `core/upload-media` store and dispatches `lockPostSaving`/`unlockPostSaving` against the editor store as items enter and leave the queue.
339
+
- The legacy (`uploadMedia`-only) path also locks via `UploadSaveLockWrapper`, which counts active uploads instead of tracking a queue.
340
+
341
+
The Save Draft button, Publish button, and `Ctrl/Cmd+S` shortcut all check `isPostSavingLocked()` ([#76973](https://github.com/WordPress/gutenberg/pull/76973) closed an earlier gap where Save Draft did not respect the lock). The lock releases when all items are uploaded or any in-flight item is cancelled.
0 commit comments