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 / CLI / Alt_Text_Command.php

Alt_Text_Command.php in AI trunk, at includes/CLI/Alt_Text_Command.php

447 lines 12.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WP-CLI command for generating alt text for images in the media library.
4 *
5 * @package WordPress\AI
6 *
7 * @since 0.9.0
8 */
9
10 declare( strict_types=1 );
11
12 namespace WordPress\AI\CLI;
13
14 use WP_CLI;
15 use WP_CLI\Utils;
16 use function WordPress\AI\has_valid_ai_credentials;
17
18 // Exit if accessed directly.
19 defined( 'ABSPATH' ) || exit;
20
21 /**
22 * Manages AI-powered alt text generation for media library images.
23 *
24 * @since 0.9.0
25 */
26 class Alt_Text_Command {
27
28 /**
29 * Maximum number of rows shown by the dry-run table.
30 */
31 private const DRY_RUN_PREVIEW_LIMIT = 100;
32
33 /**
34 * Generates alt text for images in the media library using AI.
35 *
36 * Queries images that are missing alt text and generates it using the
37 * ai/alt-text-generation ability. Processes images in batches to manage
38 * memory and API rate limits.
39 *
40 * ## OPTIONS
41 *
42 * [--batch-size=<number>]
43 * : Number of images to process per batch.
44 * ---
45 * default: 20
46 * ---
47 *
48 * [--dry-run]
49 * : Show what would be processed without making changes.
50 *
51 * [--force]
52 * : Regenerate alt text even for images that already have it.
53 *
54 * [--ids=<ids>]
55 * : Comma-separated list of specific attachment IDs to process.
56 *
57 * [--delay=<milliseconds>]
58 * : Delay in milliseconds between each API call to avoid rate limiting.
59 * ---
60 * default: 500
61 * ---
62 *
63 * [--yes]
64 * : Skip the confirmation prompt before processing.
65 *
66 * ## EXAMPLES
67 *
68 * # Generate alt text for all images missing it
69 * $ wp ai alt-text generate
70 *
71 * # Dry run to see what would be processed
72 * $ wp ai alt-text generate --dry-run
73 *
74 * # Regenerate alt text for specific images
75 * $ wp ai alt-text generate --ids=42,55,100 --force
76 *
77 * # Process in small batches with custom delay, skipping confirmation
78 * $ wp ai alt-text generate --batch-size=5 --delay=1000 --yes
79 *
80 * @when after_wp_load
81 *
82 * @param array<int, string> $args Positional arguments.
83 * @param array<string, mixed> $assoc_args Associative arguments.
84 */
85 public function generate( $args, $assoc_args ): void {
86 $this->ensure_admin_user();
87
88 $ability = wp_get_ability( 'ai/alt-text-generation' );
89 if ( ! $ability ) {
90 WP_CLI::error( 'The ai/alt-text-generation ability is not registered. Make sure the Alt Text Generation experiment is enabled in Settings > AI.' );
91 return; // WP_CLI::error() exits, but this satisfies static analysis.
92 }
93
94 if ( ! has_valid_ai_credentials() ) {
95 WP_CLI::error( 'No valid AI credentials found. Configure a provider in Settings > Connectors.' );
96 return; // WP_CLI::error() exits, but this satisfies static analysis.
97 }
98
99 $batch_size = max( 1, (int) Utils\get_flag_value( $assoc_args, 'batch-size', 20 ) );
100 $dry_run = (bool) Utils\get_flag_value( $assoc_args, 'dry-run', false );
101 $force = (bool) Utils\get_flag_value( $assoc_args, 'force', false );
102 $delay_ms = (int) Utils\get_flag_value( $assoc_args, 'delay', 500 );
103 $ids_flag = (string) Utils\get_flag_value( $assoc_args, 'ids', '' );
104
105 $explicit_ids = '' !== $ids_flag ? $this->parse_ids_flag( $ids_flag ) : null;
106 $total = null !== $explicit_ids
107 ? count( $explicit_ids )
108 : $this->count_matching_attachments( $force );
109
110 if ( 0 === $total ) {
111 WP_CLI::success( 'No images found matching the criteria.' );
112 return;
113 }
114
115 WP_CLI::log( sprintf( 'Found %d image(s) to process.', $total ) );
116
117 if ( $dry_run ) {
118 $this->display_dry_run( $explicit_ids, $force, $total );
119 return;
120 }
121
122 WP_CLI::confirm(
123 sprintf( 'Generate alt text for %d image(s)? This may incur API costs.', $total ),
124 $assoc_args
125 );
126
127 $stats = $this->process_images( $ability, $explicit_ids, $total, $batch_size, $delay_ms, $force );
128 $this->print_summary( $stats );
129 }
130
131 /**
132 * Ensures a user with admin capabilities is set for the CLI session.
133 */
134 private function ensure_admin_user(): void {
135 if ( 0 !== get_current_user_id() ) {
136 return;
137 }
138
139 $admins = get_users(
140 array(
141 'role' => 'administrator',
142 'number' => 1,
143 'fields' => 'ID',
144 )
145 );
146
147 if ( empty( $admins ) ) {
148 WP_CLI::error( 'No administrator user found. Create one or pass --user=<id>.' );
149 }
150
151 $admin_id = (int) $admins[0];
152 wp_set_current_user( $admin_id );
153 WP_CLI::log( sprintf( 'No --user supplied; running as administrator #%d.', $admin_id ) );
154 }
155
156 /**
157 * Parses the --ids flag into a list of valid image attachment IDs.
158 *
159 * @param string $ids_flag Comma-separated IDs from the --ids flag.
160 * @return int[] Array of attachment IDs that exist and are images.
161 */
162 private function parse_ids_flag( string $ids_flag ): array {
163 $ids = array_map( 'absint', explode( ',', $ids_flag ) );
164 $ids = array_filter( $ids );
165
166 return array_values(
167 array_filter(
168 $ids,
169 static function ( int $id ): bool {
170 return get_post( $id ) && wp_attachment_is_image( $id );
171 }
172 )
173 );
174 }
175
176 /**
177 * Builds the WP_Query arguments shared by counting and batched fetching.
178 *
179 * @param bool $force Whether to include images that already have alt text.
180 * @return array<string, mixed>
181 */
182 private function get_attachment_query_args( bool $force ): array {
183 $query_args = array(
184 'post_type' => 'attachment',
185 'post_mime_type' => 'image',
186 'post_status' => 'inherit',
187 'fields' => 'ids',
188 'orderby' => 'ID',
189 'order' => 'ASC',
190 );
191
192 if ( ! $force ) {
193 $query_args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
194 'relation' => 'OR',
195 array(
196 'key' => '_wp_attachment_image_alt',
197 'compare' => 'NOT EXISTS',
198 ),
199 array(
200 'key' => '_wp_attachment_image_alt',
201 'value' => '',
202 'compare' => '=',
203 ),
204 );
205 }
206
207 return $query_args;
208 }
209
210 /**
211 * Counts how many attachments would be processed.
212 *
213 * @param bool $force Whether to include images that already have alt text.
214 * @return int Total number of matching attachments.
215 */
216 private function count_matching_attachments( bool $force ): int {
217 $query_args = $this->get_attachment_query_args( $force );
218 $query_args['posts_per_page'] = 1;
219 $query_args['no_found_rows'] = false;
220
221 $query = new \WP_Query( $query_args );
222
223 return (int) $query->found_posts;
224 }
225
226 /**
227 * Fetches the next batch of attachment IDs, starting after a cursor.
228 *
229 * Pagination is cursor-based on the post ID so progress is stable even
230 * as records are mutated mid-run.
231 *
232 * @param bool $force Whether to include images that already have alt text.
233 * @param int $batch_size Maximum number of IDs to return.
234 * @param int $cursor_id Only return IDs greater than this. 0 to start.
235 * @return int[] Attachment IDs ordered ascending.
236 */
237 private function fetch_attachment_batch( bool $force, int $batch_size, int $cursor_id ): array {
238 $query_args = $this->get_attachment_query_args( $force );
239 $query_args['posts_per_page'] = $batch_size;
240 $query_args['no_found_rows'] = true;
241
242 $where_filter = null;
243 if ( $cursor_id > 0 ) {
244 $where_filter = static function ( $where ) use ( $cursor_id ) {
245 global $wpdb;
246 return $where . $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $cursor_id );
247 };
248 add_filter( 'posts_where', $where_filter );
249 }
250
251 try {
252 $query = new \WP_Query( $query_args );
253 /** @var int[] $ids */
254 $ids = $query->posts;
255 } finally {
256 if ( null !== $where_filter ) {
257 remove_filter( 'posts_where', $where_filter );
258 }
259 }
260
261 return $ids;
262 }
263
264 /**
265 * Displays the list of images that would be processed in a dry run.
266 *
267 * @param int[]|null $explicit_ids List of IDs from --ids, or null to query.
268 * @param bool $force Whether the live run would include images with alt text.
269 * @param int $total Total count of matching attachments.
270 */
271 private function display_dry_run( ?array $explicit_ids, bool $force, int $total ): void {
272 $preview_limit = self::DRY_RUN_PREVIEW_LIMIT;
273
274 if ( null !== $explicit_ids ) {
275 $preview = array_slice( $explicit_ids, 0, $preview_limit );
276 } else {
277 $preview = $this->fetch_attachment_batch( $force, $preview_limit, 0 );
278 }
279
280 $items = array();
281 foreach ( $preview as $id ) {
282 $alt = get_post_meta( $id, '_wp_attachment_image_alt', true );
283 $items[] = array(
284 'ID' => $id,
285 'Title' => get_the_title( $id ),
286 'Current Alt' => ! empty( $alt ) ? $alt : '(empty)',
287 );
288 }
289
290 Utils\format_items( 'table', $items, array( 'ID', 'Title', 'Current Alt' ) );
291
292 if ( $total > $preview_limit ) {
293 WP_CLI::log( sprintf( '... and %d more.', $total - $preview_limit ) );
294 }
295
296 WP_CLI::log( sprintf( "\nDry run complete. %d image(s) would be processed.", $total ) );
297 }
298
299 /**
300 * Processes images through the alt text generation ability.
301 *
302 * Iterates in batches without holding the full ID set in memory. For
303 * --ids mode the bounded list is sliced; otherwise the database is
304 * queried one batch at a time using a post-ID cursor.
305 *
306 * @param \WP_Ability $ability The alt text generation ability.
307 * @param int[]|null $explicit_ids List of IDs from --ids, or null to query.
308 * @param int $total Total count to process (drives the progress bar).
309 * @param int $batch_size Number of images per batch.
310 * @param int $delay_ms Delay in milliseconds between API calls.
311 * @param bool $force Whether to regenerate existing alt text.
312 * @return array{generated: int, decorative: int, skipped: int, failed: int}
313 */
314 private function process_images( $ability, ?array $explicit_ids, int $total, int $batch_size, int $delay_ms, bool $force ): array {
315 $stats = array(
316 'generated' => 0,
317 'decorative' => 0,
318 'skipped' => 0,
319 'failed' => 0,
320 );
321
322 $progress = Utils\make_progress_bar( 'Generating alt text', $total );
323 $cursor = 0;
324 $processed = 0;
325
326 while ( $processed < $total ) {
327 if ( null !== $explicit_ids ) {
328 $batch = array_slice( $explicit_ids, $processed, $batch_size );
329 } else {
330 $batch = $this->fetch_attachment_batch( $force, $batch_size, $cursor );
331 }
332
333 if ( empty( $batch ) ) {
334 break;
335 }
336
337 foreach ( $batch as $id ) {
338 $id = (int) $id;
339 if ( $id > $cursor ) {
340 $cursor = $id;
341 }
342
343 $current_alt = get_post_meta( $id, '_wp_attachment_image_alt', true );
344 if ( ! $force && '' !== $current_alt && false !== $current_alt ) {
345 ++$stats['skipped'];
346 ++$processed;
347 $progress->tick();
348 continue;
349 }
350
351 $result = $ability->execute( array( 'attachment_id' => $id ) );
352
353 if ( is_wp_error( $result ) ) {
354 ++$stats['failed'];
355 WP_CLI::warning( sprintf( 'ID %d: %s', $id, $result->get_error_message() ) );
356 ++$processed;
357 $progress->tick();
358 continue;
359 }
360
361 $alt_text = $result['alt_text'] ?? '';
362 $is_decorative = ! empty( $result['is_decorative'] );
363
364 update_post_meta( $id, '_wp_attachment_image_alt', $alt_text );
365
366 if ( $is_decorative ) {
367 ++$stats['decorative'];
368 } else {
369 ++$stats['generated'];
370 }
371
372 ++$processed;
373 $progress->tick();
374
375 if ( $delay_ms <= 0 ) {
376 continue;
377 }
378
379 usleep( $delay_ms * 1000 );
380 }
381
382 $this->free_batch_memory();
383 }
384
385 $progress->finish();
386
387 return $stats;
388 }
389
390 /**
391 * Prints the summary table after processing.
392 *
393 * @param array{generated: int, decorative: int, skipped: int, failed: int} $stats Processing statistics.
394 */
395 private function print_summary( array $stats ): void {
396 WP_CLI::log( '' );
397
398 $items = array(
399 array(
400 'Metric' => 'Generated',
401 'Count' => $stats['generated'],
402 ),
403 array(
404 'Metric' => 'Decorative',
405 'Count' => $stats['decorative'],
406 ),
407 array(
408 'Metric' => 'Skipped',
409 'Count' => $stats['skipped'],
410 ),
411 array(
412 'Metric' => 'Failed',
413 'Count' => $stats['failed'],
414 ),
415 );
416
417 Utils\format_items( 'table', $items, array( 'Metric', 'Count' ) );
418
419 $total = $stats['generated'] + $stats['decorative'];
420 if ( $total > 0 ) {
421 WP_CLI::success( sprintf( 'Generated alt text for %d image(s).', $total ) );
422 } else {
423 WP_CLI::log( 'No alt text was generated.' );
424 }
425 }
426
427 /**
428 * Frees memory held between batches so long-running CLI runs do not exhaust it.
429 *
430 * Uses `wp_cache_flush_runtime()` (WP 6.0+) to drop the in-memory portion of
431 * the object cache without touching persistent backends like Redis. Falls
432 * back to a no-op when the helper is unavailable. Also resets the query log
433 * so it does not grow unbounded when `SAVEQUERIES` is enabled.
434 */
435 private function free_batch_memory(): void {
436 global $wpdb;
437
438 $wpdb->queries = array();
439
440 if ( ! function_exists( 'wp_cache_flush_runtime' ) ) {
441 return;
442 }
443
444 wp_cache_flush_runtime();
445 }
446 }
447