Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.74% covered (warning)
74.74%
290 / 388
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImageEdit
74.74% covered (warning)
74.74%
290 / 388
37.50% covered (danger)
37.50%
3 / 8
190.35
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_abilities
100.00% covered (success)
100.00%
106 / 106
100.00% covered (success)
100.00%
1 / 1
1
 extract_colors
93.75% covered (success)
93.75%
30 / 32
0.00% covered (danger)
0.00%
0 / 1
7.01
 analyze_image_colors
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
240
 edit
87.80% covered (warning)
87.80%
72 / 82
0.00% covered (danger)
0.00%
0 / 1
31.63
 is_allowed_source_url
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 fetch_source_image
61.70% covered (warning)
61.70%
58 / 94
0.00% covered (danger)
0.00%
0 / 1
42.47
 build_multipart_body
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace BLU\Abilities;
6
7/**
8 * Image edit ability using the AI platform service.
9 */
10class ImageEdit {
11
12
13    /**
14     * Constructor - registers image generation ability.
15     */
16    public function __construct() {
17        $this->register_abilities();
18    }
19
20    /**
21     * Register image generation abilities.
22     */
23    private function register_abilities(): void {
24        blu_register_ability(
25            'blu/extract-image-colors',
26            array(
27                'label'               => 'Extract Image Colors',
28                'description'         => 'Analyze an uploaded image and return its dominant colors as hex codes. Use this when the user asks to apply a logo or image color to the site\'s global styles (accent color, primary color, brand color, etc.).',
29                'category'            => 'blu-mcp',
30                'input_schema'        => array(
31                    'type'       => 'object',
32                    'properties' => array(
33                        'image_url'  => array(
34                            'type'        => 'string',
35                            'description' => 'URL of the image to analyze. Must be a URL on this site (e.g. from the [User uploaded images] list).',
36                        ),
37                        'max_colors' => array(
38                            'type'        => 'integer',
39                            'description' => 'Maximum number of dominant colors to return. Defaults to 5.',
40                            'minimum'     => 1,
41                            'maximum'     => 10,
42                        ),
43                    ),
44                    'required'   => array( 'image_url' ),
45                ),
46                'execute_callback'    => array( $this, 'extract_colors' ),
47                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
48                'meta'                => array(
49                    'annotations' => array(
50                        'readonly'    => true,
51                        'destructive' => false,
52                        'idempotent'  => true,
53                    ),
54                ),
55            )
56        );
57
58        blu_register_ability(
59            'blu/edit-image',
60            array(
61                'label'               => 'Edit Image',
62                'description'         => 'Generatively edit an existing image: add or remove objects, change colours, adjust lighting, apply styles, or transform any aspect of the photo. Requires a source_url pointing to the current image. Use this instead of blu/generate-image whenever an existing image is available and the user wants to modify, change, or enhance it.',
63                'category'            => 'blu-mcp',
64                'input_schema'        => array(
65                    'type'       => 'object',
66                    'properties' => array(
67                        'prompt'        => array(
68                            'type'        => 'string',
69                            'description' => 'What to change in the image â€” describe the desired result or the modification (e.g. "add a dog under the table", "make the background white", "change the shirt to red"). Max 1000 characters.',
70                            'maxLength'   => 1000,
71                        ),
72                        'source_url'    => array(
73                            'type'        => 'string',
74                            'description' => 'URL of the existing image to edit. Must be an accessible HTTP/HTTPS URL.',
75                        ),
76                        'reference_url' => array(
77                            'type'        => 'string',
78                            'description' => 'URL of a second reference image to blend or combine with the source. Use this when the user has uploaded an image and wants to merge, blend, or apply elements from it onto the source. Must be an accessible HTTP/HTTPS URL.',
79                        ),
80                        'orientation'   => array(
81                            'type'        => 'string',
82                            'description' => 'The orientation of the image. Defaults to landscape.',
83                            'enum'        => array( 'landscape', 'portrait', 'square' ),
84                        ),
85                        'width'         => array(
86                            'type'        => 'integer',
87                            'description' => 'The width of the image. Defaults to 1024.',
88                            'maximum'     => 1920,
89                            'minimum'     => 1,
90                        ),
91                        'height'        => array(
92                            'type'        => 'integer',
93                            'description' => 'The height of the image. Defaults to 1024.',
94                            'maximum'     => 1080,
95                            'minimum'     => 1,
96                        ),
97                        'quality'       => array(
98                            'type'        => 'integer',
99                            'description' => 'The quality of the image. Defaults to 85.',
100                            'maximum'     => 100,
101                            'minimum'     => 1,
102                        ),
103                        'background'    => array(
104                            'type'        => 'string',
105                            'description' => 'The background of the image. Defaults to auto.',
106                            'enum'        => array( 'transparent', 'opaque', 'auto' ),
107                        ),
108                        'trim'          => array(
109                            'type'        => 'boolean',
110                            'description' => 'Whether to trim the image. Defaults to false.',
111                        ),
112                        'fit'           => array(
113                            'type'        => 'string',
114                            'description' => 'The fit of the image. Defaults to cover.',
115                            'enum'        => array( 'cover', 'contain', 'fill', 'none', 'scale-down' ),
116                        ),
117                    ),
118                    'required'   => array( 'prompt', 'source_url' ),
119                ),
120                'execute_callback'    => array( $this, 'edit' ),
121                'permission_callback' => fn() => current_user_can( 'upload_files' ),
122                'meta'                => array(
123                    'annotations' => array(
124                        'readonly'    => false,
125                        'destructive' => false,
126                        'idempotent'  => false,
127                    ),
128                ),
129            )
130        );
131    }
132
133    /**
134     * Extract dominant colors from an image using GD.
135     *
136     * @param array $input Tool input parameters.
137     * @return array Standardized ability response.
138     */
139    public function extract_colors( array $input ): array {
140        if ( ! function_exists( 'imagecreatefromstring' ) ) {
141            return blu_prepare_ability_response( 500, __( 'GD image library is not available on this server.', 'wp-module-mcp' ) );
142        }
143
144        $raw_url = (string) ( $input['image_url'] ?? '' );
145        $url     = esc_url_raw( $raw_url );
146        if ( empty( $url ) || ! filter_var( $raw_url, FILTER_VALIDATE_URL ) ) {
147            return blu_prepare_ability_response( 400, __( 'A valid image_url is required.', 'wp-module-mcp' ) );
148        }
149
150        if ( ! $this->is_allowed_source_url( $url ) ) {
151            return blu_prepare_ability_response( 400, __( 'The image_url is not allowed.', 'wp-module-mcp' ) );
152        }
153
154        $image_payload = $this->fetch_source_image( $url );
155        if ( isset( $image_payload['error'] ) ) {
156            return blu_prepare_ability_response( (int) $image_payload['status'], (string) $image_payload['error'] );
157        }
158
159        $max_colors = min( max( (int) ( $input['max_colors'] ?? 5 ), 1 ), 10 );
160        $colors     = $this->analyze_image_colors( $image_payload['content'], $max_colors );
161
162        if ( empty( $colors ) ) {
163            return blu_prepare_ability_response(
164                200,
165                array(
166                    'colors'  => array(),
167                    'message' => __( 'No distinct colors found. The image may be mostly white, black, or transparent.', 'wp-module-mcp' ),
168                )
169            );
170        }
171
172        return blu_prepare_ability_response(
173            200,
174            array(
175                'dominant' => $colors[0]['hex'],
176                'colors'   => $colors,
177                'message'  => sprintf(
178                    /* translators: %s: dominant hex color value */
179                    __( 'Dominant color: %s. Use this hex value with blu/update-global-styles to apply it as the accent or primary color.', 'wp-module-mcp' ),
180                    $colors[0]['hex']
181                ),
182            )
183        );
184    }
185
186    /**
187     * Sample pixels from image binary data and return dominant non-background colors.
188     *
189     * Resizes to an 80×80 thumbnail for efficient sampling, skips transparent,
190     * near-white, and near-black pixels, then quantizes to 32-step bins to group
191     * similar shades before ranking by frequency.
192     *
193     * @param string $content    Raw image binary content.
194     * @param int    $max_colors Maximum colors to return.
195     * @return array Array of {hex, percentage} sorted by frequency descending.
196     */
197    private function analyze_image_colors( string $content, int $max_colors ): array {
198        $img = @imagecreatefromstring( $content ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
199        if ( false === $img ) {
200            return array();
201        }
202
203        // Convert palette-mode images (indexed PNG/GIF) to truecolor so that
204        // GD maps the transparent colour index to a real alpha channel before
205        // resampling â€” otherwise transparent pixels blend as opaque colours.
206        if ( ! imageistruecolor( $img ) ) {
207            imagepalettetotruecolor( $img );
208            imagealphablending( $img, false );
209            imagesavealpha( $img, true );
210        }
211
212        $sample_w = 80;
213        $sample_h = 80;
214        $thumb    = imagecreatetruecolor( $sample_w, $sample_h );
215
216        // Preserve transparency during resampling.
217        imagealphablending( $thumb, false );
218        imagesavealpha( $thumb, true );
219        $transparent = imagecolorallocatealpha( $thumb, 255, 255, 255, 127 );
220        imagefill( $thumb, 0, 0, $transparent );
221        imagealphablending( $thumb, true );
222
223        imagecopyresampled( $thumb, $img, 0, 0, 0, 0, $sample_w, $sample_h, imagesx( $img ), imagesy( $img ) );
224        unset( $img );
225
226        $color_counts = array();
227        $total        = 0;
228
229        for ( $x = 0; $x < $sample_w; $x++ ) {
230            for ( $y = 0; $y < $sample_h; $y++ ) {
231                $rgba  = imagecolorat( $thumb, $x, $y );
232                $alpha = ( $rgba >> 24 ) & 0x7F;
233
234                // Skip mostly-transparent pixels (127 = fully transparent in GD).
235                if ( $alpha > 90 ) {
236                    continue;
237                }
238
239                $r = ( $rgba >> 16 ) & 0xFF;
240                $g = ( $rgba >> 8 ) & 0xFF;
241                $b = $rgba & 0xFF;
242
243                // Skip near-white (typical image backgrounds).
244                if ( $r > 230 && $g > 230 && $b > 230 ) {
245                    continue;
246                }
247
248                // Skip near-black.
249                if ( $r < 20 && $g < 20 && $b < 20 ) {
250                    continue;
251                }
252
253                // Quantize to 32-step bins to group perceptually similar shades.
254                $qr  = (int) round( $r / 32 ) * 32;
255                $qg  = (int) round( $g / 32 ) * 32;
256                $qb  = (int) round( $b / 32 ) * 32;
257                $key = sprintf( '%02x%02x%02x', min( 255, $qr ), min( 255, $qg ), min( 255, $qb ) );
258
259                $color_counts[ $key ] = ( $color_counts[ $key ] ?? 0 ) + 1;
260                ++$total;
261            }
262        }
263
264        unset( $thumb );
265
266        if ( 0 === $total || empty( $color_counts ) ) {
267            return array();
268        }
269
270        arsort( $color_counts );
271
272        $colors = array();
273        foreach ( array_slice( $color_counts, 0, $max_colors, true ) as $hex => $count ) {
274            $colors[] = array(
275                'hex'        => '#' . $hex,
276                'percentage' => (int) round( $count / $total * 100 ),
277            );
278        }
279
280        return $colors;
281    }
282
283    /**
284     * Edit an image via the AI platform.
285     *
286     * @param array $input Tool input parameters.
287     * @return array Standardized ability response.
288     */
289    public function edit( array $input ): array {
290        // Extend PHP execution time for this long-running request.
291        set_time_limit( 120 );
292
293        $raw_source_url = (string) ( $input['source_url'] ?? '' );
294        $source_url     = esc_url_raw( $raw_source_url );
295        if ( empty( $source_url ) || ! filter_var( $raw_source_url, FILTER_VALIDATE_URL ) ) {
296            return blu_prepare_ability_response( 400, 'A valid source_url is required.' );
297        }
298
299        if ( ! $this->is_allowed_source_url( $source_url ) ) {
300            return blu_prepare_ability_response( 400, 'source_url is not allowed.' );
301        }
302
303        $image_payload = $this->fetch_source_image( $source_url );
304        if ( is_array( $image_payload ) && isset( $image_payload['error'] ) ) {
305            return blu_prepare_ability_response( $image_payload['status'], $image_payload['error'] );
306        }
307
308        $api_url = defined( 'NFD_AI_PLATFORM_URL' ) ? NFD_AI_PLATFORM_URL : 'https://ai-platform.hiive.cloud';
309
310        // Get Hiive auth token â€” required by the ai-platform middleware.
311        $hiive_token = '';
312        if ( class_exists( '\NewfoldLabs\WP\Module\Data\HiiveConnection' ) ) {
313            $hiive_token = \NewfoldLabs\WP\Module\Data\HiiveConnection::get_auth_token();
314        }
315
316        if ( empty( $hiive_token ) ) {
317            return blu_prepare_ability_response( 401, 'Unable to retrieve Hiive authentication token for image generation.' );
318        }
319
320        $fields = array(
321            'prompt' => substr( (string) $input['prompt'], 0, 1000 ),
322        );
323        if ( ! empty( $input['orientation'] ) ) {
324            $fields['orientation'] = $input['orientation'];
325        }
326        if ( ! empty( $input['background'] ) ) {
327            $fields['background'] = $input['background'];
328        }
329        if ( isset( $input['trim'] ) ) {
330            $fields['trim'] = $input['trim'] ? '1' : '0';
331        }
332        if ( ! empty( $input['width'] ) ) {
333            $fields['width'] = (string) min( (int) $input['width'], 1920 );
334        }
335        if ( ! empty( $input['height'] ) ) {
336            $fields['height'] = (string) min( (int) $input['height'], 1080 );
337        }
338        if ( ! empty( $input['quality'] ) ) {
339            $fields['quality'] = (string) min( max( (int) $input['quality'], 1 ), 100 );
340        }
341        if ( ! empty( $input['fit'] ) ) {
342            $fields['fit'] = (string) $input['fit'];
343        }
344        $files = array(
345            array(
346                'field'    => 'images[]',
347                'filename' => $image_payload['filename'],
348                'content'  => $image_payload['content'],
349                'mime'     => $image_payload['mime'],
350            ),
351        );
352
353        // If a reference image is provided, fetch and append it as a second image.
354        $raw_reference_url = (string) ( $input['reference_url'] ?? '' );
355        if ( ! empty( $raw_reference_url ) ) {
356            $reference_url = esc_url_raw( $raw_reference_url );
357            if ( filter_var( $raw_reference_url, FILTER_VALIDATE_URL ) && $this->is_allowed_source_url( $reference_url ) ) {
358                $reference_payload = $this->fetch_source_image( $reference_url );
359                if ( is_array( $reference_payload ) && isset( $reference_payload['content'] ) ) {
360                    $files[] = array(
361                        'field'    => 'images[]',
362                        'filename' => $reference_payload['filename'],
363                        'content'  => $reference_payload['content'],
364                        'mime'     => $reference_payload['mime'],
365                    );
366                }
367            }
368        }
369
370        $multipart_body = $this->build_multipart_body( $fields, $files );
371        $response       = wp_remote_post(
372            trailingslashit( $api_url ) . 'api/v1/imagegen/edit',
373            array(
374                'headers' => array(
375                    'Authorization' => 'Bearer ' . $hiive_token,
376                    'Accept'        => 'application/json',
377                    'Content-Type'  => 'multipart/form-data; boundary=' . $multipart_body['boundary'],
378                ),
379                'body'    => $multipart_body['body'],
380                'timeout' => 120,
381            )
382        );
383        if ( is_wp_error( $response ) ) {
384            $message = $response->get_error_message();
385            if ( false !== strpos( $message, 'timed out' ) || false !== strpos( $message, 'cURL error 28' ) ) {
386                return blu_prepare_ability_response( 504, 'Image edit timed out' );
387            }
388            return blu_prepare_ability_response( 502, 'Image edit service unavailable: ' . $message );
389        }
390        $status_code = wp_remote_retrieve_response_code( $response );
391        if ( $status_code < 200 || $status_code >= 300 ) {
392            $error_body = json_decode( wp_remote_retrieve_body( $response ), true );
393            $error_msg  = is_array( $error_body ) && ! empty( $error_body['message'] )
394                ? (string) $error_body['message']
395                : 'Image edit failed with status ' . $status_code;
396            return blu_prepare_ability_response( $status_code, $error_msg );
397        }
398        $data = json_decode( wp_remote_retrieve_body( $response ), true );
399        if ( empty( $data['url'] ) ) {
400            return blu_prepare_ability_response( 500, 'No image URL in response' );
401        }
402        // CDN URL only â€” no media library sideload here.
403        return blu_prepare_ability_response( 200, array( 'url' => $data['url'] ) );
404    }
405
406    /**
407     * Allow local site URLs, hiive.cloud CDN / platform images or unsplash.com images only (SSRF guard).
408     *
409     * @param string $url The URL to check.
410     * @return bool True if the URL is allowed, false otherwise.
411     */
412    private function is_allowed_source_url( $url ): bool {
413        $parsed = wp_parse_url( $url );
414        if ( empty( $parsed['host'] ) ) {
415            return false;
416        }
417        $host      = strtolower( $parsed['host'] );
418        $site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
419
420        // local site URLs, hiive.cloud CDN / platform images (same domains onboarding cron sideloads) or unsplash.com images.
421        if ( $host === $site_host || preg_match( '/(^|\.)hiive\.cloud$/', $host ) || preg_match( '/(^|\.)unsplash\.com$/', $host ) ) {
422            return true;
423        }
424        return (bool) apply_filters( 'blu_mcp_allowed_image_edit_source_hosts', false, $host, $url );
425    }
426
427
428    /**
429     * Fetch source image bytes for forwarding to Laravel (not saved to media library).
430     *
431     * @param string $url The URL of the image to fetch.
432     *
433     * @return array
434     */
435    private function fetch_source_image( string $url ): array {
436        $allowed_mimes = array(
437            'image/jpeg' => 'jpg',
438            'image/png'  => 'png',
439            'image/webp' => 'webp',
440        );
441
442        // For local-site URLs, read directly from the filesystem to avoid loopback
443        // HTTP issues (SSL certificate problems, blocked requests on local dev envs).
444        $url_host  = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
445        $site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
446        if ( $url_host === $site_host ) {
447            $url_path = wp_parse_url( $url, PHP_URL_PATH );
448            if ( ! is_string( $url_path ) ) {
449                return array(
450                    'status' => 400,
451                    'error'  => __( 'Invalid local URL path.', 'wp-module-mcp' ),
452                );
453            }
454            $abs_root = realpath( ABSPATH );
455            $abs_file = realpath( $abs_root . '/' . ltrim( $url_path, '/' ) );
456            if ( false === $abs_file || strpos( $abs_file, $abs_root ) !== 0 ) {
457                return array(
458                    'status' => 400,
459                    'error'  => __( 'Local file path is outside the WordPress root.', 'wp-module-mcp' ),
460                );
461            }
462            if ( ! is_file( $abs_file ) ) {
463                return array(
464                    'status' => 404,
465                    'error'  => __( 'Local source image not found.', 'wp-module-mcp' ),
466                );
467            }
468            $content = file_get_contents( $abs_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions
469            if ( false === $content || '' === $content ) {
470                return array(
471                    'status' => 400,
472                    'error'  => __( 'Could not read local source image.', 'wp-module-mcp' ),
473                );
474            }
475            $max_bytes = 10 * 1024 * 1024;
476            if ( strlen( $content ) > $max_bytes ) {
477                return array(
478                    'status' => 400,
479                    'error'  => __( 'Source image exceeds 10 MB limit.', 'wp-module-mcp' ),
480                );
481            }
482            $mime = is_callable( 'mime_content_type' ) ? strtolower( mime_content_type( $abs_file ) ) : '';
483            if ( ! isset( $allowed_mimes[ $mime ] ) ) {
484                return array(
485                    'status' => 400,
486                    'error'  => __( 'Unsupported source image type.', 'wp-module-mcp' ),
487                );
488            }
489            return array(
490                'filename' => basename( $abs_file ),
491                'content'  => $content,
492                'mime'     => $mime,
493            );
494        }
495
496        // Remote URL â€” fetch via HTTP.
497        $response = wp_remote_get(
498            $url,
499            array(
500                'timeout'     => 30,
501                'redirection' => 3,
502            )
503        );
504        if ( is_wp_error( $response ) ) {
505            return array(
506                'status' => 502,
507                /* translators: %s: error message from wp_remote_get */
508                'error'  => sprintf( __( 'Unable to fetch source image: %s', 'wp-module-mcp' ), $response->get_error_message() ),
509            );
510        }
511        $status_code = wp_remote_retrieve_response_code( $response );
512        if ( $status_code < 200 || $status_code >= 300 ) {
513            return array(
514                'status' => 400,
515                /* translators: %d: HTTP status code */
516                'error'  => sprintf( __( 'Unable to fetch source image (HTTP %d).', 'wp-module-mcp' ), $status_code ),
517            );
518        }
519        $content = wp_remote_retrieve_body( $response );
520        if ( '' === $content ) {
521            return array(
522                'status' => 400,
523                'error'  => __( 'Source image is empty.', 'wp-module-mcp' ),
524            );
525        }
526        $max_bytes = 10 * 1024 * 1024;
527        if ( strlen( $content ) > $max_bytes ) {
528            return array(
529                'status' => 400,
530                'error'  => __( 'Source image exceeds 10 MB limit.', 'wp-module-mcp' ),
531            );
532        }
533        $content_type = wp_remote_retrieve_header( $response, 'content-type' );
534        $mime         = is_string( $content_type ) ? strtolower( trim( explode( ';', $content_type )[0] ) ) : '';
535        if ( ! isset( $allowed_mimes[ $mime ] ) ) {
536            return array(
537                'status' => 400,
538                'error'  => __( 'Unsupported source image type.', 'wp-module-mcp' ),
539            );
540        }
541        $path     = wp_parse_url( $url, PHP_URL_PATH );
542        $basename = is_string( $path ) ? basename( $path ) : '';
543        $filename = '' !== $basename ? $basename : 'source.' . $allowed_mimes[ $mime ];
544        return array(
545            'filename' => $filename,
546            'content'  => $content,
547            'mime'     => $mime,
548        );
549    }
550
551    /**
552     * Build a multipart/form-data body for wp_remote_post.
553     *
554     * @param array $fields Fields to include in the multipart body.
555     * @param array $files Files to include in the multipart body.
556     *
557     * @return array
558     */
559    private function build_multipart_body( array $fields, array $files ): array {
560        $boundary = '----BluMcpFormBoundary' . wp_generate_password( 16, false );
561        $body     = '';
562        foreach ( $fields as $name => $value ) {
563            $body .= '--' . $boundary . "\r\n";
564            $body .= 'Content-Disposition: form-data; name="' . $name . '"' . "\r\n\r\n";
565            $body .= $value . "\r\n";
566        }
567        foreach ( $files as $file ) {
568            $body .= '--' . $boundary . "\r\n";
569            $body .= 'Content-Disposition: form-data; name="' . $file['field'] . '"; filename="' . $file['filename'] . '"' . "\r\n";
570            $body .= 'Content-Type: ' . $file['mime'] . "\r\n\r\n";
571            $body .= $file['content'] . "\r\n";
572        }
573        $body .= '--' . $boundary . '--' . "\r\n";
574        return array(
575            'boundary' => $boundary,
576            'body'     => $body,
577        );
578    }
579}