*/ private static array $product_image_cache = array(); /** * Constructor. * * @param Encryption $encryption Encryption helper for API key access. */ public function __construct( Encryption $encryption ) { $this->encryption = $encryption; } /** * Optimize alt text for all images of a product. * * @param int $product_id Product ID. * @param bool $overwrite Whether to overwrite existing AI alt text. * * @return int Number of images processed. */ public function optimize_product_images( int $product_id, bool $overwrite = false ): int { $product = wc_get_product( $product_id ); if ( ! $product instanceof \WC_Product ) { return 0; } $featured = get_post_thumbnail_id( $product_id ); $gallery = $product->get_gallery_image_ids(); $all_ids = array_unique( array_filter( array_merge( array( (int) $featured ), $gallery ) ) ); $processed = 0; $title = $product->get_name(); foreach ( $all_ids as $attachment_id ) { $attachment_id = (int) $attachment_id; if ( ! $overwrite ) { $existing = get_post_meta( $attachment_id, self::META_ALT_TEXT, true ); if ( ! empty( $existing ) ) { continue; } } $image_url = wp_get_attachment_url( $attachment_id ); if ( empty( $image_url ) ) { continue; } $alt_text = $this->generate_alt_text( $image_url, $title ); if ( ! empty( $alt_text ) ) { update_post_meta( $attachment_id, self::META_ALT_TEXT, sanitize_text_field( $alt_text ) ); // Track that this is a product image. self::$product_image_cache[ $attachment_id ] = true; ++$processed; } } return $processed; } /** * Generate alt text for a single image using AI. * * Tries WP AI Services API first (WP 6.8+), falls back to OpenAI Vision. * * @param string $image_url Full URL of the image. * @param string $product_title Product name for context. * * @return string Generated alt text, or empty string on failure. */ public function generate_alt_text( string $image_url, string $product_title ): string { // Priority 1: WP AI Services API (WP 6.8+). if ( function_exists( 'ai_get_services' ) ) { try { $services = ai_get_services(); if ( $services && method_exists( $services, 'describe_image' ) ) { $result = $services->describe_image( $image_url ); if ( ! empty( $result ) ) { return mb_substr( $result, 0, 125 ); } } } catch ( \Exception $e ) { unset( $e ); // Intentional: fall through to OpenAI Vision on failure. } } // Priority 2: OpenAI Vision API. return $this->generate_via_openai_vision( $image_url, $product_title ); } /** * Generate alt text using OpenAI Vision API (gpt-4o-mini). * * @param string $image_url Image URL. * @param string $product_title Product name. * * @return string Alt text, or empty on failure. */ private function generate_via_openai_vision( string $image_url, string $product_title ): string { $encrypted_key = get_option( 'aicommerce_openai_api_key', '' ); if ( empty( $encrypted_key ) ) { return ''; } $api_key = $this->encryption->decrypt( $encrypted_key ); if ( empty( $api_key ) ) { return ''; } $body = array( 'model' => 'gpt-4o-mini', 'max_tokens' => 60, 'messages' => array( array( 'role' => 'user', 'content' => array( array( 'type' => 'image_url', 'image_url' => array( 'url' => $image_url ), ), array( 'type' => 'text', 'text' => sprintf( "Describe this product image for alt text. Product name: '%s'. Under 125 characters. Return ONLY the alt text.", $product_title ), ), ), ), ), ); $response = wp_remote_post( 'https://api.openai.com/v1/chat/completions', // phpcs:ignore PluginCheck.CodeAnalysis.AIProvider.DirectIntegration -- BYOK integration; WP AI Client requires WP 7.0, this plugin supports WP 6.4+. array( 'timeout' => 30, 'headers' => array( 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $body ), ) ); if ( is_wp_error( $response ) ) { return ''; } $status = wp_remote_retrieve_response_code( $response ); if ( 200 !== $status ) { return ''; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); $text = trim( $data['choices'][0]['message']['content'] ?? '' ); return mb_substr( $text, 0, 125 ); } /** * Filter callback: inject AI alt text on product images. * * Hooked to wp_get_attachment_image_attributes. * * @param array $attr Image attributes. * @param \WP_Post $attachment Attachment post object. * @param string $size Image size. * * @return array Modified attributes. */ public function inject_alt_text( array $attr, \WP_Post $attachment, $size ): array { if ( ! $this->is_product_image( $attachment->ID ) ) { return $attr; } $ai_alt = get_post_meta( $attachment->ID, self::META_ALT_TEXT, true ); if ( ! empty( $ai_alt ) ) { $attr['alt'] = esc_attr( $ai_alt ); } return $attr; } /** * Check if an attachment is used as a product featured or gallery image. * * @param int $attachment_id Attachment ID. * * @return bool True if used as a product image. */ private function is_product_image( int $attachment_id ): bool { if ( isset( self::$product_image_cache[ $attachment_id ] ) ) { return self::$product_image_cache[ $attachment_id ]; } global $wpdb; // Check if used as featured image on any product. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $is_featured = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID WHERE pm.meta_key = '_thumbnail_id' AND pm.meta_value = %d AND p.post_type = 'product' LIMIT 1", $attachment_id ) ); if ( $is_featured > 0 ) { self::$product_image_cache[ $attachment_id ] = true; return true; } // Check if in a product gallery. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $in_gallery = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID WHERE pm.meta_key = '_product_image_gallery' AND FIND_IN_SET(%d, pm.meta_value) > 0 AND p.post_type = 'product' LIMIT 1", $attachment_id ) ); $result = $in_gallery > 0; self::$product_image_cache[ $attachment_id ] = $result; return $result; } }