Skip to content

Client-side media: Add video transcoding to web-safe formats - #79375

Open
adamsilverstein wants to merge 28 commits into
trunkfrom
add/video-transcoding-mediabunny
Open

Client-side media: Add video transcoding to web-safe formats#79375
adamsilverstein wants to merge 28 commits into
trunkfrom
add/video-transcoding-mediabunny

Conversation

@adamsilverstein

@adamsilverstein adamsilverstein commented Jun 20, 2026

Copy link
Copy Markdown
Member

What?

Adds client-side video transcoding to web-safe formats for the client-side media feature, implementing #79363. When a video that is not already web-safe is uploaded, it is transcoded in the browser (via mediabunny / WebCodecs) to MP4/H.264 (or WebM/VP9) before it is served, mirroring what we already do for images and the GIF-to-video conversion in #78410.

Fixes #79363.

Note

This PR is stacked on #78410 (add/gif-to-video-mediabunny) and reuses its @wordpress/video-conversion package, worker boundary, and companion-file infrastructure. It targets that branch and should be rebased onto trunk once #78410 lands. Review the last 6 commits.

Why?

WordPress accepts video uploads in any format (.mov, .mkv, high-bitrate .mp4) and never transcodes them, because server-side transcoding needs FFmpeg, which most hosts lack. The result is slow page loads, high bandwidth/storage cost, and clips that won't play in some browsers. This is one of the capabilities called out for the 7.1 cycle and was only a roadmap bullet on #76756.

How?

Keep the original, serve a web-safe companion. WordPress always preserves the original upload (so it can be linked to or used to regenerate later, like the -scaled and HEIC originals). So the original video is stored as the attachment and the transcoded, web-safe version is sideloaded as a companion file (recorded in attachment metadata under optimized_video). The core/video block points its playback src at the companion; a toolbar control lets authors switch back to the original.

Pipeline. Detection and transcoding reuse the @wordpress/upload-media queue:

  • prepareItem probes the video metadata (getVideoMetadata, a cheap header read) and needsVideoTranscode decides eligibility (non-web-safe container/codec, oversized past the 1920px threshold, or over an optional bitrate budget) — already-optimized small files are skipped.
  • Eligible videos get Upload → GenerateVideoCompanion → Finalize; the companion is transcoded off the main thread via mediabunny's high-level Conversion API with hardwareAcceleration: 'prefer-hardware', then sideloaded.
  • Video encoding shares the single-concurrency WebCodecs gate with GIF conversion.
  • Graceful fallback: if the browser cannot encode, the companion is silently skipped (original stays); in replace-primary mode the original is uploaded unchanged so the user never loses their file.

Developer opt-out. The gutenberg_video_transcoding_keep_original PHP filter (default true) flips the pipeline to transcode-before-upload, storing only the optimized file. Since video files can be very large, this affords the ability to reduce storage requirements with the tradeoff that the original video is no longer available for download or regeneration.

Testing Instructions

  1. Enable client-side media processing and open the editor in a Chromium 137+ browser.
  2. Add a Video block and upload a non-web-safe video (e.g. a .mov, an .mkv, or a large/high-bitrate .mp4).
  3. Confirm the block plays a transcoded .mp4 once the upload finishes, while the Media Library still holds the original upload.
  4. Use the "Use original video" / "Use optimized video" toolbar toggle to switch the playback source.
  5. Upload an already-web-safe, small .mp4 and confirm it is left untouched.
  6. Add add_filter( 'gutenberg_video_transcoding_keep_original', '__return_false' ); and confirm only the optimized file is stored.

Automated tests

  • Unit: npm run test:unit packages/upload-media packages/video-conversion
  • PHP: vendor/bin/phpunit phpunit/media/video-transcoding-test.php
  • e2e: npm run test:e2e -- test/e2e/specs/editor/various/video-transcoding.spec.js

Part of #76756.

AI use

This PR was written by Claude after careful prompting, planning and review from me. I also plan to manually review the code and test the feature directly.

@github-actions github-actions Bot added [Package] Block library /packages/block-library [Package] Block editor /packages/block-editor labels Jun 20, 2026
@adamsilverstein adamsilverstein added [Feature] Client Side Media Media processing in the browser with WASM [Status] In Progress Tracking issues with work in progress [Type] Enhancement A suggestion for improvement. labels Jun 20, 2026
Comment thread lib/media/class-gutenberg-rest-attachments-controller.php Outdated
Comment thread lib/media/class-gutenberg-rest-attachments-controller.php Outdated
Comment thread lib/media/class-gutenberg-rest-attachments-controller.php Outdated
Comment thread lib/media/class-gutenberg-rest-attachments-controller.php Outdated
Comment thread lib/media/load.php Outdated
Comment thread packages/block-editor/src/components/provider/use-media-upload-settings.js Outdated
Comment thread packages/block-library/src/video/edit.js Outdated
@adamsilverstein adamsilverstein self-assigned this Jun 20, 2026
@adamsilverstein
adamsilverstein marked this pull request as ready for review June 20, 2026 19:57
@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: adamsilverstein <adamsilverstein@git.wordpress.org>
Co-authored-by: andrewserong <andrewserong@git.wordpress.org>
Co-authored-by: aduth <aduth@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown

