Skip to content

Client-side media: Add animated GIF to video conversion via mediabunny#78410

Open
adamsilverstein wants to merge 44 commits into
trunkfrom
add/gif-to-video-mediabunny
Open

Client-side media: Add animated GIF to video conversion via mediabunny#78410
adamsilverstein wants to merge 44 commits into
trunkfrom
add/gif-to-video-mediabunny

Conversation

@adamsilverstein
Copy link
Copy Markdown
Member

@adamsilverstein adamsilverstein commented May 18, 2026

What

This PR adds client-side animated GIF to video conversion to the block editor.

When an animated GIF is uploaded, the new pipeline converts it to an MP4 (or WebM) client-side using the browser's native WebCodecs ImageDecoder for frame extraction and the mediabunny library for encoding. The converted video is stored as a companion file of the original GIF attachment, and the editor switches the block to a "GIF" variation of the Video block that plays just like the original animated GIF.

This is a standalone alternative to #76946 (which used inlined FFmpeg WASM); see Why and Supersedes.

Fixes #76942

Why

Bundle size and hardware-accelerated encode performance.

Approach Worker bundle (raw) Worker bundle (gzipped) Total shipped JS
#76946 (FFmpeg WASM, inlined) ~14.1 MB ~14.1 MB ~14.1 MB
This PR (WebCodecs + mediabunny) ~380 KB ~77 KB ~775 KB total .mjs

Since WebCodecs encoding is hardware-accelerated on supported platforms, conversion is also significantly faster than software-only FFmpeg WASM.

How

Conversion pipeline (off the main thread)

  1. @wordpress/video-conversion package (new): mirrors the @wordpress/vips worker-threads pattern. A web worker is bundled with the mediabunny library and exposed via a Comlink-style proxy. The main thread calls convertGifToVideo(file, mimeType), which runs fully off the main thread.
  2. Frame pipeline (src/index.ts): ImageDecoder decodes each GIF frame, honoring real per-frame delay values. Each decoded VideoFrame is fed to mediabunny's VideoSampleSource, then encoded via VideoEncoder (AVC/H.264 for MP4, VP9 for WebM).
  3. @wordpress/upload-media integration: a new OperationType.TranscodeGif operation, an isAnimatedGif() utility (GIF89a Graphic Control Extension detection), and a transcodeGifItem() action wired into prepareItem(). Conversion is gated on WebCodecs availability at prepareItem time, runs with a concurrency limit of 1 (memory-intensive encode), and falls back gracefully: an Unsupported error from the worker leaves the original GIF in the queue.

Storage model (companion files)

An uploaded opaque animated GIF stays a single core/image attachment - it remains one item in the media library. The converted video and a static first-frame poster are sideloaded as companion files of that same attachment (like the HEIC original), recorded in media_details.animated_video and animated_video_poster. They are never separate attachments. Transparent GIFs are not converted (a <video> cannot reproduce GIF transparency), so they have no companion and upload as a normal image.

Editor block switch

The GIF→video swap happens in the editor as a block switch, not at render time.

  • A "GIF" variation of the Video block. core/video gains two variations, "Video" and "GIF", identified purely by their attribute combination (!controls && loop && autoplay && muted && playsInline) - no new block attribute. The GIF variation is scoped to block + transform (not the inserter), since it is the editor representation of a converted GIF. Its editor preview plays like a GIF (muted, looping, autoplaying).
  • Conversion is a block switch on upload. Once the companion video is available, a standalone Image block is replaced with the Video block's GIF variation playing the companion. Images nested in a Gallery are left as GIFs (a gallery only accepts image blocks); Media & Text / Cover are unaffected (their media is not a core/image block). A .gif-URL gate keeps non-GIF images from triggering an attachment fetch, and a client-id guard keeps undo from immediately re-converting.
  • Fully reversible. A "Display as GIF" toolbar control on the GIF video block switches it back to the original core/image (and opts it out of re-conversion). The Image block's "Display as original GIF" toggle (preserveAnimatedGif) controls this editor conversion, so the round-trip is reversible.
  • Native rendering, no render-time PHP. Because the converted block is a real core/video, it serializes a native <video autoplay loop muted playsinline poster> and renders on the front end with no filtering.

