PluginProbe
ActivityPub / 9.0.0
ActivityPub v9.0.0
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / cli / class-blurhash-command.php

class-blurhash-command.php in ActivityPub 9.0.0, at includes/cli/class-blurhash-command.php

273 lines 8.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WP-CLI commands for the Blurhash encoder.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\Cli;
9
10 use Activitypub\Blurhash;
11
12 /**
13 * `wp activitypub blurhash …` command surface for backfilling and managing
14 * stored blurhash placeholders.
15 *
16 * The runtime path ({@see Blurhash}) covers new uploads via a cron
17 * event; this command exists for two cases the runtime can't handle:
18 *
19 * 1. **First install on a site with existing media** — every
20 * already-uploaded image attachment is missing the postmeta and
21 * will never get it without a re-upload or a forced re-encode.
22 * `wp activitypub blurhash backfill` walks the library and fills the
23 * gap.
24 * 2. **Re-encode after an algorithm change** — if the encoder's
25 * components or source-size change, the previously-computed
26 * hashes are stale (still decodable, but produced from a
27 * different recipe). `--force` recomputes against the latest
28 * bytes.
29 *
30 * Registration is gated on `WP_CLI` so the class is only loaded by
31 * the CLI process — no overhead on web requests.
32 */
33 class Blurhash_Command extends \WP_CLI_Command {
34
35 /**
36 * WP_Query page size for the backfill walk. 100 is the same batch
37 * size WP's own bulk operations default to and keeps each query's
38 * working set bounded.
39 *
40 * @var int
41 */
42 private const PAGE_SIZE = 100;
43
44 /**
45 * Compute and persist Blurhash placeholders for image
46 * attachments missing one. Use after first install on a site
47 * with existing media, or after an encoder change with --force.
48 *
49 * Default mode walks only attachments that have no stored
50 * `_activitypub_blurhash` — already-encoded media is filtered out
51 * server-side via a `NOT EXISTS` meta query rather than fetched
52 * and skipped in PHP. Combined with `--limit`, this means a
53 * limit of N processes exactly N candidates (the prior behavior
54 * counted already-hashed attachments against the limit and could
55 * never advance past a fully-encoded first page).
56 *
57 * `--force` drops the meta-query filter and walks every image
58 * attachment in ID order. The existing hash is overwritten only
59 * after the new encode succeeds, so a failed re-encode leaves
60 * the prior good hash in place.
61 *
62 * Non-raster `image/*` mime types (SVG, etc.) are recognized by
63 * `wp_attachment_is_image()` returning false and are counted as
64 * skipped, not failed — they're outside the encoder's GD-based
65 * scope by design.
66 *
67 * Exits nonzero when any encode failed, so automation can detect
68 * partial-success runs without parsing the summary text.
69 *
70 * ## OPTIONS
71 *
72 * [--dry-run]
73 * : Walk and report what would be encoded, but don't write postmeta.
74 *
75 * [--limit=<n>]
76 * : Process at most <n> candidate attachments. Default: 0 (no limit).
77 *
78 * [--force]
79 * : Re-encode attachments that already have a stored hash.
80 *
81 * ## EXAMPLES
82 *
83 * wp activitypub blurhash backfill --dry-run
84 * wp activitypub blurhash backfill --limit=100
85 * wp activitypub blurhash backfill --force
86 *
87 * @param array<int, string> $args Positional CLI args (unused).
88 * @param array<string, mixed> $assoc_args Associative CLI flags.
89 *
90 * @when after_wp_load
91 */
92 public function backfill( $args, $assoc_args ) {
93 unset( $args );
94
95 $dry_run = ! empty( $assoc_args['dry-run'] );
96 $force = ! empty( $assoc_args['force'] );
97 $limit = isset( $assoc_args['limit'] ) ? (int) $assoc_args['limit'] : 0;
98
99 // Fail fast when the encoder can't run on this host.
100 // Without this gate, the loop would emit a `\WP_CLI::warning`
101 // per attachment and exit non-zero — noisy and unactionable.
102 // `--dry-run` still works (no encoding attempted), so
103 // operators can enumerate candidates from a GD-less host
104 // to decide whether to migrate the media.
105 if ( ! $dry_run && ! Blurhash::is_encoder_runnable() ) {
106 \WP_CLI::error( 'Blurhash encoder requires GD (imagecreatefromstring/truecolor/scale). Install or enable GD and re-run.' );
107 return;
108 }
109
110 $encoded = 0;
111 $skipped = 0;
112 $failed = 0;
113 $last_id = 0;
114
115 while ( true ) {
116 $query_args = array(
117 'post_type' => 'attachment',
118 'post_status' => 'inherit',
119 'post_mime_type' => 'image',
120 'posts_per_page' => self::PAGE_SIZE,
121 'fields' => 'ids',
122 'no_found_rows' => true,
123 'update_post_meta_cache' => false,
124 'update_post_term_cache' => false,
125 'orderby' => 'ID',
126 'order' => 'ASC',
127 );
128
129 // Default mode filters to candidates server-side, so we
130 // never call `Blurhash::get()` per row in the loop (the
131 // N+1 the prior pagination shape had). `--force` skips
132 // the filter and walks everything in ID order.
133 //
134 // Candidate set is "key missing OR value empty". A
135 // non-empty-but-malformed row (postmeta poisoning, a
136 // truncated import) self-heals through the runtime
137 // cron path because {@see Blurhash::get()} reports
138 // malformed values as absent, so `run_encode()` will
139 // re-compute on the next `wp_generate_attachment_metadata`
140 // regen. Operators who need a one-shot rescue without
141 // waiting for a regen run the command with `--force`.
142 if ( ! $force ) {
143 $query_args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- one-shot CLI backfill; not a web-path query.
144 'relation' => 'OR',
145 array(
146 'key' => Blurhash::META_KEY,
147 'compare' => 'NOT EXISTS',
148 ),
149 array(
150 'key' => Blurhash::META_KEY,
151 'value' => '',
152 'compare' => '=',
153 ),
154 );
155 }
156
157 // Keyset pagination — `ID > $last_id` is stable under
158 // concurrent inserts and deletes, so a long backfill can't
159 // silently skip attachments the way offset pagination
160 // (paged=N) does when rows shift mid-run.
161 $where_filter = null;
162 if ( $last_id > 0 ) {
163 $where_filter = self::keyset_where( $last_id );
164 \add_filter( 'posts_where', $where_filter, 10, 1 );
165 }
166
167 $query = new \WP_Query( $query_args );
168
169 if ( null !== $where_filter ) {
170 \remove_filter( 'posts_where', $where_filter, 10 );
171 }
172
173 if ( empty( $query->posts ) ) {
174 break;
175 }
176
177 foreach ( $query->posts as $attachment_id ) {
178 $attachment_id = (int) $attachment_id;
179 $last_id = $attachment_id;
180
181 // Non-encodable mime types (SVG, ICO, anything
182 // outside the GD raster set) get filtered out
183 // up-front and counted as skipped, not failed. The
184 // encoder would return null on them too, but a
185 // per-attachment WARNING for every SVG on every
186 // backfill run is noise; the user already knows we
187 // don't encode vector formats.
188 if ( ! Blurhash::is_encodable_attachment( $attachment_id ) ) {
189 ++$skipped;
190 continue;
191 }
192
193 if ( $limit > 0 && ( $encoded + $failed ) >= $limit ) {
194 break 2;
195 }
196
197 if ( $dry_run ) {
198 ++$encoded;
199 \WP_CLI::log( "would encode: attachment {$attachment_id}" );
200 continue;
201 }
202
203 $hash = Blurhash::encode_from_attachment( $attachment_id );
204
205 /*
206 * `false` is a policy skip (e.g. declared dimensions
207 * over the decode-bomb cap) — same bucket as a
208 * non-raster mime, not a failure. Warning on it every
209 * run (and exiting nonzero) would make a permanently
210 * over-cap attachment poison automation forever.
211 */
212 if ( false === $hash ) {
213 ++$skipped;
214 \WP_CLI::log( "skipped (out of encode policy): attachment {$attachment_id}" );
215 continue;
216 }
217
218 if ( null === $hash ) {
219 ++$failed;
220 \WP_CLI::warning( "encode failed: attachment {$attachment_id}" );
221 continue;
222 }
223
224 // Encode-then-set — never delete-first. A failed
225 // re-encode on `--force` leaves the prior good hash
226 // in place rather than wiping it.
227 Blurhash::set( $attachment_id, $hash );
228 ++$encoded;
229 \WP_CLI::log( "encoded {$attachment_id}: {$hash}" );
230 }
231 }
232
233 $encoded_label = $dry_run ? 'would encode' : 'encoded';
234 $summary = sprintf(
235 '%s %d, skipped %d (non-raster, unsupported, or out of encode policy), failed %d.',
236 $encoded_label,
237 $encoded,
238 $skipped,
239 $failed
240 );
241
242 if ( $dry_run ) {
243 \WP_CLI::success( '[dry-run] ' . $summary );
244 return;
245 }
246
247 // Non-zero exit on any failure so automation can detect
248 // partial-success runs without parsing the summary text.
249 if ( $failed > 0 ) {
250 \WP_CLI::error( $summary );
251 return;
252 }
253
254 \WP_CLI::success( $summary );
255 }
256
257 /**
258 * Build a `posts_where` filter closure that constrains the
259 * query to `ID > $last_id`. Used to implement keyset pagination
260 * without polluting WP_Query's stable args. The closure clears
261 * itself after each query in {@see self::backfill()}.
262 *
263 * @param int $last_id Last processed attachment ID.
264 * @return \Closure
265 */
266 private static function keyset_where( int $last_id ): \Closure {
267 return function ( $where ) use ( $last_id ) {
268 global $wpdb;
269 return $where . $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $last_id );
270 };
271 }
272 }
273