class-restai-images.php
1 <?php 2 /** 3 * Featured-image generation + auto alt text on uploads. Uses RESTai's 4 * OpenAI-compatible /v1/images/generations endpoint for image gen and a vision 5 * LLM project for alt text. 6 * 7 * @package RESTai 8 */ 9 10 namespace RESTai; 11 12 if ( ! defined( 'ABSPATH' ) ) { 13 exit; 14 } 15 16 class Images { 17 18 /** @var Client */ 19 private $client; 20 21 public function __construct( Client $client ) { 22 $this->client = $client; 23 add_filter( 'wp_handle_upload', array( $this, 'maybe_alt_text_on_upload' ), 10, 2 ); 24 } 25 26 /** 27 * Generate a featured image for the given post and attach it. 28 * 29 * @param int $post_id 30 * @return array|\WP_Error 31 */ 32 public function generate_featured_image( $post_id ) { 33 $post = get_post( $post_id ); 34 if ( ! $post ) { 35 return new \WP_Error( 'restai_no_post', __( 'Post not found.', 'restai' ) ); 36 } 37 38 $prompt = $this->build_image_prompt( $post ); 39 40 $model = $this->pick_image_generator(); 41 if ( null === $model ) { 42 return new \WP_Error( 43 'restai_no_image_gen', 44 __( 'No image generator available — assign one to your team in RESTai (Team → Image generators), or grant access to dalle3 / imagen3.', 'restai' ) 45 ); 46 } 47 48 $resp = $this->client->post( 49 'v1/images/generations', 50 array( 51 'model' => $model, 52 'prompt' => $prompt, 53 'n' => 1, 54 'size' => '1024x1024', 55 'response_format' => 'b64_json', 56 ), 57 array( 'timeout' => 300 ) 58 ); 59 60 if ( is_wp_error( $resp ) ) { 61 return $resp; 62 } 63 if ( empty( $resp['data'][0] ) ) { 64 return new \WP_Error( 'restai_image_empty', __( 'Image generator returned no data.', 'restai' ) ); 65 } 66 67 $item = $resp['data'][0]; 68 $bytes = null; 69 if ( ! empty( $item['b64_json'] ) ) { 70 $bytes = base64_decode( $item['b64_json'] ); 71 } elseif ( ! empty( $item['url'] ) ) { 72 $dl = wp_remote_get( $item['url'], array( 'timeout' => 30 ) ); 73 if ( is_wp_error( $dl ) ) { 74 return $dl; 75 } 76 $bytes = wp_remote_retrieve_body( $dl ); 77 } 78 if ( empty( $bytes ) ) { 79 return new \WP_Error( 'restai_image_empty', __( 'No image bytes received.', 'restai' ) ); 80 } 81 82 $filename = sanitize_file_name( 'restai-' . $post_id . '-' . time() . '.png' ); 83 $upload = wp_upload_bits( $filename, null, $bytes ); 84 if ( ! empty( $upload['error'] ) ) { 85 return new \WP_Error( 'restai_upload_failed', $upload['error'] ); 86 } 87 88 require_once ABSPATH . 'wp-admin/includes/file.php'; 89 require_once ABSPATH . 'wp-admin/includes/image.php'; 90 91 $attachment = array( 92 'post_mime_type' => 'image/png', 93 'post_title' => $post->post_title, 94 'post_content' => '', 95 'post_status' => 'inherit', 96 ); 97 $attach_id = wp_insert_attachment( $attachment, $upload['file'], $post_id ); 98 if ( is_wp_error( $attach_id ) ) { 99 return $attach_id; 100 } 101 $metadata = wp_generate_attachment_metadata( $attach_id, $upload['file'] ); 102 wp_update_attachment_metadata( $attach_id, $metadata ); 103 set_post_thumbnail( $post_id, $attach_id ); 104 105 return array( 106 'attachment_id' => $attach_id, 107 'url' => $upload['url'], 108 ); 109 } 110 111 /** 112 * After upload, request alt text from RESTai if auto-alt is enabled. 113 */ 114 public function maybe_alt_text_on_upload( $upload, $context ) { 115 $settings = get_option( 'restai_settings', array() ); 116 if ( empty( $settings['auto_alt_text'] ) ) { 117 return $upload; 118 } 119 if ( empty( $upload['type'] ) || strpos( $upload['type'], 'image/' ) !== 0 ) { 120 return $upload; 121 } 122 123 $plugin = Plugin::instance(); 124 $project_id = $plugin->provisioner->project_for( 'image_alt' ); 125 if ( $project_id <= 0 ) { 126 return $upload; 127 } 128 129 // We can't easily ship the binary in a one-shot question; use the 130 // public URL once the attachment is committed. Hook into add_attachment. 131 add_action( 'add_attachment', function ( $att_id ) use ( $project_id ) { 132 $src = wp_get_attachment_url( $att_id ); 133 if ( empty( $src ) ) { 134 return; 135 } 136 $prompt = 'Write alt text for the image at this URL: ' . $src; 137 $alt = ( Plugin::instance()->client )->ask( $project_id, $prompt ); 138 if ( ! is_wp_error( $alt ) && '' !== trim( (string) $alt ) ) { 139 update_post_meta( $att_id, '_wp_attachment_image_alt', wp_strip_all_tags( $alt ) ); 140 } 141 }, 20, 1 ); 142 143 return $upload; 144 } 145 146 /** 147 * Look up the first image generator the configured team has access to. 148 * Cached for a minute to avoid extra round-trips when generating multiple 149 * images in a session. 150 */ 151 private function pick_image_generator() { 152 $settings = get_option( 'restai_settings', array() ); 153 154 // Admin explicitly chose a generator — use it. 155 if ( ! empty( $settings['image_generator'] ) ) { 156 return (string) $settings['image_generator']; 157 } 158 159 $cached = wp_cache_get( 'restai_team_image_gen' ); 160 if ( false !== $cached ) { 161 return $cached ?: null; 162 } 163 $team_id = isset( $settings['team_id'] ) ? (int) $settings['team_id'] : 0; 164 if ( $team_id <= 0 ) { 165 return null; 166 } 167 $team = $this->client->get( 'teams/' . $team_id ); 168 if ( is_wp_error( $team ) ) { 169 return null; 170 } 171 $generators = isset( $team['image_generators'] ) ? (array) $team['image_generators'] : array(); 172 $pick = ! empty( $generators ) ? (string) $generators[0] : ''; 173 wp_cache_set( 'restai_team_image_gen', $pick, '', MINUTE_IN_SECONDS ); 174 return $pick !== '' ? $pick : null; 175 } 176 177 private function build_image_prompt( $post ) { 178 $snippet = wp_strip_all_tags( $post->post_content ); 179 $snippet = mb_substr( $snippet, 0, 400 ); 180 return sprintf( 181 'A clean, modern editorial illustration for a blog post titled "%s". %s. Photorealistic, well-composed, no text, no watermark.', 182 $post->post_title, 183 $snippet 184 ); 185 } 186 }