Size Change: +130 kB (+1.65%)

Total Size: 8.05 MB

📦 View Changed
Filename Size Change
build/modules/video-conversion/worker.min.js 213 kB +129 kB (+154.16%) 🆘
build/scripts/block-editor/index.min.js 477 kB +19 B (0%)
build/scripts/block-library/index.min.js 371 kB +409 B (+0.11%)
build/scripts/core-data/index.min.js 37.4 kB +13 B (+0.03%)
build/scripts/editor/index.min.js 580 kB +43 B (+0.01%)
build/scripts/upload-media/index.min.js 16.9 kB +627 B (+3.85%)

compressed-size-action

@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown

Flaky tests detected in 34667e1.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/33114972868
📝 Reported tests:

three users concurrently edit a large post with diverse blocks in /test/e2e/specs/editor/collaboration/collaboration-stress.spec.ts, passed after 1 failed attempt.
TimeoutError: locator.waitFor: Timeout 10000ms exceeded.
Call log:
  - waiting for getByRole('button', { name: 'Dismiss this notice' }).filter({ hasText: 'Draft saved' }) to be visible

    at Editor.saveDraft (/home/runner/work/gutenberg/gutenberg/packages/e2e-test-utils-playwright/src/editor/save-draft.ts:16:4)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/collaboration/collaboration-stress.spec.ts:407:3

@adamsilverstein adamsilverstein linked an issue Jun 24, 2026 that may be closed by this pull request
9 tasks
Base automatically changed from add/gif-to-video-mediabunny to trunk June 29, 2026 18:44
Detect uploaded videos that are not web-safe (non-MP4/WebM container,
non-web-safe codec, oversized, or over a bitrate budget) and transcode
them to MP4/H.264 (or WebM/VP9) in the browser before they are served,
mirroring the GIF-to-video flow.

The original upload is preserved as the attachment (consistent with how
WordPress keeps the `-scaled` and HEIC originals); the transcoded,
web-safe version is sideloaded as a companion file and is what plays.
A `videoKeepOriginal` setting flips the pipeline to replace-primary so
only the optimized file is stored.

Transcoding runs off the main thread in the existing
@wordpress/video-conversion worker via mediabunny's Conversion API, with
hardware acceleration where available and graceful fallback to the
original when the browser cannot encode. Video encoding shares the
single-concurrency WebCodecs gate with GIF conversion.

Part of #76756. Implements #79363.
…g-mediabunny

# Conflicts:
#	lib/media/class-gutenberg-rest-attachments-controller.php
…g-mediabunny

# Conflicts:
#	lib/media/class-gutenberg-rest-attachments-controller.php
#	packages/block-editor/src/components/provider/use-media-upload-settings.js
#	packages/block-library/src/video/edit.js
#	packages/upload-media/src/store/private-actions.ts
#	packages/video-conversion/README.md
#	packages/video-conversion/src/index.ts
#	packages/video-conversion/src/video-conversion-worker.ts
#	packages/video-conversion/src/worker.ts
Merging trunk brought in stricter `@wordpress/dependency-group` and
`import/order` rules these branch-owned files predate. The
`waitForFunction` call also passed its options as the second argument,
where Playwright expects the polling arg, so the 120s timeout was
silently falling back to `actionTimeout`.
WebCodecs treats 'prefer-hardware' as a requirement rather than a hint, so
the browser rejects the encoder configuration outright when no hardware
encoder is available for the codec and output size. Headless browsers, VMs
and CI runners have none, which made every transcode fail there.

Probe encoder support at the real output dimensions and bitrate before
encoding, and fall back to 'no-preference' so the browser can reach its
software encoder. Only give up, gracefully, when no hint works.

Also read the source dimensions unconditionally: they are needed for the
probe, since support is parameter-specific.
Production code changed in block-editor, block-library, upload-media and
video-conversion needs an entry in each package's changelog.
The sideload endpoint only accepted image and PDF attachments, because a
sideloaded file is a sub-size of the attachment it extends. The web-safe
transcode of an uploaded video is a companion of the video attachment
itself, so every transcode was rejected with "Only images and PDFs can be
sideloaded" and the original video was silently kept instead.