PHP (minimal)

  • Enqueues the @wordpress/video-conversion/loader script module in the editor (lib/client-assets.php).
  • Allows the converted video and poster as valid sideload sizes on the attachment (class-gutenberg-rest-attachments-controller.php).
  • Cleans up the sideloaded companion video and poster when their attachment is deleted, since core's wp_delete_attachment_files() does not know about them (lib/media/animated-gif-to-video.php).

Scope & browser support

Client-side media already requires Document Isolation Policy globally (for SharedArrayBuffer / crossOriginIsolated). This PR does not change that requirement, and Firefox (which lacks VideoEncoder) is out of scope for both approaches. The conversion path is gated on typeof ImageDecoder !== 'undefined' && typeof VideoEncoder !== 'undefined' and falls back gracefully to uploading the original GIF when WebCodecs is unavailable.

Screencasts

Uploaded GIF converted to GIF video block, then transformed to regular video block:

covert.to.gif.block.mp4

Transformed video, restore to show as GIF instead:

convert.to.gif.mp4

Testing

Verified manually end-to-end (see screencasts above): uploading an animated GIF converts it to the Video block's GIF variation, the front end renders a native looping <video>, and the round-trip back to the original GIF works in both directions.

Automated coverage:

  • Unit tests for the mediabunny pipeline, isAnimatedGif, store selectors, and prepareItem wiring: 21 suites / 214 tests, all passing.
  • Unit tests for the core/video GIF variation (isGifVariation detection; exactly one active variation; not in the inserter).
  • PHP unit tests for the companion path resolution (including directory-traversal safety) and companion cleanup on attachment delete.
  • E2E spec at test/e2e/specs/editor/various/gif-to-video.spec.js: uploads an animated GIF via the Image block and asserts the block is switched to the Video block's GIF variation playing the converted companion, while the attachment remains the original image/gif with the companion recorded in media_details.animated_video. The spec skips automatically if WebCodecs or cross-origin isolation is not active. PHP/E2E run in CI (wp-env was unavailable locally).

Changes from earlier revisions

Following review feedback (h/t @andrewserong and @swissspidy), the GIF→video swap moved from a render-time PHP filter to an editor block switch (the "GIF variation of the Video block" idea from the review thread). This removed most of the feature's PHP: the render_block_core/image marking filter and the wp_content_img_tag <img><video> swap are gone (~208 lines of PHP removed), leaving only companion cleanup on delete. The upload/conversion pipeline and the companion-file storage model are unchanged; only where the swap happens moved.

Supersedes

This PR consolidates the full GIF→video feature on the mediabunny/WebCodecs engine and replaces the FFmpeg-WASM stack:

References

Standalone alternative to PR #76946 (FFmpeg WASM). mediabunny + WebCodecs
avoids the ~14MB inlined WASM and the crossOriginIsolated requirement, so
conversion runs on installs that are not cross-origin isolated.
Client-side media already requires Document Isolation Policy, so avoiding
SharedArrayBuffer is not a differentiator and Firefox is out of scope. The
mediabunny win is bundle size and hardware-accelerated encode performance.
Adds worker plumbing for @wordpress/mediabunny following the @wordpress/vips
pattern: worker.ts exposes the API via expose(), mediabunny-worker.ts lazily
creates a Blob URL Worker and wraps it for main-thread RPC, and loader.ts
registers the dynamic module import for the WordPress import map.
Add TranscodeGif operation type and gifConvert/videoOutputFormat settings to
the upload-media store. Implement getActiveVideoProcessingCount and
getPendingVideoProcessing private selectors (mirroring image-processing
equivalents). Create lazy dynamic-import wrapper for @wordpress/mediabunny/worker
following the vips util pattern. Wire tsconfig project reference to mediabunny.
Add gutenberg_enqueue_mediabunny_loader() to client-assets.php (parallel
to the existing vips loader) so @wordpress/mediabunny/loader is registered
in the block editor import map. Add @wordpress/mediabunny entry to
docs/manifest.json. Map @wordpress/mediabunny/worker to a jest stub so
upload-media unit tests can run without the auto-generated worker-code.ts.
Add gif-to-video.spec.js which uploads the existing 100x80 animated GIF
fixture via the image block and asserts the resulting attachment has a
video/mp4 or video/webm MIME type after the mediabunny pipeline runs.
Mirrors the client-side-media-processing.spec.js harness conventions.

