PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.18
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.18
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / media-optimization / class-media-batch-optimizer.php

class-media-batch-optimizer.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.18, at media-optimization/class-media-batch-optimizer.php

373 lines 12.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Metasync_Media_Batch_Optimizer
4 * AJAX-driven batch optimizer for converting existing media library images.
5 * Uses browser-driven AJAX chaining for speed, with WP Cron as fallback.
6 *
7 * @package Search Atlas SEO
8 * @copyright Copyright (C) 2021-2025, Search Atlas Group - support@searchatlas.com
9 * @since 2.6.0
10 */
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 class Metasync_Media_Batch_Optimizer {
17
18 private const QUEUE_OPTION = 'metasync_batch_optimize_queue';
19 private const PROGRESS_OPTION = 'metasync_batch_optimize_progress';
20 private const CRON_HOOK = 'metasync_media_batch_optimize_cron';
21 private const DEFAULT_BATCH_SIZE = 10;
22 private const MIN_BATCH_SIZE = 2;
23 private const MAX_FILTER_BATCH_SIZE = 50;
24 private const CRON_TIME_LIMIT = 30; // seconds per cron tick
25 private const TIME_SAFETY_MARGIN = 5; // seconds reserved for saving progress
26 private const QUERY_PAGE_SIZE = 1000;
27
28 /**
29 * Memory per image estimate in bytes (30 MB).
30 * Used to calculate how many images can be processed safely.
31 */
32 private const MEMORY_PER_IMAGE = 30 * 1024 * 1024;
33
34 /**
35 * Calculate adaptive batch size based on available memory.
36 *
37 * Uses PHP memory_limit and current usage to determine a safe batch size.
38 * Filterable via 'metasync_batch_optimize_size' for manual override.
39 *
40 * @return int Batch size (clamped between MIN_BATCH_SIZE and DEFAULT_BATCH_SIZE).
41 */
42 public static function get_batch_size(): int {
43 $filtered = apply_filters('metasync_batch_optimize_size', 0);
44 if ($filtered > 0) {
45 return max(self::MIN_BATCH_SIZE, min(self::MAX_FILTER_BATCH_SIZE, (int) $filtered));
46 }
47
48 return static::calculate_batch_size(
49 (string) ini_get('memory_limit'),
50 memory_get_usage(true)
51 );
52 }
53
54 /**
55 * Calculate a safe time limit for cron processing.
56 *
57 * Respects PHP's max_execution_time so the process can save progress
58 * before being killed. Leaves TIME_SAFETY_MARGIN seconds for cleanup.
59 *
60 * @param int $max_execution_time PHP max_execution_time (0 = unlimited). Accepts parameter for testability.
61 * @return int Safe time limit in seconds (minimum 1).
62 */
63 protected static function get_safe_time_limit(int $max_execution_time = -1): int {
64 if ($max_execution_time < 0) {
65 $max_execution_time = (int) ini_get('max_execution_time');
66 }
67
68 // 0 means no limit — use the configured constant.
69 if ($max_execution_time === 0) {
70 return self::CRON_TIME_LIMIT;
71 }
72
73 $safe = $max_execution_time - self::TIME_SAFETY_MARGIN;
74
75 return max(1, min(self::CRON_TIME_LIMIT, $safe));
76 }
77
78 /**
79 * Pure calculation of batch size from memory parameters.
80 *
81 * @param string $memory_limit PHP memory_limit value (e.g. '128M', '-1').
82 * @param int $memory_usage Current memory usage in bytes.
83 * @return int Batch size (clamped between MIN_BATCH_SIZE and DEFAULT_BATCH_SIZE).
84 */
85 protected static function calculate_batch_size(string $memory_limit, int $memory_usage): int {
86 // Unlimited or unreadable memory — use default.
87 if ($memory_limit === '-1' || $memory_limit === '') {
88 return self::DEFAULT_BATCH_SIZE;
89 }
90
91 $memory_limit = trim($memory_limit);
92 if ($memory_limit === '0') {
93 return self::MIN_BATCH_SIZE;
94 }
95
96 $limit_bytes = (int) $memory_limit;
97 $unit = strtolower(substr($memory_limit, -1));
98 $limit_bytes = match ($unit) {
99 'g' => $limit_bytes * 1024 * 1024 * 1024,
100 'm' => $limit_bytes * 1024 * 1024,
101 'k' => $limit_bytes * 1024,
102 default => $limit_bytes,
103 };
104
105 $available = $limit_bytes - $memory_usage;
106
107 if ($available <= 0) {
108 return self::MIN_BATCH_SIZE;
109 }
110
111 $safe_count = (int) floor($available / self::MEMORY_PER_IMAGE);
112
113 return max(self::MIN_BATCH_SIZE, min(self::DEFAULT_BATCH_SIZE, $safe_count));
114 }
115
116 /**
117 * Start a new batch optimization run.
118 *
119 * @param array $settings Media optimization settings.
120 * @return array Progress data.
121 */
122 public static function start_batch(array $settings): array {
123 if (self::is_running()) {
124 return self::get_progress();
125 }
126
127 $ids = self::query_unoptimized_ids();
128
129 if (empty($ids)) {
130 return [
131 'total' => 0,
132 'processed' => 0,
133 'failed' => 0,
134 'status' => 'completed',
135 'started_at' => current_time('mysql'),
136 ];
137 }
138
139 update_option(self::QUEUE_OPTION, $ids, false);
140
141 $progress = [
142 'total' => count($ids),
143 'processed' => 0,
144 'failed' => 0,
145 'status' => 'running',
146 'started_at' => current_time('mysql'),
147 ];
148 update_option(self::PROGRESS_OPTION, $progress, false);
149
150 // Store settings for cron fallback to use
151 update_option('metasync_batch_optimize_settings', $settings, false);
152
153 // Schedule cron as fallback (runs if browser tab is closed)
154 if (!wp_next_scheduled(self::CRON_HOOK)) {
155 wp_schedule_event(time() + 120, 'metasync_every_2_minutes', self::CRON_HOOK);
156 }
157
158 return self::get_progress();
159 }
160
161 /**
162 * Cancel a running batch optimization.
163 */
164 public static function cancel_batch(): void {
165 $progress = self::get_progress();
166 $progress['status'] = 'cancelled';
167 update_option(self::PROGRESS_OPTION, $progress, false);
168
169 delete_option(self::QUEUE_OPTION);
170 wp_clear_scheduled_hook(self::CRON_HOOK);
171 }
172
173 /**
174 * Get current batch progress.
175 *
176 * @return array Progress data.
177 */
178 public static function get_progress(): array {
179 $default = [
180 'total' => 0,
181 'processed' => 0,
182 'failed' => 0,
183 'status' => 'idle',
184 'started_at' => '',
185 ];
186
187 return wp_parse_args(get_option(self::PROGRESS_OPTION, []), $default);
188 }
189
190 /**
191 * Check if a batch is currently running.
192 */
193 public static function is_running(): bool {
194 $progress = self::get_progress();
195 return $progress['status'] === 'running';
196 }
197
198 /**
199 * Process one batch via AJAX (browser-driven chaining).
200 * Processes one adaptive batch of images and returns updated progress immediately.
201 *
202 * @return array Updated progress data.
203 */
204 public static function process_ajax_tick(): array {
205 $progress = self::get_progress();
206
207 if ($progress['status'] !== 'running') {
208 return $progress;
209 }
210
211 $queue = get_option(self::QUEUE_OPTION, []);
212
213 if (empty($queue)) {
214 self::complete_batch();
215 return self::get_progress();
216 }
217
218 $settings = get_option('metasync_batch_optimize_settings', []);
219
220 // Request WordPress's image processing memory limit (typically 256MB) before heavy work
221 wp_raise_memory_limit('image');
222
223 $batch = array_splice($queue, 0, self::get_batch_size());
224
225 foreach ($batch as $attachment_id) {
226 // Re-check status in case cancel was triggered mid-batch
227 $current = get_option(self::PROGRESS_OPTION, []);
228 if (($current['status'] ?? '') !== 'running') {
229 break;
230 }
231
232 self::convert_single($attachment_id, $settings, $progress);
233 }
234
235 update_option(self::QUEUE_OPTION, $queue, false);
236 update_option(self::PROGRESS_OPTION, $progress, false);
237
238 if (empty($queue)) {
239 self::complete_batch();
240 return self::get_progress();
241 }
242
243 return $progress;
244 }
245
246 /**
247 * Process batch tick via WP Cron (fallback when browser tab is closed).
248 * Runs a time-limited loop to process as many images as possible within CRON_TIME_LIMIT seconds.
249 */
250 public static function process_batch_tick(): void {
251 $progress = self::get_progress();
252
253 if ($progress['status'] !== 'running') {
254 self::complete_batch();
255 return;
256 }
257
258 $queue = get_option(self::QUEUE_OPTION, []);
259
260 if (empty($queue)) {
261 self::complete_batch();
262 return;
263 }
264
265 $settings = get_option('metasync_batch_optimize_settings', []);
266 $start = time();
267 $time_limit = self::get_safe_time_limit();
268
269 // Request WordPress's image processing memory limit (typically 256MB) before heavy work
270 wp_raise_memory_limit('image');
271
272 // Process images until time limit or queue empty.
273 // Batch size is re-computed each iteration so it adapts as memory fills up.
274 while (!empty($queue) && (time() - $start) < $time_limit) {
275 $batch = array_splice($queue, 0, self::get_batch_size());
276
277 foreach ($batch as $attachment_id) {
278 self::convert_single($attachment_id, $settings, $progress);
279 }
280
281 // Save progress after each batch (in case of crash)
282 update_option(self::PROGRESS_OPTION, $progress, false);
283 }
284
285 update_option(self::QUEUE_OPTION, $queue, false);
286
287 if (empty($queue)) {
288 self::complete_batch();
289 }
290 }
291
292 /**
293 * Convert a single attachment with error handling.
294 * Catches fatal errors (e.g. memory_limit) per-image so the batch continues.
295 */
296 private static function convert_single(int $attachment_id, array $settings, array &$progress): void {
297 try {
298 $success = Metasync_Image_Converter::convert_attachment($attachment_id, $settings);
299 $progress['processed']++;
300 if (!$success) {
301 $progress['failed']++;
302 }
303 } catch (\Throwable $e) {
304 $progress['processed']++;
305 $progress['failed']++;
306 error_log('[MetaSync Media Opt] Batch conversion error for attachment ' . $attachment_id . ': ' . $e->getMessage());
307 }
308 }
309
310 /**
311 * Mark batch as completed and clean up.
312 */
313 private static function complete_batch(): void {
314 $progress = self::get_progress();
315 if ($progress['status'] === 'running') {
316 $progress['status'] = 'completed';
317 update_option(self::PROGRESS_OPTION, $progress, false);
318 }
319
320 delete_option(self::QUEUE_OPTION);
321 delete_option('metasync_batch_optimize_settings');
322 wp_clear_scheduled_hook(self::CRON_HOOK);
323 }
324
325 /**
326 * Query all JPEG/PNG attachment IDs that have not been converted.
327 *
328 * Uses $wpdb->get_col() with keyset (cursor) pagination instead of
329 * WP_Query/get_posts with posts_per_page=-1, avoiding memory spikes
330 * on sites with large media libraries (50k+ images).
331 *
332 * Keyset pagination (WHERE p.ID > last_id) is used instead of
333 * LIMIT/OFFSET to maintain constant query performance regardless
334 * of page depth and immunity to concurrent inserts/deletes.
335 *
336 * @return int[] Attachment IDs.
337 */
338 private static function query_unoptimized_ids(): array {
339 global $wpdb;
340
341 $ids = [];
342 $last_id = 0;
343
344 do {
345 $batch = $wpdb->get_col(
346 $wpdb->prepare(
347 "SELECT p.ID
348 FROM {$wpdb->posts} p
349 LEFT JOIN {$wpdb->postmeta} pm
350 ON pm.post_id = p.ID AND pm.meta_key = '_metasync_converted_format'
351 WHERE p.post_type = 'attachment'
352 AND p.post_status = 'inherit'
353 AND p.post_mime_type IN ('image/jpeg', 'image/png')
354 AND pm.meta_id IS NULL
355 AND p.ID > %d
356 ORDER BY p.ID ASC
357 LIMIT %d",
358 $last_id,
359 self::QUERY_PAGE_SIZE
360 )
361 );
362
363 if (!empty($batch)) {
364 $int_batch = array_map('intval', $batch);
365 array_push($ids, ...$int_batch);
366 $last_id = end($int_batch);
367 }
368 } while (!empty($batch) && count($batch) === self::QUERY_PAGE_SIZE);
369
370 return $ids;
371 }
372 }
373