*/ public static function providers() { $providers = array(); if ( Ai_WordPress::available() ) { $providers['connectors'] = array( 'label' => __( 'The AI connected to WordPress (Settings → Connectors)', 'fixlight' ), 'class' => Ai_WordPress::class, ); } return $providers + array( 'hosted' => array( 'label' => __( 'Fixlight free service (50 images a month)', 'fixlight' ), 'class' => Ai_Hosted::class, ), ); } /** * The active provider id, or '' when AI is off or not set up. * * @return string */ public static function active_provider() { $provider = (string) Settings::get( 'ai_provider' ); if ( 'hosted' === $provider ) { return Settings::get( 'ai_hosted_consent' ) ? 'hosted' : ''; } if ( 'connectors' === $provider ) { return Ai_WordPress::available() ? 'connectors' : ''; } return ''; } /** * The instructions every provider gets. * * @return string */ public static function system_prompt() { return implode( "\n", array( 'You write alt text for images on websites, for people who use screen readers.', '', 'Rules:', '- One sentence, at most 125 characters.', '- Describe what the image shows that matters to a visitor of this page. Use the page context if it is given.', '- Do not start with "Image of", "Picture of", "Photo of" or similar.', '- If the image contains text that matters (a sign, a banner, a menu, a chart title), include that text exactly as written, without translating it.', '- For a chart or graph, state its main takeaway in one short sentence.', '- For a logo, write the name shown in the logo followed by the word "logo" (in the requested language).', '- If the image is purely decorative (a pattern, a divider, a texture, a spacer, an abstract background), set "decorative" to true and "alt" to "".', '- Never guess who a person is. Describe people only by what is visible and relevant.', '- Write in the requested language.', '', 'Answer only with JSON: {"alt": "...", "decorative": false}', ) ); } /** * JSON schema of the answer. * * @return array */ public static function schema() { return array( 'type' => 'object', 'properties' => array( 'alt' => array( 'type' => 'string' ), 'decorative' => array( 'type' => 'boolean' ), ), 'required' => array( 'alt', 'decorative' ), 'additionalProperties' => false, ); } /** * Writes a suggestion for one image and stores it for review. * * @param int $attachment_id Attachment id. * @return array|\WP_Error Suggestion: alt, decorative, provider, model, applied. */ public static function suggest( $attachment_id ) { $provider = self::active_provider(); if ( '' === $provider ) { return new \WP_Error( 'fixlight_ai_off', __( 'AI alt text is not set up. Turn it on in Fixlight → Settings.', 'fixlight' ), array( 'status' => 400 ) ); } if ( ! wp_attachment_is_image( $attachment_id ) ) { return new \WP_Error( 'fixlight_not_image', __( 'That is not an image in the media library.', 'fixlight' ), array( 'status' => 404 ) ); } $image = self::image_payload( $attachment_id ); if ( is_wp_error( $image ) ) { return $image; } $context = self::context( $attachment_id ); $class = self::providers()[ $provider ]['class']; $answer = call_user_func( array( $class, 'describe' ), $image, $context ); if ( is_wp_error( $answer ) ) { return $answer; } $suggestion = array( 'alt' => self::clean_alt( isset( $answer['alt'] ) ? (string) $answer['alt'] : '' ), 'decorative' => ! empty( $answer['decorative'] ), 'provider' => $provider, 'model' => isset( $answer['model'] ) ? sanitize_text_field( (string) $answer['model'] ) : '', 'time' => time(), 'applied' => false, ); if ( ! $suggestion['decorative'] && '' === $suggestion['alt'] ) { return new \WP_Error( 'fixlight_ai_empty', __( 'The AI did not return a description. Try again, or write one yourself.', 'fixlight' ), array( 'status' => 502 ) ); } if ( Settings::get( 'ai_auto_apply' ) ) { $saved = Images::save( $attachment_id, $suggestion['alt'], $suggestion['decorative'] ); if ( ! is_wp_error( $saved ) ) { $suggestion['applied'] = true; } } update_post_meta( $attachment_id, self::SUGGESTION_META, $suggestion ); if ( isset( $answer['quota'] ) ) { $suggestion['quota'] = $answer['quota']; } return $suggestion; } /** * The stored suggestion for an image, if any. * * @param int $attachment_id Attachment id. * @return array|null */ public static function stored_suggestion( $attachment_id ) { $suggestion = get_post_meta( $attachment_id, self::SUGGESTION_META, true ); return is_array( $suggestion ) && isset( $suggestion['alt'] ) ? $suggestion : null; } /** * Tidies model output into alt text. * * @param string $alt Raw alt text. * @return string */ public static function clean_alt( $alt ) { $alt = trim( wp_strip_all_tags( $alt ) ); $alt = preg_replace( '/^(an?\s+)?(image|picture|photo|photograph)\s+(of|showing)\s+/i', '', $alt ); $alt = preg_replace( '/^(una?\s+)?(imagen|foto|fotografía)\s+(de|que muestra)\s+/iu', '', $alt ); $alt = trim( (string) $alt, " \t\n\r\0\x0B\"'" ); if ( function_exists( 'mb_substr' ) && mb_strlen( $alt ) > 250 ) { $alt = rtrim( mb_substr( $alt, 0, 249 ) ) . '…'; } if ( '' !== $alt && function_exists( 'mb_strtoupper' ) ) { $alt = mb_strtoupper( mb_substr( $alt, 0, 1 ) ) . mb_substr( $alt, 1 ); } return $alt; } /** * A JPEG of the image, at most 768 pixels on its longest side, base64 encoded. * * @param int $attachment_id Attachment id. * @return array|\WP_Error mime, data (base64), bytes. */ public static function image_payload( $attachment_id ) { $source = get_attached_file( $attachment_id ); $sized = image_get_intermediate_size( $attachment_id, 'medium_large' ); if ( $sized && ! empty( $sized['path'] ) ) { $uploads = wp_get_upload_dir(); $candidate = trailingslashit( $uploads['basedir'] ) . $sized['path']; if ( is_readable( $candidate ) ) { $source = $candidate; } } if ( ! $source || ! is_readable( $source ) ) { return new \WP_Error( 'fixlight_no_file', __( 'The image file could not be read.', 'fixlight' ), array( 'status' => 404 ) ); } if ( ! function_exists( 'wp_tempnam' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; // Not loaded on REST requests. } $editor = wp_get_image_editor( $source ); if ( ! is_wp_error( $editor ) ) { $editor->resize( self::MAX_SIDE, self::MAX_SIDE, false ); $editor->set_quality( 82 ); $base = wp_tempnam( 'fixlight-ai' ); // Creates an empty file; deleted below with the JPEG. $temp = $base . '.jpg'; $saved = $editor->save( $temp, 'image/jpeg' ); if ( ! is_wp_error( $saved ) && ! empty( $saved['path'] ) && is_readable( $saved['path'] ) ) { $bytes = file_get_contents( $saved['path'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local temporary file. wp_delete_file( $saved['path'] ); wp_delete_file( $temp ); wp_delete_file( $base ); if ( false !== $bytes && '' !== $bytes ) { return array( 'mime' => 'image/jpeg', 'data' => base64_encode( $bytes ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Image bytes for the API request. 'bytes' => strlen( $bytes ), ); } } wp_delete_file( $temp ); wp_delete_file( $base ); } // No image editor on this server: send the file as it is if it is small and in a common format. $type = wp_check_filetype( $source ); if ( in_array( $type['type'], array( 'image/jpeg', 'image/png', 'image/webp', 'image/gif' ), true ) && filesize( $source ) <= 1500000 ) { $bytes = file_get_contents( $source ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local media file. if ( false !== $bytes ) { return array( 'mime' => $type['type'], 'data' => base64_encode( $bytes ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Image bytes for the API request. 'bytes' => strlen( $bytes ), ); } } return new \WP_Error( 'fixlight_image_format', __( 'This image could not be prepared for the AI. Write its alt text yourself.', 'fixlight' ), array( 'status' => 415 ) ); } /** * Page context sent with the image. * * @param int $attachment_id Attachment id. * @return array{language:string,text:string} */ public static function context( $attachment_id ) { $lines = array(); $file = wp_basename( (string) get_attached_file( $attachment_id ) ); if ( '' !== $file ) { $lines[] = 'File name: ' . $file; } $title = get_the_title( $attachment_id ); // Camera names (IMG_2041, DSC0034) say nothing about the picture. if ( '' !== $title && ! preg_match( '/^(img|dsc|dscn|pxl|photo|image|screenshot|whatsapp)[\s_\-]?\d/i', $title ) && ! preg_match( '/^\d+$/', $title ) ) { $lines[] = 'Image title: ' . $title; } $caption = wp_get_attachment_caption( $attachment_id ); if ( $caption ) { $lines[] = 'Caption: ' . wp_strip_all_tags( $caption ); } $parent = (int) wp_get_post_parent_id( $attachment_id ); if ( $parent ) { $lines[] = 'Used on the page: ' . wp_strip_all_tags( get_the_title( $parent ) ); if ( 'product' === get_post_type( $parent ) ) { $lines[] = 'This is a product photo in an online shop.'; } } $lines[] = 'Website: ' . wp_strip_all_tags( get_bloginfo( 'name' ) ); // The site's language, not the language of the admin's own profile. $language = self::language_name( get_locale() ); $lines[] = 'Write the alt text in: ' . $language; return array( 'language' => $language, 'text' => implode( "\n", $lines ), ); } /** * English name of the site's language, for the prompt. * * @param string $locale Locale such as es_MX. * @return string */ public static function language_name( $locale ) { $names = array( 'en' => 'English', 'es' => 'Spanish', 'pt' => 'Portuguese', 'fr' => 'French', 'de' => 'German', 'it' => 'Italian', 'nl' => 'Dutch', 'pl' => 'Polish', 'sv' => 'Swedish', 'da' => 'Danish', 'nb' => 'Norwegian', 'fi' => 'Finnish', 'cs' => 'Czech', 'ro' => 'Romanian', 'hu' => 'Hungarian', 'el' => 'Greek', 'tr' => 'Turkish', 'ru' => 'Russian', 'uk' => 'Ukrainian', 'ja' => 'Japanese', 'ko' => 'Korean', 'zh' => 'Chinese', 'ar' => 'Arabic', 'he' => 'Hebrew', 'hi' => 'Hindi', 'id' => 'Indonesian', 'vi' => 'Vietnamese', 'th' => 'Thai', 'ca' => 'Catalan', ); $regions = array( 'MX' => 'Mexico', 'ES' => 'Spain', 'AR' => 'Argentina', 'CO' => 'Colombia', 'CL' => 'Chile', 'BR' => 'Brazil', 'PT' => 'Portugal', 'GB' => 'United Kingdom', 'US' => 'United States', 'CA' => 'Canada', 'AU' => 'Australia', 'TW' => 'Taiwan', 'CN' => 'China', ); $parts = explode( '_', (string) $locale ); $name = isset( $names[ $parts[0] ] ) ? $names[ $parts[0] ] : 'the language with code ' . $parts[0]; if ( isset( $parts[1], $regions[ $parts[1] ] ) && in_array( $parts[0], array( 'es', 'pt', 'zh' ), true ) ) { $name .= ' (' . $regions[ $parts[1] ] . ')'; } return $name; } /** * Parses a JSON answer, tolerating code fences around it. * * @param string $text Model output. * @return array|null */ public static function parse_answer( $text ) { $text = trim( (string) $text ); $text = preg_replace( '/^```(?:json)?\s*|\s*```$/', '', $text ); $data = json_decode( $text, true ); if ( ! is_array( $data ) && preg_match( '/\{.*\}/s', $text, $matches ) ) { $data = json_decode( $matches[0], true ); } return is_array( $data ) && array_key_exists( 'alt', $data ) ? $data : null; } /** * Turns a failed HTTP response into a readable error. * * @param array|\WP_Error $response Response. * @param string $service Service name for the message. * @return \WP_Error|null Null when the response is a success. */ public static function http_error( $response, $service ) { if ( is_wp_error( $response ) ) { /* translators: 1: service name, 2: error message. */ return new \WP_Error( 'fixlight_ai_http', sprintf( __( 'Could not reach %1$s: %2$s', 'fixlight' ), $service, $response->get_error_message() ), array( 'status' => 502 ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code >= 200 && $code < 300 ) { return null; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); $message = ''; if ( is_array( $body ) ) { if ( isset( $body['error']['message'] ) ) { $message = (string) $body['error']['message']; } elseif ( isset( $body['message'] ) ) { $message = (string) $body['message']; } } if ( 401 === $code || 403 === $code ) { /* translators: %s: service name. */ $message = sprintf( __( '%s refused the request. Try again later.', 'fixlight' ), $service ); } elseif ( 429 === $code && '' === $message ) { /* translators: %s: service name. */ $message = sprintf( __( '%s says there were too many requests. Wait a minute and try again.', 'fixlight' ), $service ); } /* translators: 1: service name, 2: HTTP status code, 3: error message. */ return new \WP_Error( 'fixlight_ai_http', sprintf( __( '%1$s answered with an error (%2$d). %3$s', 'fixlight' ), $service, $code, wp_strip_all_tags( substr( $message, 0, 300 ) ) ), array( 'status' => 502 ) ); } }