Accept a video parent, scoped to the 'optimized-video' size so a video is
still rejected for the sub-sizes it cannot have.
@aduth

aduth commented Aug 26, 2026

Copy link
Copy Markdown
Member

Hey 👋 I wanted to give you a heads-up since this pull request adds new files containing JSX.

#80123 enables an ESLint rule that requires that files containing JSX must use the .tsx file extension. This is in line with coding guidelines expecting new code to be written as TypeScript.

What you'll need to do: You'll need to rename any new files containing JSX to use the .tsx file extension. This may also require you to address type errors that were not previously caught due to the use of the .js file extension.

This comment is automated, based on pull requests with recent activity that contain affected .js or .jsx files. But if you have any questions or if I can help with the migration in any way, please let me know and I'll do my best to help!

Trunk now rejects JSX in `.js` files, and new source files in the
repository are TypeScript. Move the control to `.tsx` and type its
props and the attachment record it reads.
A failed 'optimized-video' sideload was not in the optional-companion
allowlist, so cancelItem took the total-failure branch and deleted the
already-uploaded original video from the server. Share the companion
size list between the GIF and video flows.

Also re-kick items waiting on the shared video-encoding gate when a
TranscodeVideo operation is cancelled, not only a TranscodeGif; without
it a failed video transcode left every queued video stuck.
Name the audio codec browsers play for each container (AAC for MP4,
Opus for WebM) and treat an audio track mediabunny discards as an
unsupported conversion, so the original is kept instead of a mute
companion playing by default.

Drop computeDuration() from getVideoMetadata: no caller reads it, and on
a container without an index it walks every cluster to EOF before the
transcode decision is even made.
get_sideloaded_file_names() did not include the optimized_video
metadata key, so the companion's sideload provenance row was never
deleted. Also move the block-library changelog entry back under
Unreleased after the 10.5.0 release bump.
The Media Library selection object has no media_details, so a video
chosen from the library played the non-web-safe original while the
toolbar offered the optimized version. Look the companion up on the
attachment record when the selection does not carry it, as the
animated-GIF transform already does.
Replace the window.__videoTranscodingKeepOriginal inline script, which
the WordPress-agnostic block-editor package read directly, with a
video_keep_original field on the REST API root index that flows through
the editor settings like the image processing settings do.
@github-actions github-actions Bot added the [Package] Editor /packages/editor label Aug 29, 2026
@adamsilverstein

adamsilverstein commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review and testing @andrewserong -

I went through your points with Claude and it put together this summary:

The 400 on /sideload was a server-side gap: the sideload endpoint only accepted image and PDF parents ("Only images and PDFs can be sideloaded"), so every transcoded companion was rejected and the original was silently kept. Fixed in 2335246 by accepting a video parent, scoped to the optimized-video size only. The e2e spec now covers the full upload, transcode, sideload and finalize chain, so this path is exercised in CI. Re-testing with that same .mov would be very welcome.

On the larger questions:

Long videos: the original always uploads first and stays the attachment, so nothing is lost or blocked on the transcode. The transcode runs afterward in a worker, one at a time. There is no duration or size cap on it yet, though, so a 30 minute recording would keep the worker busy for a while. A budget like the one the GIF conversion already has (skip the transcode past N decoded pixels, keep the original) seems like the right guardrail and is a small follow up.

Single rendition vs adaptive streaming: the goal here is narrower than a streaming pipeline - it is "the video plays for every visitor". An H.264 .mov already plays in current browsers, so the real gap is the codec rather than the container: an iPhone recording in its default HEVC setting plays for the author on a Mac but not on a Linux desktop or many Android devices, which have no HEVC decoder, and the author gets no signal why. One H.264/AAC rendition closes that gap; multiple renditions and HLS are a much bigger surface and feel like plugin or host territory, the same split as image CDNs.

UI: the current design is no prompt, matching how image scaling and HEIC conversion already behave on upload. The author can switch back to the original from the block toolbar. A modal for every video upload seems heavy, but a size or duration budget with a notice when it kicks in might be a reasonable middle ground.

Core vs plugin: the argument for core is that core accepted an upload it cannot promise will play for every visitor. The proposal is the smallest fix for that, with the transcoding tunables left to filters so plugins can override or opt out.

On timing: agreed this should not be rushed. It missed 7.1 and there is no push to force it into a release before it has had wider testing.

