|
| 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 x.x.x |
| 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 x.x.x |
| 25 | + */ |
| 26 | +class Alt_Text_Command { |
| 27 | + |
| 28 | + /** |
| 29 | + * Generates alt text for images in the media library using AI. |
| 30 | + * |
| 31 | + * Queries images that are missing alt text and generates it using the |
| 32 | + * ai/alt-text-generation ability. Processes images in batches to manage |
| 33 | + * memory and API rate limits. |
| 34 | + * |
| 35 | + * ## OPTIONS |
| 36 | + * |
| 37 | + * [--batch-size=<number>] |
| 38 | + * : Number of images to process per batch. |
| 39 | + * --- |
| 40 | + * default: 20 |
| 41 | + * --- |
| 42 | + * |
| 43 | + * [--dry-run] |
| 44 | + * : Show what would be processed without making changes. |
| 45 | + * |
| 46 | + * [--force] |
| 47 | + * : Regenerate alt text even for images that already have it. |
| 48 | + * |
| 49 | + * [--ids=<ids>] |
| 50 | + * : Comma-separated list of specific attachment IDs to process. |
| 51 | + * |
| 52 | + * [--delay=<milliseconds>] |
| 53 | + * : Delay in milliseconds between each API call to avoid rate limiting. |
| 54 | + * --- |
| 55 | + * default: 500 |
| 56 | + * --- |
| 57 | + * |
| 58 | + * ## EXAMPLES |
| 59 | + * |
| 60 | + * # Generate alt text for all images missing it |
| 61 | + * $ wp ai alt-text generate |
| 62 | + * |
| 63 | + * # Dry run to see what would be processed |
| 64 | + * $ wp ai alt-text generate --dry-run |
| 65 | + * |
| 66 | + * # Regenerate alt text for specific images |
| 67 | + * $ wp ai alt-text generate --ids=42,55,100 --force |
| 68 | + * |
| 69 | + * # Process in small batches with custom delay |
| 70 | + * $ wp ai alt-text generate --batch-size=5 --delay=1000 |
| 71 | + * |
| 72 | + * @when after_wp_load |
| 73 | + * |
| 74 | + * @param array $args Positional arguments. |
| 75 | + * @param array $assoc_args Associative arguments. |
| 76 | + */ |
| 77 | + public function generate( $args, $assoc_args ): void { |
| 78 | + $this->ensure_admin_user(); |
| 79 | + |
| 80 | + $ability = wp_get_ability( 'ai/alt-text-generation' ); |
| 81 | + if ( ! $ability ) { |
| 82 | + WP_CLI::error( 'The ai/alt-text-generation ability is not registered. Make sure the Alt Text Generation experiment is enabled in Settings > AI.' ); |
| 83 | + } |
| 84 | + |
| 85 | + if ( ! has_valid_ai_credentials() ) { |
| 86 | + WP_CLI::error( 'No valid AI credentials found. Configure a provider in Settings > AI.' ); |
| 87 | + } |
| 88 | + |
| 89 | + $batch_size = (int) Utils\get_flag_value( $assoc_args, 'batch-size', 20 ); |
| 90 | + $dry_run = Utils\get_flag_value( $assoc_args, 'dry-run', false ); |
| 91 | + $force = Utils\get_flag_value( $assoc_args, 'force', false ); |
| 92 | + $delay_ms = (int) Utils\get_flag_value( $assoc_args, 'delay', 500 ); |
| 93 | + $ids_flag = Utils\get_flag_value( $assoc_args, 'ids', '' ); |
| 94 | + |
| 95 | + $attachment_ids = $this->get_attachment_ids( $ids_flag, $force ); |
| 96 | + |
| 97 | + if ( empty( $attachment_ids ) ) { |
| 98 | + WP_CLI::success( 'No images found matching the criteria.' ); |
| 99 | + return; |
| 100 | + } |
| 101 | + |
| 102 | + WP_CLI::log( sprintf( 'Found %d image(s) to process.', count( $attachment_ids ) ) ); |
| 103 | + |
| 104 | + if ( $dry_run ) { |
| 105 | + $this->display_dry_run( $attachment_ids ); |
| 106 | + return; |
| 107 | + } |
| 108 | + |
| 109 | + $stats = $this->process_images( $ability, $attachment_ids, $batch_size, $delay_ms, $force ); |
| 110 | + $this->print_summary( $stats ); |
| 111 | + } |
| 112 | + |
| 113 | + /** |
| 114 | + * Ensures a user with admin capabilities is set for the CLI session. |
| 115 | + */ |
| 116 | + private function ensure_admin_user(): void { |
| 117 | + if ( 0 !== get_current_user_id() ) { |
| 118 | + return; |
| 119 | + } |
| 120 | + |
| 121 | + $admins = get_users( |
| 122 | + array( |
| 123 | + 'role' => 'administrator', |
| 124 | + 'number' => 1, |
| 125 | + 'fields' => 'ID', |
| 126 | + ) |
| 127 | + ); |
| 128 | + |
| 129 | + if ( empty( $admins ) ) { |
| 130 | + WP_CLI::error( 'No administrator user found. Create one or pass --user=<id>.' ); |
| 131 | + } |
| 132 | + |
| 133 | + wp_set_current_user( (int) $admins[0] ); |
| 134 | + } |
| 135 | + |
| 136 | + /** |
| 137 | + * Gets attachment IDs to process. |
| 138 | + * |
| 139 | + * @param string $ids_flag Comma-separated IDs from the --ids flag. |
| 140 | + * @param bool $force Whether to include images that already have alt text. |
| 141 | + * @return int[] Array of attachment IDs. |
| 142 | + */ |
| 143 | + private function get_attachment_ids( string $ids_flag, bool $force ): array { |
| 144 | + if ( '' !== $ids_flag ) { |
| 145 | + $ids = array_map( 'absint', explode( ',', $ids_flag ) ); |
| 146 | + $ids = array_filter( $ids ); |
| 147 | + |
| 148 | + // Validate they exist and are images. |
| 149 | + return array_values( |
| 150 | + array_filter( |
| 151 | + $ids, |
| 152 | + static function ( int $id ): bool { |
| 153 | + return get_post( $id ) && wp_attachment_is_image( $id ); |
| 154 | + } |
| 155 | + ) |
| 156 | + ); |
| 157 | + } |
| 158 | + |
| 159 | + $query_args = array( |
| 160 | + 'post_type' => 'attachment', |
| 161 | + 'post_mime_type' => 'image', |
| 162 | + 'post_status' => 'inherit', |
| 163 | + 'posts_per_page' => -1, |
| 164 | + 'fields' => 'ids', |
| 165 | + 'orderby' => 'ID', |
| 166 | + 'order' => 'ASC', |
| 167 | + ); |
| 168 | + |
| 169 | + if ( ! $force ) { |
| 170 | + $query_args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query |
| 171 | + 'relation' => 'OR', |
| 172 | + array( |
| 173 | + 'key' => '_wp_attachment_image_alt', |
| 174 | + 'compare' => 'NOT EXISTS', |
| 175 | + ), |
| 176 | + array( |
| 177 | + 'key' => '_wp_attachment_image_alt', |
| 178 | + 'value' => '', |
| 179 | + 'compare' => '=', |
| 180 | + ), |
| 181 | + ); |
| 182 | + } |
| 183 | + |
| 184 | + $query = new \WP_Query( $query_args ); |
| 185 | + |
| 186 | + return array_map( 'absint', $query->posts ); |
| 187 | + } |
| 188 | + |
| 189 | + /** |
| 190 | + * Displays the list of images that would be processed in a dry run. |
| 191 | + * |
| 192 | + * @param int[] $attachment_ids Array of attachment IDs. |
| 193 | + */ |
| 194 | + private function display_dry_run( array $attachment_ids ): void { |
| 195 | + $items = array(); |
| 196 | + foreach ( $attachment_ids as $id ) { |
| 197 | + $alt = get_post_meta( $id, '_wp_attachment_image_alt', true ); |
| 198 | + $items[] = array( |
| 199 | + 'ID' => $id, |
| 200 | + 'Title' => get_the_title( $id ), |
| 201 | + 'Current Alt' => ! empty( $alt ) ? $alt : '(empty)', |
| 202 | + ); |
| 203 | + } |
| 204 | + |
| 205 | + Utils\format_items( 'table', $items, array( 'ID', 'Title', 'Current Alt' ) ); |
| 206 | + WP_CLI::log( sprintf( "\nDry run complete. %d image(s) would be processed.", count( $attachment_ids ) ) ); |
| 207 | + } |
| 208 | + |
| 209 | + /** |
| 210 | + * Processes images through the alt text generation ability. |
| 211 | + * |
| 212 | + * @param object $ability The alt text generation ability. |
| 213 | + * @param int[] $attachment_ids Array of attachment IDs. |
| 214 | + * @param int $batch_size Number of images per batch. |
| 215 | + * @param int $delay_ms Delay in milliseconds between API calls. |
| 216 | + * @param bool $force Whether to regenerate existing alt text. |
| 217 | + * @return array{generated: int, decorative: int, skipped: int, failed: int} |
| 218 | + */ |
| 219 | + private function process_images( $ability, array $attachment_ids, int $batch_size, int $delay_ms, bool $force ): array { |
| 220 | + $stats = array( |
| 221 | + 'generated' => 0, |
| 222 | + 'decorative' => 0, |
| 223 | + 'skipped' => 0, |
| 224 | + 'failed' => 0, |
| 225 | + ); |
| 226 | + $progress = Utils\make_progress_bar( 'Generating alt text', count( $attachment_ids ) ); |
| 227 | + $batches = array_chunk( $attachment_ids, $batch_size ); |
| 228 | + |
| 229 | + foreach ( $batches as $batch ) { |
| 230 | + foreach ( $batch as $id ) { |
| 231 | + $current_alt = get_post_meta( $id, '_wp_attachment_image_alt', true ); |
| 232 | + if ( ! $force && '' !== $current_alt && false !== $current_alt ) { |
| 233 | + ++$stats['skipped']; |
| 234 | + $progress->tick(); |
| 235 | + continue; |
| 236 | + } |
| 237 | + |
| 238 | + $result = $ability->execute( array( 'attachment_id' => $id ) ); |
| 239 | + |
| 240 | + if ( is_wp_error( $result ) ) { |
| 241 | + ++$stats['failed']; |
| 242 | + WP_CLI::warning( sprintf( 'ID %d: %s', $id, $result->get_error_message() ) ); |
| 243 | + $progress->tick(); |
| 244 | + continue; |
| 245 | + } |
| 246 | + |
| 247 | + $alt_text = $result['alt_text'] ?? ''; |
| 248 | + $is_decorative = ! empty( $result['is_decorative'] ); |
| 249 | + |
| 250 | + update_post_meta( $id, '_wp_attachment_image_alt', $alt_text ); |
| 251 | + |
| 252 | + if ( $is_decorative ) { |
| 253 | + ++$stats['decorative']; |
| 254 | + } else { |
| 255 | + ++$stats['generated']; |
| 256 | + } |
| 257 | + |
| 258 | + $progress->tick(); |
| 259 | + |
| 260 | + if ( $delay_ms <= 0 ) { |
| 261 | + continue; |
| 262 | + } |
| 263 | + |
| 264 | + usleep( $delay_ms * 1000 ); |
| 265 | + } |
| 266 | + |
| 267 | + $this->stop_the_insanity(); |
| 268 | + } |
| 269 | + |
| 270 | + $progress->finish(); |
| 271 | + |
| 272 | + return $stats; |
| 273 | + } |
| 274 | + |
| 275 | + /** |
| 276 | + * Prints the summary table after processing. |
| 277 | + * |
| 278 | + * @param array{generated: int, decorative: int, skipped: int, failed: int} $stats Processing statistics. |
| 279 | + */ |
| 280 | + private function print_summary( array $stats ): void { |
| 281 | + WP_CLI::log( '' ); |
| 282 | + |
| 283 | + $items = array( |
| 284 | + array( |
| 285 | + 'Metric' => 'Generated', |
| 286 | + 'Count' => $stats['generated'], |
| 287 | + ), |
| 288 | + array( |
| 289 | + 'Metric' => 'Decorative', |
| 290 | + 'Count' => $stats['decorative'], |
| 291 | + ), |
| 292 | + array( |
| 293 | + 'Metric' => 'Skipped', |
| 294 | + 'Count' => $stats['skipped'], |
| 295 | + ), |
| 296 | + array( |
| 297 | + 'Metric' => 'Failed', |
| 298 | + 'Count' => $stats['failed'], |
| 299 | + ), |
| 300 | + ); |
| 301 | + |
| 302 | + Utils\format_items( 'table', $items, array( 'Metric', 'Count' ) ); |
| 303 | + |
| 304 | + $total = $stats['generated'] + $stats['decorative']; |
| 305 | + if ( $total > 0 ) { |
| 306 | + WP_CLI::success( sprintf( 'Generated alt text for %d image(s).', $total ) ); |
| 307 | + } else { |
| 308 | + WP_CLI::log( 'No alt text was generated.' ); |
| 309 | + } |
| 310 | + } |
| 311 | + |
| 312 | + /** |
| 313 | + * Clears the WordPress object cache to prevent memory exhaustion during batch processing. |
| 314 | + */ |
| 315 | + private function stop_the_insanity(): void { |
| 316 | + global $wpdb, $wp_object_cache; |
| 317 | + |
| 318 | + $wpdb->queries = array(); |
| 319 | + |
| 320 | + if ( ! is_object( $wp_object_cache ) ) { |
| 321 | + return; |
| 322 | + } |
| 323 | + |
| 324 | + if ( property_exists( $wp_object_cache, 'group_ops' ) ) { |
| 325 | + $wp_object_cache->group_ops = array(); |
| 326 | + } |
| 327 | + if ( property_exists( $wp_object_cache, 'stats' ) ) { |
| 328 | + $wp_object_cache->stats = array(); |
| 329 | + } |
| 330 | + if ( property_exists( $wp_object_cache, 'memcache_debug' ) ) { |
| 331 | + $wp_object_cache->memcache_debug = array(); |
| 332 | + } |
| 333 | + if ( property_exists( $wp_object_cache, 'cache' ) ) { |
| 334 | + $wp_object_cache->cache = array(); |
| 335 | + } |
| 336 | + if ( ! method_exists( $wp_object_cache, '__remoteset' ) ) { |
| 337 | + return; |
| 338 | + } |
| 339 | + |
| 340 | + $wp_object_cache->__remoteset(); |
| 341 | + } |
| 342 | +} |
0 commit comments