Skip to content

Commit 99ab991

Browse 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.
1 parent b6054e2 commit 99ab991

2 files changed

Lines changed: 77 additions & 40 deletions

File tree

‎docs/explanations/architecture/client-side-media.md‎

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Introduction
44

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.
66

77
Key benefits include:
88

@@ -103,13 +103,15 @@ The `uploadItem()` operation uploads the (optionally transcoded) image to the se
103103

104104
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.
105105

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+
106108
For images that exceed the `big_image_size_threshold` (default: 2560px), a scaled version is also generated and sideloaded.
107109

108110
If the original image requires EXIF rotation (orientation ≠ 1), a rotated version is generated and sideloaded as well.
109111

110112
### 5. Sideload sub-sizes
111113

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`.
113115

114116
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.
115117

@@ -142,16 +144,18 @@ The quality value is passed through to the vips worker during resize and crop op
142144

143145
## WASM module loading
144146

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:
146148

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.
148150

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.
150152

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.
152154

153155
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.
154156

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+
155159
## Cross-origin isolation
156160

157161
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.
@@ -179,10 +183,12 @@ add_filter( 'gutenberg_use_document_isolation_policy', '__return_true' );
179183

180184
Cross-origin isolation requires that cross-origin resources include proper CORS attributes. WordPress handles this at two levels:
181185

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.
183187

184188
**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.
185189

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+
186192
### Browser support
187193

188194
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
198204

199205
## Feature detection
200206

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 |
202218

203-
| Check | Reason |
204-
| --- | --- |
205-
| WebAssembly available | Required for wasm-vips |
206-
| SharedArrayBuffer available | Required for WASM threading (implies Document-Isolation-Policy is active) |
207-
| CSP allows `blob:` workers | Required for inline worker creation |
219+
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.
208220

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.
210224

211225
## Supported formats
212226

227+
The following formats are processed in the WASM/vips pipeline (`CLIENT_SIDE_SUPPORTED_MIME_TYPES`):
228+
213229
| Format | Read | Write | Transparency | Animation | Progressive/Interlace |
214230
| --- | --- | --- | --- | --- | --- |
215231
| JPEG | Yes | Yes | No | No | Yes |
@@ -219,10 +235,22 @@ The PHP-side feature flag (`wp_client_side_media_processing_enabled` filter) is
219235
| GIF | Yes | Yes | No | Yes | Yes |
220236

221237
Notes:
222-
- HEIC/HEIF is not supported due to trademark/licensing concerns.
223238
- AVIF encoding uses `effort: 2` to balance encoding speed with quality.
224239
- Animated GIF and WebP images preserve all frames during processing.
225240
- 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.
226254

227255
## Fallback behavior
228256

@@ -243,9 +271,10 @@ Client-side media processing extends the WordPress REST API in several ways:
243271

244272
| Parameter | Endpoint | Type | Default | Description |
245273
| --- | --- | --- | --- | --- |
246-
| `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. |
249278

250279
When `generate_sub_sizes` is `false`, the following server-side filters are also temporarily disabled:
251280
- `intermediate_image_sizes_advanced` — Prevents sub-size generation.
@@ -282,13 +311,12 @@ The endpoint requires `edit_post` and `upload_files` capabilities. It reads the
282311

283312
### REST index media settings
284313

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.
286318

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).
290-
- `jpeg_interlaced`, `png_interlaced`, `gif_interlaced` — Progressive/interlace settings (respects `image_save_progressive` filter).
291-
- `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.
292320

293321
## Concurrency
294322

@@ -302,3 +330,12 @@ The upload store enforces two separate concurrency limits to balance performance
302330
When a concurrency limit is reached, new items wait in the queue. As operations complete, pending items are automatically dequeued and processed.
303331

304332
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

Comments
 (0)