/ wordpress / restai / includes / class-restai-images.php
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  }