Every other special sideload size uses underscores (animated_video,
source_original), and the Core backport follows that convention, so the
token the editor sends must match.
The setting is read from the REST index into the block editor settings,
but the base entity only requests an explicit _fields list, so it was
never returned. Add it to the entity fields and the matching preload path.
@github-actions github-actions Bot added the [Package] Core data /packages/core-data label Aug 29, 2026
adamsilverstein added a commit to adamsilverstein/wordpress-develop that referenced this pull request Aug 31, 2026
…ideo upload.

Add server-side support for the client-side video transcoding flow. When client-side media processing is enabled, the editor transcodes an uploaded video that is not already web-safe to MP4/WebM in the browser and sideloads the result as a companion of the original video attachment, which remains the attachment itself.

- Accept a video attachment as the sideload parent for the new `optimized_video` companion size, and only for that size; every other size still requires an image or PDF parent.
- Record the companion under the `optimized_video` attachment metadata key on finalize, and treat it as a name the request is allowed to store.
- Delete the companion file alongside the attachment in `wp_delete_attachment_files()`.
- Expose the new `wp_video_transcoding_keep_original` filter (default `true`) on the REST API index as `video_keep_original`, so the editor knows whether to keep the original upload or transcode before uploading, and preload it with the other media processing settings.

See WordPress/gutenberg#79375.
@adamsilverstein

Copy link
Copy Markdown
Member Author

Core backport: WordPress/wordpress-develop#13323 (Trac https://core.trac.wordpress.org/ticket/65998)

Claude prepared the backport and pushed two small fixes here along the way:

Two things came up while mapping the PHP onto Core. The sideload size token was optimized-video with a hyphen, while every other special size (animated_video, source_original) uses underscores; it is now optimized_video on both sides. And video_keep_original was read from the REST index in useBlockEditorSettings but never requested, since the base site entity asks for an explicit _fields list; it is now in that list and in the preload path.

@andrewserong

Copy link
Copy Markdown
Contributor

Thanks for summarizing, Adam! It's been a while since I've looked at this, so just sharing some thoughts after having not looked at it for a while. Please know that I think as a long-term goal being able to support video conversion is a great idea, so my thoughts here are in the spirit of supporting the effort!

Now, onto the approach. I think there are a lot of assumptions here, and this problem space is more complex than a single PR can capture succinctly.

Transcoding can take a lot of time, and depending on the computer, is computationally expensive. Will it slow down the editor for some users while it's happening? What if a user saves their post and closes the window, or closes their laptop lid. Where will they be in the transcoding process? What if they're in the site editor and switch to a different view from the post they were working on?

What is the baseline configuration that we should use for a single video? How can a user preview what it looks like and make choices about how they want it to look? What if the video is too compressed, or not compressed enough? The differences for an individual image can be marginal, but on a bigger video can be significant. And what about audio codecs? What if the video looks fine, but an indie artist's music video doesn't sound right?

If we're using the current image pipeline as an example, users can currently choose from a range of sub-sizes or select "full" to show the original, after the conversion has happened. This is easy to do because generating sub-sizes for images is quick, so the UI to make that selection can kind of happen after the conversion has happened. Whereas transcoding video is typically slow.

All of this is why I'm skeptical of transcoding behind the scenes being the right path for this feature. Especially if one of the goals is to ultimately not upload the original file for the case of very large media. It isn't clear to me in the linked issue (#79363) if the goal is to solve the storage problem or not, as it's used as one of the reasons we should do this feature, but it's also mentioned that we're preserving the file that the user uploaded. There are good arguments either way, but in its current form, I wasn't sure where we're trying to land.

So, my recommendation here would be to consider (and maybe this is more a discussion for the linked issue): what's the best UX we'd like to provide a user for this feature? Unlike client-side image processing which can fall back to a server-side approach, this is a new feature with no fallback. Once it's announced "WordPress can automatically convert your videos", we're committed to supporting it, so there's a bit less wriggle room IMO than for the still image processing.


Alrighty, those are my first impressions. I think overall it's a good feature idea, but also a project in and of itself to make it feel seamless and polished. Would it be worth introducing it behind an experiment to begin with so that many of these questions can be explored after the initial foundations have landed?

I like asking all these questions, but I'm sure there's much of this PR that is infrastructural that doesn't needed to be blocked by thinking through all the particulars. (I.e. I'm trying to figure out how I can best be supportive here without needlessly blocking good work!)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] Client Side Media Media processing in the browser with WASM [Package] Block editor /packages/block-editor [Package] Block library /packages/block-library [Package] Core data /packages/core-data [Package] Editor /packages/editor [Status] In Progress Tracking issues with work in progress [Type] Enhancement A suggestion for improvement.

Projects

Status: 🔎 Needs Review

Development

Successfully merging this pull request may close these issues.

Video transcoding to web-safe formats

4 participants