PluginProbe
AI / trunk
AI vtrunk
1.3.0 1.2.0 1.1.0 1.0.2 1.0.1 1.0.0 0.9.0 trunk 0.1.1 0.2.0 0.2.1 0.3.0 0.3.1 0.4.0 0.4.1 0.5.0 0.6.0 0.7.0 0.8.0
ai / includes / Experiments / Alt_Text_Generation / Alt_Text_Generation.php

Alt_Text_Generation.php in AI trunk, at includes/Experiments/Alt_Text_Generation/Alt_Text_Generation.php

424 lines 12.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Alt text generation experiment implementation.
4 *
5 * @package WordPress\AI
6 */
7
8 declare( strict_types=1 );
9
10 namespace WordPress\AI\Experiments\Alt_Text_Generation;
11
12 use WordPress\AI\Abilities\Image\Alt_Text_Generation as Alt_Text_Generation_Ability;
13 use WordPress\AI\Abstracts\Abstract_Feature;
14 use WordPress\AI\Asset_Loader;
15 use WordPress\AI\CLI\Alt_Text_Command;
16 use WordPress\AI\Experiments\Experiment_Category;
17
18 use function WordPress\AI\get_bulk_action_max_items;
19
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit;
22 }
23
24 /**
25 * Alt text generation experiment.
26 *
27 * Generates accessible alternative text for images using AI vision models.
28 *
29 * @since 0.3.0
30 */
31 class Alt_Text_Generation extends Abstract_Feature {
32 /**
33 * One-shot query args the bulk action redirect uses to trigger generation.
34 *
35 * @since 1.3.0
36 *
37 * @var list<string>
38 */
39 private const BULK_QUERY_ARGS = array( 'wpai_bulk_alt_text', 'wpai_attachment_ids', '_wpai_bulk_nonce' ); // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- This is used as an array const.
40
41 /**
42 * Nonce action signing the bulk action redirect.
43 *
44 * @since x.x.x
45 *
46 * @var string
47 */
48 private const BULK_NONCE_ACTION = 'wpai_bulk_alt_text';
49
50 /**
51 * {@inheritDoc}
52 */
53 public static function get_id(): string {
54 return 'alt-text-generation';
55 }
56
57 /**
58 * Tracks whether the media-focused assets have already been enqueued.
59 *
60 * @since 0.3.0
61 *
62 * @var bool
63 */
64 private bool $media_assets_enqueued = false;
65
66 /**
67 * {@inheritDoc}
68 */
69 protected function load_metadata(): array {
70 return array(
71 'label' => __( 'Alt Text Generation', 'ai' ),
72 'description' => __( 'Generates accessible alternative (alt) text for images using AI vision models, following common web accessibility guidance. Requires an AI connector that includes support for vision-based image analysis models.', 'ai' ),
73 'category' => Experiment_Category::EDITOR,
74 'capability' => 'vision',
75 );
76 }
77
78 /**
79 * {@inheritDoc}
80 */
81 public function register(): void {
82 add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
83 add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_assets' ) );
84 add_action( 'wp_enqueue_media', array( $this, 'enqueue_media_frame_assets' ) );
85 add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_media_library_assets' ) );
86 add_action( 'add_meta_boxes_attachment', array( $this, 'setup_attachment_meta_box' ) );
87 add_filter( 'attachment_fields_to_edit', array( $this, 'add_button_to_media_modal' ), 10, 2 );
88 add_filter( 'bulk_actions-upload', array( $this, 'register_bulk_action' ) );
89 add_filter( 'handle_bulk_actions-upload', array( $this, 'handle_bulk_action' ), 10, 3 );
90 add_filter( 'removable_query_args', array( $this, 'register_removable_query_args' ) );
91
92 if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
93 return;
94 }
95
96 \WP_CLI::add_command( 'ai alt-text', Alt_Text_Command::class );
97 }
98
99 /**
100 * Registers any needed abilities.
101 *
102 * @since 0.3.0
103 */
104 public function register_abilities(): void {
105 wp_register_ability(
106 'ai/' . self::get_id(),
107 array(
108 'label' => $this->get_label(),
109 'description' => $this->get_description(),
110 'ability_class' => Alt_Text_Generation_Ability::class,
111 ),
112 );
113 }
114
115 /**
116 * Enqueues block editor assets.
117 *
118 * @since 0.3.0
119 */
120 public function enqueue_editor_assets(): void {
121 Asset_Loader::enqueue_script( 'alt_text_generation', 'experiments/alt-text-generation', array( 'include_core_abilities' => true ) );
122 Asset_Loader::localize_script(
123 'alt_text_generation',
124 'AltTextGenerationData',
125 array(
126 'enabled' => $this->is_enabled(),
127 )
128 );
129
130 $this->maybe_enqueue_media_script();
131 $this->maybe_enqueue_media_editor_script();
132 }
133
134 /**
135 * Enqueues assets whenever the core media modal is registered.
136 *
137 * @since 0.3.0
138 */
139 public function enqueue_media_frame_assets(): void {
140 $this->maybe_enqueue_media_script();
141 }
142
143 /**
144 * Conditionally enqueues assets on media-related admin screens (e.g., upload.php).
145 *
146 * @since 0.3.0
147 *
148 * @param string $hook_suffix Current admin page hook suffix.
149 */
150 public function maybe_enqueue_media_library_assets( string $hook_suffix ): void {
151 if ( ! $this->is_enabled() ) {
152 return;
153 }
154
155 if ( in_array( $hook_suffix, array( 'upload.php', 'media-new.php' ), true ) ) {
156 $this->maybe_enqueue_media_script();
157 $this->maybe_enqueue_bulk_script();
158 return;
159 }
160
161 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
162
163 if ( ! $screen || 'attachment' !== $screen->post_type ) {
164 return;
165 }
166
167 $this->maybe_enqueue_media_script();
168 }
169
170 /**
171 * Shared helper to enqueue and localize the media UI script once per request.
172 *
173 * @since 0.3.0
174 */
175 private function maybe_enqueue_media_script(): void {
176 if ( $this->media_assets_enqueued || ! $this->is_enabled() ) {
177 return;
178 }
179
180 Asset_Loader::enqueue_script( 'alt_text_generation_media', 'experiments/alt-text-generation-media', array( 'include_core_abilities' => true ) );
181 Asset_Loader::localize_script(
182 'alt_text_generation_media',
183 'AltTextGenerationMediaData',
184 array(
185 'enabled' => $this->is_enabled(),
186 )
187 );
188
189 $this->media_assets_enqueued = true;
190 }
191
192 /**
193 * Conditionally enqueues assets for the experimental Gutenberg media editor.
194 *
195 * Requires the Gutenberg media editor experiment or media modal experiment to be enabled.
196 *
197 * @since 1.0.0
198 */
199 private function maybe_enqueue_media_editor_script(): void {
200 if ( ! is_plugin_active( 'gutenberg/gutenberg.php' ) ) {
201 return;
202 }
203
204 $experiments = get_option( 'gutenberg-experiments' );
205 if (
206 ! isset( $experiments['gutenberg-media-editor'] ) &&
207 ! isset( $experiments['gutenberg-media-editor-modal'] )
208 ) {
209 return;
210 }
211
212 Asset_Loader::enqueue_script(
213 'alt_text_generation_media_editor',
214 'experiments/alt-text-generation-media-editor'
215 );
216 }
217
218 /**
219 * Sets up the attachment meta box.
220 *
221 * Adds a meta box to the attachment edit screen that contains
222 * the Generate/Regenerate button.
223 *
224 * @since 0.3.0
225 *
226 * @param \WP_Post $post The attachment post.
227 */
228 public function setup_attachment_meta_box( \WP_Post $post ): void {
229 if (
230 ! $this->is_enabled() ||
231 ! wp_attachment_is_image( $post )
232 ) {
233 return;
234 }
235
236 add_meta_box(
237 'ai_alt_text_generation',
238 __( 'Alt Text', 'ai' ),
239 array( $this, 'render_attachment_meta_box' ),
240 'attachment',
241 );
242 }
243
244 /**
245 * Renders the attachment meta box content.
246 *
247 * @since 0.3.0
248 *
249 * @param \WP_Post $post The attachment post.
250 */
251 public function render_attachment_meta_box( \WP_Post $post ): void {
252 $button_text = empty( get_post_meta( $post->ID, '_wp_attachment_image_alt', true ) ) ? __( 'Generate', 'ai' ) : __( 'Regenerate', 'ai' );
253
254 printf(
255 '<div class="ai-alt-text-media-actions" style="margin-top: 16px;">' .
256 '<button id="ai-alt-text-generate-button" class="button button-secondary" type="button" data-attachment-id="%1$d">%2$s</button>' .
257 '<span class="spinner" aria-hidden="true" style="margin-inline-start: 8px; float: none;"></span>' .
258 '<p class="description" aria-live="polite" style="margin-top: 10px; line-height: 1.3;"></p>' .
259 '</div>',
260 absint( $post->ID ),
261 esc_html( $button_text )
262 );
263 }
264
265 /**
266 * Adds the "Generate Alt Text" option to the Media Library bulk actions menu.
267 *
268 * @since 0.7.0
269 *
270 * @param array<string, string> $actions The existing bulk actions.
271 * @return array<string, string> The bulk actions with the generate alt text option added.
272 */
273 public function register_bulk_action( array $actions ): array {
274 if ( ! $this->is_enabled() ) {
275 return $actions;
276 }
277
278 $actions['wpai_generate_alt_text'] = __( 'Generate Alt Text', 'ai' );
279
280 return $actions;
281 }
282
283 /**
284 * Registers the bulk alt text trigger params as removable query args.
285 *
286 * The bulk action redirect carries `wpai_bulk_alt_text` and
287 * `wpai_attachment_ids` in the URL, and the bulk script runs whenever they
288 * are present. Listing them here lets core clean them out of the address
289 * bar on the first paint, via the canonical URL it prints in `admin_head`,
290 * so reloading the results page does not re-trigger the whole generation.
291 * The sort, pagination, and view switcher links are handled by the request
292 * URI scrub in {@see Alt_Text_Generation::maybe_enqueue_bulk_script()}.
293 *
294 * @since 1.3.0
295 *
296 * @param list<string> $args Query args removed from admin URLs.
297 * @return list<string> Args including the bulk alt text trigger params.
298 */
299 public function register_removable_query_args( array $args ): array {
300 return array_merge( $args, self::BULK_QUERY_ARGS );
301 }
302
303 /**
304 * Handles the "Generate Alt Text" bulk action by redirecting with selected image IDs.
305 *
306 * @since 0.7.0
307 *
308 * @param string $redirect_url The current redirect URL.
309 * @param string $doaction The bulk action being performed.
310 * @param list<int> $post_ids The list of post IDs to process.
311 * @return string The redirect URL, possibly with bulk alt text query args appended.
312 */
313 public function handle_bulk_action( string $redirect_url, string $doaction, array $post_ids ): string {
314 if ( 'wpai_generate_alt_text' !== $doaction || ! current_user_can( 'upload_files' ) ) {
315 return $redirect_url;
316 }
317
318 $image_ids = array_values( array_filter( $post_ids, 'wp_attachment_is_image' ) );
319
320 if ( empty( $image_ids ) ) {
321 return $redirect_url;
322 }
323
324 return add_query_arg(
325 array(
326 'wpai_bulk_alt_text' => 1,
327 'wpai_attachment_ids' => implode( ',', array_map( 'absint', $image_ids ) ),
328 '_wpai_bulk_nonce' => wp_create_nonce( self::BULK_NONCE_ACTION ),
329 ),
330 $redirect_url
331 );
332 }
333
334 /**
335 * Enqueues the bulk alt text script when a bulk action redirect is detected.
336 *
337 * @since 0.7.0
338 */
339 private function maybe_enqueue_bulk_script(): void {
340 if ( ! isset( $_GET['wpai_bulk_alt_text'] ) || ! current_user_can( 'upload_files' ) ) {
341 return;
342 }
343
344 $nonce = isset( $_GET['_wpai_bulk_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpai_bulk_nonce'] ) ) : '';
345
346 if ( ! wp_verify_nonce( $nonce, self::BULK_NONCE_ACTION ) ) {
347 return;
348 }
349
350 $raw_ids = isset( $_GET['wpai_attachment_ids'] ) ? sanitize_text_field( wp_unslash( $_GET['wpai_attachment_ids'] ) ) : '';
351 $ids = array_values( array_unique( array_filter( array_map( 'absint', explode( ',', $raw_ids ) ) ) ) );
352
353 if ( empty( $ids ) ) {
354 return;
355 }
356
357 // One billed model call per image, so bound the batch.
358 $max_items = get_bulk_action_max_items( $this->get_id() );
359 $truncated_count = max( 0, count( $ids ) - $max_items );
360 $ids = array_slice( $ids, 0, $max_items );
361
362 /*
363 * The trigger params have been read; scrub them from the request URI so
364 * the sort header links the list table builds from it do not carry them.
365 * Sorting links only strip `paged`, not removable query args, so this
366 * mirrors what core does for its own one-shot params in wp-admin/upload.php.
367 * The script receives the attachment IDs through wp_localize_script()
368 * below and does not need them to stay in the URL. The value is only
369 * rewritten, not output, so no sanitization applies.
370 */
371 if ( isset( $_SERVER['REQUEST_URI'] ) ) {
372 $_SERVER['REQUEST_URI'] = remove_query_arg( self::BULK_QUERY_ARGS, (string) $_SERVER['REQUEST_URI'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
373 }
374
375 Asset_Loader::enqueue_script( 'alt_text_generation_bulk', 'experiments/alt-text-generation-bulk', array( 'include_core_abilities' => true ) );
376 Asset_Loader::localize_script(
377 'alt_text_generation_bulk',
378 'AltTextGenerationBulkData',
379 array(
380 'attachmentIds' => $ids,
381 'truncatedCount' => $truncated_count,
382 )
383 );
384 }
385
386 /**
387 * Adds a button to the media modal to generate alt text.
388 *
389 * @since 0.3.0
390 *
391 * @param array<string, mixed> $fields The attachment fields.
392 * @param \WP_Post|null $post The attachment post.
393 * @return array<string, mixed> The attachment fields with the button added.
394 */
395 public function add_button_to_media_modal( array $fields, ?\WP_Post $post ): array {
396 if (
397 ! $this->is_enabled() ||
398 null === $post ||
399 ! wp_attachment_is_image( $post )
400 ) {
401 return $fields;
402 }
403
404 $button_text = empty( get_post_meta( $post->ID, '_wp_attachment_image_alt', true ) ) ? __( 'Generate', 'ai' ) : __( 'Regenerate', 'ai' );
405
406 $fields['ai_alt_text'] = array(
407 'label' => __( 'Alt Text', 'ai' ),
408 'input' => 'html',
409 'show_in_edit' => false,
410 'html' => sprintf(
411 '<div class="ai-alt-text-media-actions">' .
412 '<button id="ai-alt-text-generate-button" class="button button-secondary" type="button" data-attachment-id="%1$d">%2$s</button>' .
413 '<span class="spinner" aria-hidden="true" style="margin-inline-start: 8px; float: none;"></span>' .
414 '<p class="description" aria-live="polite" style="margin-top: 6px; font-size: 12px;"></p>' .
415 '</div>',
416 absint( $post->ID ),
417 esc_html( $button_text )
418 ),
419 );
420
421 return $fields;
422 }
423 }
424