Skip to content

Commit abe4259

Browse files
Add WP-CLI command for bulk alt text generation
Introduce `wp ai alt-text generate` to backfill alt text for existing media library images using the ai/alt-text-generation ability. Supports --batch-size, --dry-run, --force, --ids, and --delay flags. Processes images server-side in batches with progress reporting and memory management. Closes #435
1 parent ff069e8 commit abe4259

File tree

2 files changed

+347
-0
lines changed

2 files changed

+347
-0
lines changed

includes/CLI/Alt_Text_Command.php

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
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+
}

includes/Main.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ public function load(): void {
9393
// Defer feature initialization to the 'init' action.
9494
add_action( 'init', array( $this, 'initialize_features' ), 15 );
9595

96+
// Register WP-CLI commands.
97+
if ( defined( 'WP_CLI' ) && WP_CLI ) {
98+
\WP_CLI::add_command( 'ai alt-text', CLI\Alt_Text_Command::class );
99+
}
100+
96101
// Register the default ability category.
97102
add_action( 'wp_abilities_api_categories_init', array( $this, 'register_ability_category' ) );
98103
}

0 commit comments

Comments
 (0)