PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.4
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.4
4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 All 202 releases
betterdocs / includes / Modules / GifVideo.php

GifVideo.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.4, at includes/Modules/GifVideo.php

330 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace WPDeveloper\BetterDocs\Modules;
3
4 /**
5 * Serve animated GIFs in docs as muted, looping video.
6 *
7 * Docs lean on GIF screencasts, and GIF is a heavy format: on
8 * essential-blocks.com, 340 GIFs across 51 docs weighed 756 MB, while the same
9 * clips as H.264 MP4 weigh 97 MB. A GIF attachment can be linked to a converted
10 * MP4 (and a poster frame) through two attachment meta fields; when it is, the
11 * GIF's Image block renders as a <video> at the GIF's own width and height.
12 *
13 * Content is not modified: editors keep the Image block and the GIF, and
14 * removing the meta restores the GIF. Videos load nothing until they are near
15 * the screen, then play muted in a loop like the GIF did.
16 */
17 final class GifVideo {
18 const VIDEO_META = '_betterdocs_gif_video';
19 const POSTER_META = '_betterdocs_gif_poster';
20
21 private static $printed_script = false;
22
23 public static function init() {
24 $self = new self();
25 add_action( 'init', [ $self, 'register_meta' ] );
26 add_filter( 'render_block_core/image', [ $self, 'render_image' ], 10, 2 );
27 add_filter( 'attachment_fields_to_edit', [ $self, 'attachment_fields' ], 10, 2 );
28 add_filter( 'attachment_fields_to_save', [ $self, 'save_attachment_fields' ], 10, 2 );
29 add_action( 'wp_enqueue_media', [ $self, 'enqueue_picker' ] );
30 }
31
32 /**
33 * "Video version" and "Poster image" fields on a GIF's attachment details
34 * (media modal and attachment edit screen), so editors can link them
35 * without the REST API.
36 *
37 * @param array $fields Attachment form fields.
38 * @param \WP_Post $post Attachment.
39 * @return array
40 */
41 public function attachment_fields( $fields, $post ) {
42 if ( 'image/gif' !== get_post_mime_type( $post ) || ! current_user_can( 'upload_files' ) ) {
43 return $fields;
44 }
45
46 $fields['betterdocs_gif_video'] = [
47 'label' => __( 'Video version', 'betterdocs' ),
48 'input' => 'html',
49 'html' => $this->picker_html( $post->ID, self::VIDEO_META, 'video' ),
50 'helps' => __( 'An MP4 of this GIF. Docs play it in place of the GIF: same look, much smaller download.', 'betterdocs' ),
51 ];
52 $fields['betterdocs_gif_poster'] = [
53 'label' => __( 'Poster image', 'betterdocs' ),
54 'input' => 'html',
55 'html' => $this->picker_html( $post->ID, self::POSTER_META, 'image' ),
56 'helps' => __( 'Optional. Shown until the video starts.', 'betterdocs' ),
57 ];
58
59 return $fields;
60 }
61
62 /**
63 * @param int $id GIF attachment ID.
64 * @param string $key Meta key.
65 * @param string $type Library type to pick from: "video" or "image".
66 * @return string
67 */
68 private function picker_html( $id, $key, $type ) {
69 $field = 'betterdocs_gif_' . ( self::VIDEO_META === $key ? 'video' : 'poster' );
70 $linked = (int) get_post_meta( $id, $key, true );
71 $file = $linked ? get_attached_file( $linked ) : '';
72 $name = $file ? wp_basename( $file ) : '';
73
74 return sprintf(
75 '<span class="betterdocs-gif-field"><input type="hidden" id="attachments-%1$d-%2$s" name="attachments[%1$d][%2$s]" value="%3$d" />'
76 . '<span class="betterdocs-gif-name" style="display:block;word-break:break-all;margin-bottom:4px">%4$s</span>'
77 . '<button type="button" class="button button-small betterdocs-gif-pick" data-type="%5$s" data-title="%6$s" data-button="%7$s">%8$s</button> '
78 . '<button type="button" class="button-link button-link-delete betterdocs-gif-clear"%9$s>%10$s</button></span>',
79 (int) $id,
80 esc_attr( $field ),
81 $name ? $linked : 0,
82 esc_html( $name ),
83 esc_attr( $type ),
84 esc_attr( 'video' === $type ? __( 'Choose the video version', 'betterdocs' ) : __( 'Choose a poster image', 'betterdocs' ) ),
85 esc_attr__( 'Use this file', 'betterdocs' ),
86 esc_html__( 'Choose', 'betterdocs' ),
87 $name ? '' : ' hidden',
88 esc_html__( 'Remove', 'betterdocs' )
89 );
90 }
91
92 /**
93 * Store the picked files; ignore anything that isn't a video (clip) or
94 * an image (poster).
95 *
96 * @param array $post Attachment post data.
97 * @param array $attachment Submitted attachment fields.
98 * @return array
99 */
100 public function save_attachment_fields( $post, $attachment ) {
101 if ( empty( $post['ID'] ) || 'image/gif' !== get_post_mime_type( $post['ID'] ) || ! current_user_can( 'upload_files' ) ) {
102 return $post;
103 }
104
105 foreach ( [ 'betterdocs_gif_video' => [ self::VIDEO_META, 'video/' ], 'betterdocs_gif_poster' => [ self::POSTER_META, 'image/' ] ] as $field => $target ) {
106 if ( ! isset( $attachment[ $field ] ) ) {
107 continue;
108 }
109 $value = absint( $attachment[ $field ] );
110 if ( ! $value ) {
111 delete_post_meta( $post['ID'], $target[0] );
112 } elseif ( 0 === strpos( (string) get_post_mime_type( $value ), $target[1] ) ) {
113 update_post_meta( $post['ID'], $target[0], $value );
114 }
115 }
116
117 return $post;
118 }
119
120 /**
121 * Open the media library to pick the video or poster. Loaded wherever
122 * the media modal is. The field is looked up again on select: closing
123 * the image picker re-renders the GIF's details (the GIF is in that
124 * library), which replaces the field's markup.
125 */
126 public function enqueue_picker() {
127 if ( ! current_user_can( 'upload_files' ) ) {
128 return;
129 }
130
131 $js = <<<'JS'
132 jQuery(function($){var frames={};
133 function set(name,id,label){var w=$('input[name="'+name+'"]').last().closest('.betterdocs-gif-field');w.find('.betterdocs-gif-name').text(label);w.find('.betterdocs-gif-clear').prop('hidden',!id);w.find('input').val(id).trigger('change');}
134 $(document).on('click','.betterdocs-gif-pick',function(e){e.preventDefault();var b=$(this),t=b.data('type'),name=b.closest('.betterdocs-gif-field').find('input').attr('name');
135 var f=frames[t]||(frames[t]=wp.media({title:b.data('title'),library:{type:t},multiple:false,button:{text:b.data('button')}}));
136 f.off('select').on('select',function(){var a=f.state().get('selection').first();if(a){set(name,a.id,a.get('filename'));}});
137 f.open();});
138 $(document).on('click','.betterdocs-gif-clear',function(e){e.preventDefault();set($(this).closest('.betterdocs-gif-field').find('input').attr('name'),0,'');});
139 });
140 JS;
141 wp_add_inline_script( 'media-views', $js );
142 }
143
144 /**
145 * Attachment IDs of the MP4 and the poster, editable over REST by users
146 * who can upload media.
147 */
148 public function register_meta() {
149 foreach ( [ self::VIDEO_META => 'video/', self::POSTER_META => 'image/' ] as $key => $type ) {
150 register_post_meta(
151 'attachment',
152 $key,
153 [
154 'type' => 'integer',
155 'single' => true,
156 'show_in_rest' => true,
157 'default' => 0,
158 'sanitize_callback' => function ( $value ) use ( $type ) {
159 $value = absint( $value );
160 return self::is_attachment_type( $value, $type ) ? $value : 0;
161 },
162 'auth_callback' => function () {
163 return current_user_can( 'upload_files' );
164 },
165 ]
166 );
167 }
168 }
169
170 /**
171 * Whether an attachment exists and its mime type starts with $type
172 * ("video/" for the clip, "image/" for the poster).
173 *
174 * @param int $id Attachment ID.
175 * @param string $type Mime type prefix.
176 * @return bool
177 */
178 private static function is_attachment_type( $id, $type ) {
179 return $id && 0 === strpos( (string) get_post_mime_type( $id ), $type );
180 }
181
182 /**
183 * @param string $content Rendered Image block.
184 * @param array $block Parsed block.
185 * @return string
186 */
187 public function render_image( $content, $block ) {
188 if ( is_admin() || wp_is_json_request() || 'docs' !== get_post_type() ) {
189 return $content;
190 }
191
192 // Core adds "Expand on click" after this filter, and only to an <img>.
193 // Its render callback has already hooked the lightbox for this block
194 // when it applies, so keep the image there.
195 if ( false !== has_filter( 'render_block_core/image', 'block_core_image_render_lightbox' ) ) {
196 return $content;
197 }
198
199 $id = isset( $block['attrs']['id'] ) ? (int) $block['attrs']['id'] : 0;
200 if ( ! $id && preg_match( '/\bwp-image-(\d+)\b/', $content, $m ) ) {
201 $id = (int) $m[1];
202 }
203 if ( ! $id || 'image/gif' !== get_post_mime_type( $id ) ) {
204 return $content;
205 }
206
207 // Only the doc page itself gets the video. The REST API, feeds and
208 // the Markdown view render content outside the page template (before
209 // or during template_redirect) and keep the GIF for their readers.
210 if ( ! did_action( 'template_redirect' ) || doing_action( 'template_redirect' ) || is_feed() ) {
211 return $content;
212 }
213
214 $video_id = (int) get_post_meta( $id, self::VIDEO_META, true );
215 $src = self::is_attachment_type( $video_id, 'video/' ) ? wp_get_attachment_url( $video_id ) : '';
216 if ( ! $src || ! preg_match( '/<img\b[^>]*>/i', $content, $img ) ) {
217 return $content;
218 }
219
220 $tag = new \WP_HTML_Tag_Processor( $img[0] );
221 $tag->next_tag( 'img' );
222
223 // Size the video like the image the block shows (full, Medium, …),
224 // and keep the block's own inline styles (width, aspect ratio, crop,
225 // border), so the video takes the same space as the GIF did.
226 $width = $tag->get_attribute( 'width' );
227 $height = $tag->get_attribute( 'height' );
228 if ( ! $width || ! $height ) {
229 $meta = wp_get_attachment_metadata( $id );
230 $dimensions = is_array( $meta ) ? wp_image_src_get_dimensions( (string) $tag->get_attribute( 'src' ), $meta, $id ) : false;
231 if ( $dimensions ) {
232 list( $width, $height ) = $dimensions;
233 } elseif ( ! empty( $meta['width'] ) && ! empty( $meta['height'] ) ) {
234 $width = (int) $meta['width'];
235 $height = (int) $meta['height'];
236 }
237 }
238 // An image shows at its width attribute, capped by its container; pin
239 // that width inline, since themes often stretch `figure > video`.
240 $style = $width ? sprintf( 'width: %dpx; ', (int) $width ) : '';
241 $style = trim( $style . 'max-width: 100%; height: auto; ' . (string) $tag->get_attribute( 'style' ) );
242
243 $attrs = [
244 'class' => $tag->get_attribute( 'class' ),
245 'src' => $src,
246 'width' => $width,
247 'height' => $height,
248 'aria-label' => $tag->get_attribute( 'alt' ),
249 'style' => $style,
250 'preload' => 'none',
251 'data-betterdocs-gif' => '1',
252 // The clip plays itself while on screen (print_script()). Keep
253 // lazy-load/facade optimizers off it: xSpeed's click-to-play
254 // facade collapsed it to a 0x0 button in centred image figures.
255 'data-skip-lazy' => '1',
256 'data-no-lazy' => '1',
257 ];
258 $poster_id = (int) get_post_meta( $id, self::POSTER_META, true );
259 $poster = self::is_attachment_type( $poster_id, 'image/' ) ? wp_get_attachment_url( $poster_id ) : '';
260 if ( $poster ) {
261 $attrs['poster'] = $poster;
262 }
263
264 $html = '<video muted loop playsinline';
265 foreach ( $attrs as $name => $value ) {
266 if ( null === $value || '' === $value || false === $value ) {
267 continue;
268 }
269 $html .= sprintf( ' %s="%s"', $name, 'src' === $name || 'poster' === $name ? esc_url( $value ) : esc_attr( $value ) );
270 }
271 $html .= '></video>';
272
273 $this->enqueue_style();
274 $this->print_script();
275
276 return str_replace( $img[0], $html, $content );
277 }
278
279 /**
280 * Core's Image and Gallery styles target `img`; apply the same rules
281 * (rounded style, wide/full alignment, cropped gallery) to the video.
282 */
283 private function enqueue_style() {
284 if ( wp_style_is( 'betterdocs-gif-video', 'enqueued' ) ) {
285 return;
286 }
287
288 $v = 'video[data-betterdocs-gif]';
289 $css = ".wp-block-image {$v}{vertical-align:bottom;box-sizing:border-box}"
290 . ".wp-block-image[style*=border-radius] {$v}{border-radius:inherit}"
291 . ".wp-block-image.alignfull {$v},.wp-block-image.alignwide {$v}{width:100% !important}"
292 . ".wp-block-image.is-style-circle-mask {$v}{border-radius:9999px}"
293 . ":root :where(.wp-block-image.is-style-rounded {$v},.wp-block-image .is-style-rounded {$v}){border-radius:9999px}"
294 . ".wp-block-gallery.has-nested-images figure.wp-block-image {$v}{display:block;max-width:100% !important}"
295 . ".wp-block-gallery.has-nested-images.is-cropped figure.wp-block-image:not(#individual-image) {$v}{width:100% !important;flex:1 0 0%;height:100% !important;object-fit:cover}";
296
297 wp_register_style( 'betterdocs-gif-video', false, [], BETTERDOCS_VERSION );
298 wp_add_inline_style( 'betterdocs-gif-video', $css );
299 wp_enqueue_style( 'betterdocs-gif-video' );
300 }
301
302 /**
303 * Play each video while it is on screen, like the GIF animated; pause it
304 * off screen. With reduced motion requested, or when the browser blocks
305 * autoplay (e.g. iOS Low Power Mode), show controls instead.
306 */
307 private function print_script() {
308 if ( self::$printed_script ) {
309 return;
310 }
311 self::$printed_script = true;
312
313 add_action(
314 'wp_footer',
315 function () {
316 $js = <<<'JS'
317 (function(){var v=document.querySelectorAll('video[data-betterdocs-gif]');if(!v.length)return;
318 if(window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches){v.forEach(function(e){e.controls=true;e.preload='metadata';});return;}
319 function play(e){var p=e.play();if(p&&p.catch)p.catch(function(){e.controls=true;e.preload='metadata';});}
320 if(!('IntersectionObserver' in window)){v.forEach(play);return;}
321 var o=new IntersectionObserver(function(es){es.forEach(function(x){if(x.isIntersecting)play(x.target);else x.target.pause();});},{rootMargin:'200px 0px'});
322 v.forEach(function(e){o.observe(e);});})();
323 JS;
324 wp_print_inline_script_tag( $js, [ 'id' => 'betterdocs-gif-video' ] );
325 },
326 20
327 );
328 }
329 }
330