Also add packages/mediabunny/src/worker-code.ts to the ESLint global
ignores, mirroring the existing vips exclusion - it is auto-generated
by the build process and must not be linted.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 18, 2026

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: swissspidy <swissspidy@git.wordpress.org>

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

The crossOriginIsolated check in skipIfGifConversionInactive is a
conservative test-environment safety check, not a runtime capability
differentiator. Add a comment explaining this so reviewers do not
mistake it for feature-gating logic.
@adamsilverstein adamsilverstein added [Type] Enhancement A suggestion for improvement. [Feature] Client Side Media Media processing in the browser with WASM labels May 18, 2026
@adamsilverstein adamsilverstein self-assigned this May 18, 2026
@adamsilverstein adamsilverstein added the [Status] In Progress Tracking issues with work in progress label May 18, 2026
Swapping every <img> with a companion video at render time broke layouts
when the image was nested: galleries (crop-to-fit, lightbox, captions),
Media & Text and Cover. Gate the swap by block context: a render_block_core/image
filter marks only eligible images - standalone Image blocks whose author has
not opted out (preserveAnimatedGif) and that are not inside a Gallery
(galleryId context). Media & Text and Cover render their media as the block's
own <img>, not a core/image block, so they never reach the filter.
wp_content_img_tag swaps only marked images, prefers the static poster
companion, and the controller learns the 'animated-video-poster' role.
Adds PHP tests for the gating, swap and cleanup.
@adamsilverstein adamsilverstein added Needs PHP backport Needs PHP backport to Core and removed No Core Sync Required Indicates that any changes do not need to be synced to WordPress Core labels May 21, 2026
@adamsilverstein
Copy link
Copy Markdown
Member Author

Updated the labels: removed No Core Sync Required and added Needs PHP backport, since this PR ships real server-side behavior in PHP that needs to exist in Core for the feature to work:

  • lib/media/animated-gif-to-video.php – the front-end render swap (render_block_core/image marking + wp_content_img_tag <img><video> swap) and the companion-file cleanup on attachment delete.
  • lib/media/class-gutenberg-rest-attachments-controller.php – the animated-video-poster sideload/finalize branch in the REST attachments controller.
  • lib/client-assets.php / lib/media/load.php – registration and wiring for the above.

None of this can be delivered through the JS packages alone, so a Core backport is required.

