Chapter 24
AI Image Block — Generate & Insert Images from the Editor
Editor-side image generation UI, inserting into Media Library / Image block
This chapter builds a block called “AI Image.” Type a description, click a button, and a real generated image appears in the post, already saved to the Media Library, not just a temporary preview.
The endpoint
Same registration pattern as every REST endpoint in this book:
function ai_course_ch24_register_rest_route() {
register_rest_route(
'ai-course/v1',
'/generate-image',
array(
'methods' => 'POST',
'callback' => 'ai_course_ch24_generate_image',
'permission_callback' => function () {
// Uploading files is the right capability to check here,
// this endpoint creates a real Media Library attachment.
return current_user_can( 'upload_files' );
},
'args' => array(
'description' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
}
add_action( 'rest_api_init', 'ai_course_ch24_register_rest_route' );
This callback has more work to do first
Every callback so far in this book has returned rest_ensure_response( $result ) straight from a generate_*_result() call. This one doesn’t, because it has more work to do first.
function ai_course_ch24_generate_image( WP_REST_Request $request ) {
$description = $request->get_param( 'description' );
$image_file = wp_ai_client_prompt( $description )
->generate_image();
if ( is_wp_error( $image_file ) ) {
return $image_file;
}
// covered below
}
Chapter 12’s image demo used getDataUri() directly in an <img> tag, and flagged the tradeoff plainly: reload the page, and the image regenerates from scratch, at a new cost, because nothing was ever saved. This chapter fixes exactly that, by saving the generated image as a real file before ever sending anything back to the browser.
Turning a data URI into a real attachment
// Data URIs look like "data:image/png;base64,....", pull the MIME
// type and the actual encoded bytes back out of that string.
$data_uri = $image_file->getDataUri();
if ( ! preg_match( '/^data:(image\/[a-zA-Z]+);base64,(.+)$/', $data_uri, $matches ) ) {
return new WP_Error( 'ai_course_bad_image', 'Could not read the generated image.' );
}
$mime_type = $matches[1];
$binary_data = base64_decode( $matches[2] );
$extension = str_replace( 'image/', '', $mime_type );
$filename = 'ai-generated-' . time() . '.' . $extension;
// Writes the actual file to the uploads folder.
$upload = wp_upload_bits( $filename, null, $binary_data );
if ( ! empty( $upload['error'] ) ) {
return new WP_Error( 'ai_course_upload_failed', $upload['error'] );
}
// Creates the Media Library entry pointing at that file.
$attachment_id = wp_insert_attachment(
array(
'post_mime_type' => $mime_type,
'post_title' => sanitize_text_field( $description ),
'post_status' => 'inherit',
),
$upload['file']
);
if ( is_wp_error( $attachment_id ) ) {
return $attachment_id;
}
// Generates thumbnails and other standard attachment metadata.
require_once ABSPATH . 'wp-admin/includes/image.php';
$attachment_data = wp_generate_attachment_metadata( $attachment_id, $upload['file'] );
wp_update_attachment_metadata( $attachment_id, $attachment_data );
return rest_ensure_response(
array(
'id' => $attachment_id,
'url' => $upload['url'],
)
);
This code picks up right where the AI call left off. It takes the data URI from getDataUri(), decodes the actual image bytes out of it, then runs three WordPress functions that turn those bytes into a permanent attachment, each one building on the last.
wp_upload_bits() writes the raw image data to your uploads folder as an actual file, the same folder any normal media upload goes to.
wp_insert_attachment() creates the Media Library entry pointing at that file, the actual database record that makes it show up in your Media Library.
wp_generate_attachment_metadata() generates the thumbnails and size variants WordPress normally creates for any uploaded image. Skip this step and the image would exist, but without the resized versions WordPress expects to find later.
None of these three are AI-specific. Any plugin that needs to sideload a file into the Media Library, from a URL, an API response, anywhere, uses this same sequence. The only AI-specific part of this whole function is the two lines at the very top that actually generate the image.
The response this endpoint sends back is deliberately small: just id and url. Not the full GenerativeAiResult structure every other chapter has returned.
By the time this function finishes, the AI’s response has already been consumed and turned into a permanent file. There’s nothing left to hand the browser except where to find it.
The block itself
Create chapter-24-image-block.php inside includes:
<?php
/**
* Chapter 24: AI Image Block, Generate & Insert Images from the Editor
* Usage: search for "AI Image" in the block inserter, inside the block
* editor.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // No direct access.
}
// Registers the REST endpoint the block's JavaScript will call.
function ai_course_ch24_register_rest_route() {
register_rest_route(
'ai-course/v1',
'/generate-image',
array(
'methods' => 'POST',
'callback' => 'ai_course_ch24_generate_image',
'permission_callback' => function () {
// Uploading files is the right capability to check here,
// this endpoint creates a real Media Library attachment.
return current_user_can( 'upload_files' );
},
'args' => array(
'description' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
}
add_action( 'rest_api_init', 'ai_course_ch24_register_rest_route' );
// Generates the image, then saves it into the Media Library as a real
// attachment, rather than returning a temporary data URI like Chapter 12 did.
function ai_course_ch24_generate_image( WP_REST_Request $request ) {
$description = $request->get_param( 'description' );
$image_file = wp_ai_client_prompt( $description )
->generate_image();
if ( is_wp_error( $image_file ) ) {
return $image_file;
}
// Data URIs look like "data:image/png;base64,....", pull the MIME
// type and the actual encoded bytes back out of that string.
$data_uri = $image_file->getDataUri();
if ( ! preg_match( '/^data:(image\/[a-zA-Z]+);base64,(.+)$/', $data_uri, $matches ) ) {
return new WP_Error( 'ai_course_bad_image', 'Could not read the generated image.' );
}
$mime_type = $matches[1];
$binary_data = base64_decode( $matches[2] );
$extension = str_replace( 'image/', '', $mime_type );
$filename = 'ai-generated-' . time() . '.' . $extension;
// Writes the actual file to the uploads folder.
$upload = wp_upload_bits( $filename, null, $binary_data );
if ( ! empty( $upload['error'] ) ) {
return new WP_Error( 'ai_course_upload_failed', $upload['error'] );
}
// Creates the Media Library entry pointing at that file.
$attachment_id = wp_insert_attachment(
array(
'post_mime_type' => $mime_type,
'post_title' => sanitize_text_field( $description ),
'post_status' => 'inherit',
),
$upload['file']
);
if ( is_wp_error( $attachment_id ) ) {
return $attachment_id;
}
// Generates thumbnails and other standard attachment metadata.
require_once ABSPATH . 'wp-admin/includes/image.php';
$attachment_data = wp_generate_attachment_metadata( $attachment_id, $upload['file'] );
wp_update_attachment_metadata( $attachment_id, $attachment_data );
return rest_ensure_response(
array(
'id' => $attachment_id,
'url' => $upload['url'],
)
);
}
// Loads the block's JavaScript in the block editor.
function ai_course_ch24_enqueue_block_editor_assets() {
wp_enqueue_script(
'ai-course-ch24-image-block',
plugins_url( 'js/chapter-24-image-block.js', __FILE__ ),
array( 'wp-blocks', 'wp-element', 'wp-components', 'wp-api-fetch' ),
'1.0',
true
);
}
add_action( 'enqueue_block_editor_assets', 'ai_course_ch24_enqueue_block_editor_assets' );
Then create chapter-24-image-block.js inside includes/js:
( function ( blocks, element, components, apiFetch ) {
const el = element.createElement;
const { useState } = element;
blocks.registerBlockType( 'ai-course/image', {
title: 'AI Image',
icon: 'format-image',
category: 'media',
// Matches the shape of the core Image block closely enough that
// this behaves like a normal image once it's generated.
attributes: {
url: { type: 'string', default: '' },
id: { type: 'number' },
alt: { type: 'string', default: '' }
},
edit( props ) {
const { attributes, setAttributes } = props;
const [ description, setDescription ] = useState( '' );
const [ generating, setGenerating ] = useState( false );
const generateImage = () => {
setGenerating( true );
apiFetch( {
path: '/ai-course/v1/generate-image',
method: 'POST',
data: { description }
} )
.then( ( data ) => {
// The response here is just { id, url }, not a full
// GenerativeAiResult, the endpoint already unwrapped
// it after saving the image to the Media Library.
setAttributes( { url: data.url, id: data.id, alt: description } );
setGenerating( false );
} )
.catch( () => {
setGenerating( false );
} );
};
// Once an image exists, just show it, the same as a normal
// Image block would.
if ( attributes.url ) {
return el( 'img', {
src: attributes.url,
alt: attributes.alt,
style: { maxWidth: '100%' }
} );
}
// Before that, show the description field and the button.
return el(
'div',
{},
el( components.TextControl, {
label: 'Describe the image',
value: description,
onChange: setDescription
} ),
el(
components.Button,
{
variant: 'secondary',
onClick: generateImage,
disabled: generating || ! description
},
generating ? 'Generating...' : 'Generate Image'
)
);
},
save( props ) {
const { attributes } = props;
return el( 'img', { src: attributes.url, alt: attributes.alt } );
}
} );
} )( window.wp.blocks, window.wp.element, window.wp.components, window.wp.apiFetch );
Add the “AI Image” block, type a description, and click “Generate Image.” The description field and button disappear once the image is ready, replaced by the image itself, the same way finishing a normal Image block replaces its own placeholder. Check your Media Library afterward, the image is really there, not just embedded in this one post.
Try it yourself
Add a way to regenerate the image with a different description once one already exists, an “Edit” button that clears attributes.url and shows the description field again. The REST endpoint doesn’t need to change at all, it already creates a new attachment every time it’s called.
For readers who already know JSX
The imports:
import { registerBlockType } from '@wordpress/blocks';
import { useState } from '@wordpress/element';
import { TextControl, Button } from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';
The two return statements inside edit():
if ( attributes.url ) {
return <img src={ attributes.url } alt={ attributes.alt } style={ { maxWidth: '100%' } } />;
}
return (
<div>
<TextControl
label="Describe the image"
value={ description }
onChange={ setDescription }
/>
<Button
variant="secondary"
onClick={ generateImage }
disabled={ generating || ! description }
>
{ generating ? 'Generating...' : 'Generate Image' }
</Button>
</div>
);
save()‘s return statement:
return <img src={ attributes.url } alt={ attributes.alt } />;
Everything else, the REST endpoint, the attachment creation, the apiFetch() call, stays exactly the same.