Files
2026-04-28 15:13:50 +02:00

284 lines
9.0 KiB
PHP

<?php
namespace AIOSEO\Plugin\Common\Ai;
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* AI Image handler for managing WordPress attachments.
*
* @since 4.8.8
*/
class Image {
/**
* The hook name for generating image metadata.
*
* @since 4.8.8
*
* @var string
*/
private $generateImageMetadataHook = 'aioseo_generate_ai_image_metadata';
/**
* Class constructor.
*
* @since 4.8.8
*/
public function __construct() {
add_action( $this->generateImageMetadataHook, [ $this, 'generateImageMetadata' ], 10, 2 );
}
/**
* Returns the data for Vue.
*
* @since 4.8.9
*
* @param int|null $objectId The object ID.
* @return array The data.
*/
public function getVueDataEdit( $objectId = null ) {
$objectId = $objectId ?: absint( get_the_ID() );
return [
'extend' => [
'imageBlockToolbar' => apply_filters( 'aioseo_ai_image_generator_extend_image_block_toolbar', true, $objectId ),
'imageBlockPlaceholder' => apply_filters( 'aioseo_ai_image_generator_extend_image_block_placeholder', true, $objectId ),
'featuredImageButton' => apply_filters( 'aioseo_ai_image_generator_extend_featured_image_button', true, $objectId ),
]
];
}
/**
* Creates a WordPress attachment from base64 image data.
*
* @since 4.8.8
*
* @param string $base64Data The base64 encoded image data.
* @param string $prompt The AI prompt used to generate the image.
* @param string $format The image format (jpg, png, etc.).
* @param int $postId The post ID to attach the image to.
* @param array $metadata Additional metadata (quality, style, aspectRatio, etc.).
* @return array The attachment data on success, false on failure.
* @throws \Exception If the attachment creation fails.
*/
public function createAttachment( $base64Data, $prompt, $format, $postId, $metadata = [] ) {
if ( empty( $base64Data ) || empty( $prompt ) || empty( $format ) || empty( $postId ) ) {
throw new \Exception( 'Invalid parameters.' );
}
$imageData = base64_decode( $base64Data );
if ( false === $imageData ) {
throw new \Exception( 'Failed to decode base64 image data.' );
}
if ( ! in_array( $format, aioseo()->helpers->getAllowedImageExtensions(), true ) ) {
throw new \Exception( 'Invalid image format.' );
}
$quality = trim( $metadata['quality'] ?? '' );
$style = trim( $metadata['style'] ?? '' );
$aspectRatio = trim( $metadata['aspectRatio'] ?? '' );
$filenameContext = substr( $prompt, 0, 25 ) . '-' . $quality . '-' . $style . '-' . $aspectRatio . '-' . date_i18n( 'Ymd-His' );
$filename = 'aioseo-ai-' . aioseo()->helpers->toLowerCase( sanitize_file_name( $filenameContext ) ) . '.' . $format;
$upload = wp_upload_bits( $filename, null, $imageData );
if ( ! empty( $upload['error'] ) ) {
throw new \Exception( esc_html( sprintf( 'Failed to upload image. Error: %s', $upload['error'] ) ) );
}
$attachmentData = [
'post_title' => substr( $prompt, 0, 60 ),
'post_content' => '',
'post_parent' => $postId,
'post_mime_type' => 'image/' . $format,
'guid' => $upload['url']
];
$attachmentId = wp_insert_attachment( $attachmentData, $upload['file'], $postId, true );
if ( is_wp_error( $attachmentId ) ) {
wp_delete_file( $upload['file'] );
throw new \Exception( esc_html( sprintf( 'Failed to insert attachment. Error: %s', $attachmentId->get_error_message() ) ) );
}
if ( ! $attachmentId ) {
wp_delete_file( $upload['file'] );
throw new \Exception( 'Failed to insert attachment. No attachment ID returned.' );
}
update_post_meta( $attachmentId, '_aioseo_ai_generated', 1 );
update_post_meta( $attachmentId, '_aioseo_ai_data', [
'prompt' => $prompt,
'quality' => $quality,
'style' => $style,
'aspectRatio' => $aspectRatio
] );
$parentImageId = ! empty( $metadata['parentImageId'] ) ? (int) $metadata['parentImageId'] : 0;
if ( $parentImageId ) {
update_post_meta( $attachmentId, '_aioseo_ai_parent', $parentImageId );
}
// Generate attachment metadata (thumbnails) asynchronously via Action Scheduler to avoid timeout.
aioseo()->actionScheduler->scheduleAsync( $this->generateImageMetadataHook, [ $attachmentId, $upload['file'] ] );
$src = wp_get_attachment_image_src( $attachmentId, 'full' );
list( $url, $width, $height ) = $src;
if ( ! $width || ! $height ) {
list( $width, $height ) = [ 0, 0 ];
$wpImageSize = wp_getimagesize( $upload['file'] );
if ( $wpImageSize ) {
list( $width, $height ) = $wpImageSize;
}
}
return [
'alt' => trim( wp_strip_all_tags( get_post_meta( $attachmentId, '_wp_attachment_image_alt', true ) ) ),
'aspectRatio' => $aspectRatio,
'format' => $format,
'height' => $height,
'id' => $attachmentId,
'parentImageId' => $parentImageId,
'prompt' => $prompt,
'quality' => $quality,
'style' => $style,
'url' => $url,
'width' => $width,
];
}
/**
* Gets AI-generated images for a specific post.
*
* @since 4.8.8
*
* @param int $postId The post ID.
* @return array Array of AI image data.
*/
public function getByPostId( $postId ) {
$images = [];
if ( empty( $postId ) ) {
return $images;
}
// Get all attachments for this post that are AI-generated.
$attachmentIds = get_posts( [
'post_type' => 'attachment',
'post_parent' => $postId,
'post_status' => 'inherit',
'posts_per_page' => -1,
'fields' => 'ids',
'meta_key' => '_aioseo_ai_generated', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => 1, // phpcs:ignore HM.Performance.SlowMetaQuery.slow_query_meta_value, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'meta_compare' => '='
] );
if ( empty( $attachmentIds ) ) {
return $images;
}
foreach ( $attachmentIds as $attachmentId ) {
$images[] = $this->buildImageData( $attachmentId );
}
return $images;
}
/**
* Deletes the images and updates the parent image id.
*
* @since 4.8.8
*
* @param array $ids The attachment IDs.
* @return void
*/
public function deleteImages( $ids ) {
foreach ( $ids as $id ) {
wp_delete_attachment( $id, true );
// Update all images post meta that have the parent image id set to the deleted image id.
$attachmentIds = get_posts( [
'post_type' => 'attachment',
'post_status' => 'inherit',
'posts_per_page' => -1,
'fields' => 'ids',
'meta_key' => '_aioseo_ai_parent', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $id, // phpcs:ignore HM.Performance.SlowMetaQuery.slow_query_meta_value, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'meta_compare' => '='
] );
foreach ( $attachmentIds as $attachmentId ) {
delete_post_meta( $attachmentId, '_aioseo_ai_parent' );
}
}
}
/**
* Generates attachment metadata (thumbnails) for AI-generated images.
* This is called asynchronously via Action Scheduler to avoid blocking the REST API response.
*
* @since 4.8.8
*
* @param int $attachmentId The attachment ID.
* @param string $file Path to the image file.
* @return void
*/
public function generateImageMetadata( $attachmentId, $file ) {
if ( ! $attachmentId || ! $file || ! file_exists( $file ) || ! get_post( $attachmentId ) ) {
return;
}
require_once ABSPATH . 'wp-admin/includes/image.php';
$metadata = wp_generate_attachment_metadata( $attachmentId, $file );
if ( $metadata ) {
wp_update_attachment_metadata( $attachmentId, $metadata );
}
}
/**
* Builds the image data for a specific attachment.
*
* @since 4.8.8
*
* @param int $attachmentId The attachment ID.
* @return array The image data.
*/
private function buildImageData( $attachmentId ) {
$aiData = get_post_meta( $attachmentId, '_aioseo_ai_data', true );
$aiParent = get_post_meta( $attachmentId, '_aioseo_ai_parent', true );
$mimeType = get_post_mime_type( $attachmentId );
$src = wp_get_attachment_image_src( $attachmentId, 'full' );
list( $url, $width, $height ) = $src;
if ( ! $width || ! $height ) {
list( $width, $height ) = [ 0, 0 ];
$wpImageSize = wp_getimagesize( get_attached_file( $attachmentId ) );
if ( $wpImageSize ) {
list( $width, $height ) = $wpImageSize;
}
}
return [
'alt' => trim( wp_strip_all_tags( get_post_meta( $attachmentId, '_wp_attachment_image_alt', true ) ) ),
'aspectRatio' => $aiData['aspectRatio'] ?? null,
'format' => $mimeType ? str_replace( 'image/', '', $mimeType ) : '',
'height' => $height,
'id' => $attachmentId,
'parentImageId' => ! empty( $aiParent ) ? (int) $aiParent : 0,
'prompt' => $aiData['prompt'] ?? null,
'quality' => $aiData['quality'] ?? null,
'style' => $aiData['style'] ?? null,
'url' => $url,
'width' => $width
];
}
}