Note on timing: the client-side media feature was reverted from Core (WordPress/wordpress-develop#11309), so the backport is currently blocked. I'll work on the Core backport once the feature is restored in Core.

@github-actions github-actions Bot added the [Package] Block library /packages/block-library label May 21, 2026
@andrewserong
Copy link
Copy Markdown
Contributor

lib/media/animated-gif-to-video.php – the front-end render swap (render_block_core/image marking + wp_content_img_tag

What about the idea of using a GIF variation of the video block that @swissspidy mentioned on the other PR. So, like you'd explored previously, when a user uploads a GIF image (and we confirm it isn't transparent) we perform the transformation and the user gets a "GIF" block in the editor. I quite liked that idea. It also unlocks:

Where "Restore" would mean turning the GIF video block back into an image.

So we wouldn't filter on the wp_content_img_tag but rather deal with this via block switching in the editor. That might help reduce the amount of PHP we're shipping for this feature, too?

(As an aside, I'll be AFK for a little bit shortly, so apologies if my replies take a little longer than usual!)

Thanks again for all the back and forth on this!

@adamsilverstein
Copy link
Copy Markdown
Member Author

What about the idea of using a GIF variation of the video block that @swissspidy mentioned on the other PR. So, like you'd explored previously, when a user uploads a GIF image (and we confirm it isn't transparent) we perform the transformation and the user gets a "GIF" block in the editor. I quite liked that idea. It also unlocks:

Sure, I can give that a try. I was already thinking about exploring that further. I'll just adjust the current pr, we can always revert back to the current state at fa4526d if we decide this is a better approach.

Register a 'GIF' variation of core/video (alongside an explicit 'Video'
variation) identified purely by its attribute combination: no controls,
looping, autoplaying, muted and inline. No new block attribute is added.

The GIF variation is the editor representation of an animated GIF that
client-side media has converted to a video, so it is scoped to block and
transform only (not the inserter). The editor preview plays it like a GIF,
and a toolbar control restores it to the original GIF image.
When client-side media converts an opaque animated GIF, the GIF stays the
image attachment and the converted video and poster are sideloaded as
companion files. Watch for that companion and, for a standalone Image block
the author has not opted out of, replace it with the Video block's GIF
variation playing the companion video.

This moves the GIF-to-video swap into the editor (block switching) instead
of rewriting the markup at render time. Gallery images are left as images,
the fetch is gated on a .gif URL so other images are unaffected, and a
client-id guard keeps undo from immediately re-converting. The existing
'Display as original GIF' toggle now controls this editor conversion.
The GIF-to-video swap now happens in the editor (the Video block's GIF
variation), so the converted block already serializes a <video> and renders
natively. Drop the render_block_core/image marking filter and the
wp_content_img_tag <img>-to-<video> swap; PHP no longer rewrites the markup.

What remains is removing the sideloaded companion video and poster when their
attachment is deleted, which core does not handle. Tests are narrowed to the
companion path resolution (including traversal safety) and that cleanup.
Unit-test isGifVariation and that exactly one variation is active for any
attributes (and that the GIF variation is not in the inserter). Rework the
e2e spec to assert an uploaded GIF is switched to the Video block's GIF
variation playing the converted companion, with the attachment still the
original GIF image.
@adamsilverstein
Copy link
Copy Markdown
Member Author

Thanks @andrewserong - I went ahead and implemented this. The render-time PHP swap is gone; an uploaded GIF now becomes a "GIF" variation of the Video block in the editor, and a "Display as GIF" toolbar control restores the original image. Largely follows the gif-block approach in @swissspidy's media-experiments:

  • core/video gains "Video" + "GIF" variations, identified purely by their attribute combination (!controls && loop && autoplay && muted && playsInline) - no new block attribute. The GIF variation isn't in the inserter; it's the editor representation of a converted GIF, and its preview plays like a GIF.
  • On upload, once the converted-video companion is available, a standalone Image block is swapped to the GIF video block. Gallery images stay GIFs (a gallery only accepts image blocks), and Media & Text / Cover are unaffected since their media isn't a core/image block.
  • Because the block is now a real core/video, it serializes a native <video autoplay loop muted playsinline poster> and renders with no filtering - so the render_block_core/image marker and the wp_content_img_tag swap are gone (~208 fewer lines of PHP). The only PHP left cleans up the sideloaded companion files on delete.
  • The companion-file storage is unchanged: the GIF stays a single core/image attachment with the video + poster sideloaded; only where the swap happens moved.

Restore is a toolbar control rather than a generic block transform, since recovering the original GIF URL needs the attachment record (async), which a synchronous transform can't do. No rush on a re-review - enjoy your time AFK!

@adamsilverstein
Copy link
Copy Markdown
Member Author

Screencasts

Uploaded GIF converted to GIF video block, then transformed to regular video block:

covert.to.gif.block.mp4

Transformed video, restore to show as GIF instead:

convert.to.gif.mp4

The comment still described the removed render-time swap. The feature now
swaps to a video in the editor (the converted block is a real core/video),
so the PHP only cleans up sideloaded companions on attachment delete.
The companion video and poster are no longer swapped into the markup at
render time; the editor switches the uploaded image block to the Video
block's GIF variation, which serializes a native <video>. Update the
comments in the REST sideload handler and the upload-media pipeline to
describe the editor block switch instead of the removed render-time swap.
The companion files and their delete_attachment cleanup are unchanged.
The upload/processing spinner is absolutely centered on the .wp-block-image
figure, which spans the full content width. When the preview is narrower than
that (e.g. a small animated GIF held in the uploading state for several seconds
while it transcodes to a video), the spinner drifted to the right of the image.

Shrink the figure to the preview with width:fit-content while .is-transient,
so the centered spinner lands on the image. Wide and full alignments are
excluded so they keep their own width.
Copy link
Copy Markdown
Contributor

@andrewserong andrewserong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great progress here @adamsilverstein, this is pretty close to how I was hoping it'd work, and feels like the best of both worlds to me! Also nice that MediaBunny works so well and has such a relatively small footprint 👍

A few observations while testing, some of it is nitpicky design stuff:


For this toggle, I'd go with a button instead as we're swapping out the block when a user toggles it. I'd usually expect a toggle to still be present after clicking:

Image

It also might make it consistent with the transform to variation button on the Video block:

Image

Speaking of which, I really like this! It's actually a lovely improvement outside of the GIF conversion stuff IMO as there's a way to really quickly make any video behave like a looping GIF 🎉

Also, I like the Display as GIF button in the toolbar, feels nice and easy to find if a user wants to switch to using the real GIF:

Image

I haven't had a detailed look at the code just yet, but I had a question about the useEffect in AnimatedGifConverter. Should that also use __unstableMarkNextChangeAsNotPersistent()? I wasn't sure if this should or shouldn't be covered in the Undo stack. Currently you can CMD+Z directly after upload to restore to an image block, and I assume we probably don't want to allow that? I.e. if a user goes from an empty Image block to uploading a GIF, the first undo step should probably return to an empty Image block.

Also, I imagine we probably need a useEffect but I was wondering if there's a way to more closely tie it to the upload action, just in case there's any situation where it can accidentally fire on page load? I don't think there is, but just something to keep in mind as we've run into tricky bugs with useEffects in blocks before. Not sure how feasible (or worth it) it'd be, but if there's some way to tie this to "an upload just occurred" that might help harden things. (I imagine it's not possible for this to be an explicit conversion that occurs once an upload completes) 🤔


Finally, I found that some .gif files I downloaded from GIPHY didn't automatically convert to MP4 even though (at least visually) they don't seem to have transparency. I've attached one below:

Image

Should that one be being converted, or is it already small enough that it isn't? (Also don't take the content of that GIF as a signal that I'm disappearing from this PR thread 😄)


Anyway, the UX is feeling really good to me overall so far! It might be worth getting a couple extra pairs of eyes on it when you feel it's ready for a final review to see if other folks agree, but IMO this is feeling really nice. Thanks again for all the back and forth 👍

@adamsilverstein
Copy link
Copy Markdown
Member Author

Great progress here @adamsilverstein, this is pretty close to how I was hoping it'd work, and feels like the best of both worlds to me! Also nice that MediaBunny works so well and has such a relatively small footprint 👍

A few observations while testing, some of it is nitpicky design stuff:

For this toggle, I'd go with a button instead as we're swapping out the block when a user toggles it. I'd usually expect a toggle to still be present after clicking:

Good idea!

I haven't had a detailed look at the code just yet, but I had a question about the useEffect in AnimatedGifConverter. Should that also use __unstableMarkNextChangeAsNotPersistent()? I wasn't sure if this should or shouldn't be covered in the Undo stack. Currently you can CMD+Z directly after upload to restore to an image block, and I assume we probably don't want to allow that? I.e. if a user goes from an empty Image block to uploading a GIF, the first undo step should probably return to an empty Image block.

Hmm, good point. I was wondering about the correct action to take when a user hits undo and agree it should take them back to the state before the attempted upload (typically an empty image block).

Also, I imagine we probably need a useEffect but I was wondering if there's a way to more closely tie it to the upload action, just in case there's any situation where it can accidentally fire on page load? I don't think there is, but just something to keep in mind as we've run into tricky bugs with useEffects in blocks before. Not sure how feasible (or worth it) it'd be, but if there's some way to tie this to "an upload just occurred" that might help harden things. (I imagine it's not possible for this to be an explicit conversion that occurs once an upload completes) 🤔

Interesting, I will investigate.

Finally, I found that some .gif files I downloaded from GIPHY didn't automatically convert to MP4 even though (at least visually) they don't seem to have transparency. I've attached one below:

Should that one be being converted, or is it already small enough that it isn't? (Also don't take the content of that GIF as a signal that I'm disappearing from this PR thread 😄)

Hmm, let me review that specifically to see what is happening with this one.

@swissspidy
Copy link
Copy Markdown
Member

Also, I imagine we probably need a useEffect but I was wondering if there's a way to more closely tie it to the upload action, just in case there's any situation where it can accidentally fire on page load? I don't think there is, but just something to keep in mind as we've run into tricky bugs with useEffects in blocks before. Not sure how feasible (or worth it) it'd be, but if there's some way to tie this to "an upload just occurred" that might help harden things. (I imagine it's not possible for this to be an explicit conversion that occurs once an upload completes) 🤔

Interesting, I will investigate.

It could probably be done within image block's onChange callback. If media is a video, call replaceBlocks() instead of setAttributes()

onChange: onSelectImage,

function onSelectImage( media ) {
if ( Array.isArray( media ) ) {
onSelectImagesList( media );
return;
}
if ( ! media || ! media.url ) {
setAttributes( {
url: undefined,
alt: undefined,
id: undefined,
title: undefined,
caption: undefined,
blob: undefined,
} );
setTemporaryURL();
return;
}

…pload undo

Two review-feedback fixes to the editor-side conversion:

- Gate the auto-swap on the upload-media queue: an animated GIF only
  becomes a video block when its attachment id passed through the queue
  during this browser session. Existing image blocks loaded from saved
  post content are no longer silently swapped on page load.
- Wrap the swap with __unstableMarkNextChangeAsNotPersistent so the
  block change folds into the upload's undo step. A single Cmd+Z from
  the converted video block goes back to the pre-upload state instead
  of stopping at an intermediate image-with-GIF block.
…esence

hasTransparency previously returned true for any image with an alpha
channel. That was a false positive for two common shapes:

- Animated GIFs declare a transparent color index for their disposal-
  method frame compositing, so vips reports hasAlpha() === true even
  when no pixel ever renders transparently. This caused GIPHY-style
  animated GIFs to skip the GIF-to-video conversion path.
- PNG encoders frequently retain the alpha channel of an RGBA source
  even when every pixel is opaque, so PNG -> JPEG transcoding was
  skipped unnecessarily.

Sample the alpha band's minimum on the first frame instead. For frame
0 any transparent pixel is visible (there is no previous frame to
inherit from), and min() over a single channel is cheap.
@adamsilverstein
Copy link
Copy Markdown
Member Author

adamsilverstein commented May 27, 2026

Thanks again @andrewserong - all three points from the review are now addressed:

Toggle → button

The inspector toggle is gone; the image block now has a "Display as video" toolbar button that mirrors the video block's "Display as GIF" toolbar control. Same icon language, same direction of motion - no more disappearing toggle.

Undo + useEffect trigger robustness

Both concerns covered in 485b0d7:

  • The swap is now wrapped with __unstableMarkNextChangeAsNotPersistent(), so it folds into the upload's undo step. A single Cmd+Z from the converted video goes straight back to the pre-upload (empty image) state.
  • The auto-swap is now gated on a session-only latch: we only convert attachments whose ids have passed through the @wordpress/upload-media queue this session (isUploadingById). An image block already present in saved post content - even with a companion video in its media_details - is left alone. The toolbar button is still there if a user explicitly wants to convert a previously-saved GIF.

GIPHY GIFs not converting

Confirmed via the Homer GIF you attached - it's the transparency gate, but the gate itself was the bug. Test results on your file:

Check Result
isAnimatedGif() byte scan true (28 frames)
vips.Image.hasAlpha() true (bands=4)
Actual alpha-min on frame 0 (uchar) 255 - fully opaque
ImageMagick frame inspection frames 1-27 have mean-alpha=1.0

The alpha channel in frames 1-27 isn't visible transparency - it's the GIF disposal-method sentinel for "this pixel keeps the previous frame's value". hasAlpha() just checks channel presence and can't tell the difference. This pattern is extremely common for GIPHY-style animated GIFs, so the old gate was potentially wrong for the majority of real-world animated GIFs. Nice catch!

Fixed in e487510: hasTransparency() now extractBand(bands-1).min() on frame 0 and compares against the format-appropriate opaque value (255 for uchar, 65535 for ushort). For frame 0 of an animated GIF any transparent pixel is by definition visible - there's no previous frame to inherit from - so this is both correct and cheap (one min() over a single channel). 7 new tests in packages/vips/src/test/has-transparency.ts cover the opaque-with-alpha-channel case, partial/full transparency, both bit depths, and the alpha-band selection.

This also incidentally fixes an analogous false positive in the PNG → JPEG transcoding path: PNG encoders often retain an alpha channel from an RGBA source even when every pixel is opaque, and those PNGs were skipping JPEG transcoding for no reason.

If you re-test with the homer gif, you should now see the video conversion.

Per swissspidy's suggestion, fold the GIF-to-video swap into the image
block's onSelectImage callback. When the selected media is a GIF whose
sideloaded animated_video companion is present, build the video block
and replaceBlock() instead of setAttributes().

Tying the swap to the upload's onChange (rather than watching the
attachment record from a separate component) means:

- Already-saved image blocks are left alone on page load - no watcher
  fires when the post loads, so the explicit toolbar button is the only
  path to convert an existing GIF block. This removes the session-upload
  latch the watcher needed.
- The conversion happens once, deterministically, on the upload event
  itself, sidestepping the useEffect timing concerns Andrew flagged.

Drops the now-unused pieces:
- packages/block-library/src/image/animated-gif-converter.js
- The image block's preserveAnimatedGif attribute (no opt-out flag is
  needed when there is no auto-watcher to opt out of)
- The preserveAnimatedGif: true that gif-restore-control set on restore
@adamsilverstein
Copy link
Copy Markdown
Member Author

Great call @swissspidy - done in 62248a4.

onSelectImage now does the swap directly: when the selected media is a GIF whose media_details.animated_video companion is present, the block calls replaceBlock( clientId, createBlock( 'core/video', { ... } ) ) instead of setAttributes( ... ). Tying it to the upload's onChange is much cleaner than the watcher I had:

  • No more useEffect on the attachment record, no more session-upload latch, no separate <AnimatedGifConverter> component to mount alongside every image block. The swap fires once, on the upload event itself.
  • Already-saved image blocks are inherently left alone - they never re-trigger onSelectImage, so there's no auto-conversion on page load to worry about. The toolbar "Display as video" button is still there as the explicit path for those.
  • Drops the preserveAnimatedGif attribute and its book-keeping in the restore control - with no auto-watcher, there's nothing to opt out of.

Net result is ~140 lines lighter and a much simpler mental model. Thanks for steering this in a better direction!

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 Needs PHP backport Needs PHP backport to Core [Package] Block library /packages/block-library [Status] In Progress Tracking issues with work in progress [Type] Enhancement A suggestion for improvement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add animated GIF to web video conversion

3 participants