diff --git a/docs/experiments/image-generation.md b/docs/experiments/image-generation.md index 5fa603c1..38f07c50 100644 --- a/docs/experiments/image-generation.md +++ b/docs/experiments/image-generation.md @@ -11,13 +11,14 @@ The Image Generation experiment adds AI-powered image generation to the WordPres When enabled, the Image Generation experiment adds: - **Featured image panel:** A "Generate featured image" button that creates AI images from post content. The image is imported into the media library and set as the featured image. Images are marked with an "AI Generated Featured Image" label. -- **Block buttons:** A "Generate Image" inline and toolbar button on Image, Cover, Media & Text, and Gallery blocks. Clicking it opens a modal where you describe the image, generate it, preview it, and insert it into the block. +- **Block buttons:** A "Generate Image" inline and toolbar button on Image, Cover, Media & Text, and Gallery blocks. Clicking it opens a modal where you describe the image, generate it, preview it, optionally refine it (edit with follow-up prompts using the current image as reference), and insert it into the block. **Key Features:** - One-click featured image generation from post content - Inline image generation from supported blocks (Image, Cover, Media & Text, Gallery) -- Modal flow for inline generation: describe → generate → preview → keep, or start over +- Modal flow for inline generation: describe → generate → preview → keep, refine, or start over +- Image refinement: use the current generated image as a reference for follow-up prompts (models that support edits use it as context) - Step-by-step progress messages during generation (e.g. "Generating image prompt", "Generating image", "Generating alt text", "Importing image") - Automatically imports generated images into the media library - Sets generated images as featured images or inserts them into blocks @@ -32,7 +33,7 @@ The experiment consists of four main components: 1. **Experiment Class** (`WordPress\AI\Experiments\Image_Generation\Image_Generation`): Handles registration, asset enqueuing, featured image and inline block editor UI integration, and post meta registration 2. **Generate Image Prompt Ability** (`WordPress\AI\Abilities\Image\Generate_Image_Prompt`): Generates optimized image generation prompts from post content and context -3. **Generate Image Ability** (`WordPress\AI\Abilities\Image\Generate_Image`): Generates base64-encoded images from prompts using AI models +3. **Generate Image Ability** (`WordPress\AI\Abilities\Image\Generate_Image`): Generates base64-encoded images from prompts (and optionally from a reference image for refining) using AI models 4. **Import Image Ability** (`WordPress\AI\Abilities\Image\Import_Base64_Image`): Imports base64-encoded images into the WordPress media library All three abilities can be called directly via REST API, making them useful for automation, bulk processing, or custom integrations. @@ -79,8 +80,9 @@ All three abilities can be called directly via REST API, making them useful for - `editor.BlockEdit` with `withGenerateImageToolbarButton` (`ai/image-generation-inline-toolbar`): adds a "Generate Image" toolbar button in block controls - `editor.MediaUpload` with `withGenerateImageInlineButton` (`ai/image-generation-inline-button`): adds an inline "Generate Image" button in the MediaUpload placeholder area (uses `updateBlockAttributes` from the block editor store since MediaUpload does not receive `setAttributes`) - When either button is clicked, `GenerateImageInlineModal` opens with an idle state (prompt input). The user submits a prompt and the modal: - - Calls `runAbility( 'ai/image-generation', { prompt } )` - - Shows preview with "Keep", and "Start Over" actions + - Calls `runAbility( 'ai/image-generation', { prompt } )` (or `{ prompt, reference }` when refining) + - Shows preview with "Keep", "Refine", and "Start Over" actions + - "Refine" switches to refinment state: user enters a follow-up prompt; the current image is passed as `reference` so models supporting edits can use it as context - "Keep" calls `uploadImage()` (with optional alt text generation) and `insertIntoBlock()` to insert the imported image into the block - `insertIntoBlock()` sets block attributes based on block type: `core/image` (id, url, alt), `core/cover` (id, url, alt, dimRatio: 50, isDark: false, sizeSlug: 'full'), `core/media-text` (mediaId, mediaUrl, mediaType), `core/gallery` (appends a new inner `core/image` block) @@ -92,7 +94,8 @@ All three abilities can be called directly via REST API, making them useful for - Uses AI with a dedicated system instruction to generate an optimized image generation prompt - Returns a plain text prompt string suitable for image generation models - **Image Generation** (via `ai/image-generation`): - - Accepts `prompt` (string) as required input + - Accepts `prompt` (string) as required input and optional `reference` (base64-encoded string) for image editing + - If `reference` is provided, uses it as a reference image for editing - Uses AI image generation models (via `get_preferred_image_models()`) - Sets request timeout to 90 seconds for longer generation times - Returns an object `{ image: { data, provider_metadata, model_metadata } }` where `data` is the base64-encoded image @@ -137,11 +140,16 @@ array( array( 'type' => 'object', 'properties' => array( - 'prompt' => array( + 'prompt' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'description' => 'Prompt used to generate an image.', ), + 'reference' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'Optional base64-encoded image to use as a reference for editing.', + ), ), 'required' => array( 'prompt' ), ) @@ -730,7 +738,7 @@ You can extend the React components to add custom UI elements: 2. **Modify the inline generation modal:** - Edit `src/experiments/image-generation/components/GenerateImageInlineModal.tsx` - - The modal supports idle (prompt input), generating, and preview (keep/start over) states + - The modal supports idle (prompt input), generating, preview (keep/refine/start over) states - Customize the flow, UI copy, or add new actions 3. **Add or change supported blocks for inline generation:** @@ -788,7 +796,8 @@ add_filter( 'wp_generate_attachment_metadata', function( $metadata, $attachment_ - Add an Image, Cover, Media & Text, or Gallery block - Select the block and click the "Generate Image" toolbar or inline button - Enter a prompt (e.g. "A sunset over mountains") and click Generate - - Verify the preview appears with "Keep", and "Start Over" + - Verify the preview appears with "Keep", "Refine", and "Start Over" + - Click "Refine", add a follow-up prompt (e.g. "Add clouds"), and Generate; verify the image updates - Click "Keep" and verify the image is imported and inserted into the block - Test with each supported block type to ensure correct attribute mapping @@ -845,7 +854,7 @@ npm run test:php - The ability uses `get_preferred_image_models()` to determine which AI image models to use - Models are tried in order until one succeeds - Default models include Google's Gemini (e.g. gemini-3-pro-image-preview, gemini-2.5-flash-image), Imagen, and OpenAI's DALL-E 3 and GPT-image models -- All default models support image generation +- All default models support image generation; the Google models also support image editing (when a reference image is provided) - Request timeout is set to 90 seconds to accommodate longer image generation times ### Prompt Generation diff --git a/includes/Abilities/Image/Generate_Image.php b/includes/Abilities/Image/Generate_Image.php index a29b6878..ec630f41 100644 --- a/includes/Abilities/Image/Generate_Image.php +++ b/includes/Abilities/Image/Generate_Image.php @@ -12,6 +12,7 @@ use Throwable; use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; +use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Http\DTO\RequestOptions; @@ -35,11 +36,16 @@ protected function input_schema(): array { return array( 'type' => 'object', 'properties' => array( - 'prompt' => array( + 'prompt' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'description' => esc_html__( 'Prompt used to generate an image.', 'ai' ), ), + 'reference' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'Optional base64-encoded image to use as a reference image for edits.', 'ai' ), + ), ), 'required' => array( 'prompt' ), ); @@ -106,8 +112,10 @@ protected function output_schema(): array { * @since 0.2.0 */ protected function execute_callback( $input ) { + $reference_image = ! empty( $input['reference'] ) ? (string) $input['reference'] : null; + // Generate the image. - $result = $this->generate_image( $input['prompt'] ); + $result = $this->generate_image( $input['prompt'], $reference_image ); // If we have an error, return it. if ( is_wp_error( $result ) ) { @@ -162,18 +170,18 @@ protected function meta(): array { * @since 0.2.0 * * @param string $prompt The prompt to generate an image from. + * @param string|null $reference_image Optional base64-encoded image to use as a reference for edits. * @return array{data: string, provider_metadata: array, model_metadata: array}|\WP_Error The generated image data, or a WP_Error on failure. */ - protected function generate_image( string $prompt ) { // phpcs:ignore Generic.NamingConventions.ConstructorName.OldStyle - $request_options = new RequestOptions(); - $request_options->setTimeout( 90 ); + protected function generate_image( string $prompt, ?string $reference_image = null ) { // phpcs:ignore Generic.NamingConventions.ConstructorName.OldStyle + $prompt_builder = $this->get_prompt_builder( $prompt, $reference_image ); + + if ( is_wp_error( $prompt_builder ) ) { + return $prompt_builder; + } // Generate the image using the AI client. - $result = wp_ai_client_prompt( $prompt ) - ->using_request_options( $request_options ) - ->as_output_file_type( FileTypeEnum::inline() ) - ->using_model_preference( ...get_preferred_image_models() ) - ->generate_image_result(); + $result = $prompt_builder->generate_image_result(); if ( is_wp_error( $result ) ) { return $result; @@ -217,4 +225,37 @@ protected function generate_image( string $prompt ) { // phpcs:ignore Generic.Na return $data; } + + /** + * Gets a prompt builder for generating an image. + * + * @since x.x.x + * + * @param string $prompt The prompt to generate an image from. + * @param string|null $reference_image Optional base64-encoded image to use as a reference for edits. + * @return \WP_AI_Client_Prompt_Builder|\WP_Error The prompt builder, or a WP_Error on failure. + */ + private function get_prompt_builder( string $prompt, ?string $reference_image = null ) { + $request_options = new RequestOptions(); + $request_options->setTimeout( 90 ); + + $prompt_builder = wp_ai_client_prompt( $prompt ) + ->using_request_options( $request_options ) + ->as_output_file_type( FileTypeEnum::inline() ) + ->using_model_preference( ...get_preferred_image_models() ); + + if ( null !== $reference_image ) { + try { + $file = new File( $reference_image ); + $prompt_builder = $prompt_builder->with_file( $file ); + } catch ( Throwable $t ) { + return new WP_Error( + 'invalid_reference', + esc_html__( 'The reference image is not valid base64-encoded data.', 'ai' ) + ); + } + } + + return $prompt_builder; + } } diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index a7edf711..e894b031 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -40,8 +40,8 @@ public static function get_id(): string { */ protected function load_metadata(): array { return array( - 'label' => __( 'Image Generation', 'ai' ), - 'description' => __( 'Generate featured images and inline images using AI', 'ai' ), + 'label' => __( 'Image Generation and Editing', 'ai' ), + 'description' => __( 'Generate and edit featured images and inline images with AI', 'ai' ), 'category' => Experiment_Category::EDITOR, ); } diff --git a/src/experiments/image-generation/components/GenerateImageInlineModal.tsx b/src/experiments/image-generation/components/GenerateImageInlineModal.tsx index 2b6dd13c..b4adc88b 100644 --- a/src/experiments/image-generation/components/GenerateImageInlineModal.tsx +++ b/src/experiments/image-generation/components/GenerateImageInlineModal.tsx @@ -27,7 +27,7 @@ import type { const { aiImageGenerationData } = window as any; -type ModalState = 'idle' | 'generating' | 'preview'; +type ModalState = 'idle' | 'generating' | 'preview' | 'refining'; interface Props { blockName: string; @@ -39,7 +39,9 @@ interface Props { /** * Modal component for inline AI image generation in the block editor. * - * Supports a generate → preview → insert flow. + * Supports a generate → preview → refine → insert flow. When refining, + * the current preview image is sent as a reference to the generation + * ability so that models supporting image editing can use it as context. * * @param {Props} props The props for the component. * @param {string} props.blockName The name of the block. @@ -55,23 +57,37 @@ export function GenerateImageInlineModal( { }: Props ) { const [ state, setState ] = useState< ModalState >( 'idle' ); const [ prompt, setPrompt ] = useState( '' ); + const [ refinePrompt, setRefinePrompt ] = useState( '' ); const [ generatedData, setGeneratedData ] = useState< GeneratedImageData | null >( null ); + const [ originalImageSrc, setOriginalImageSrc ] = useState< string | null >( + null + ); const [ progress, setProgress ] = useState( '' ); const [ error, setError ] = useState< string | null >( null ); /** - * Runs the image generation ability with the given prompt. + * Runs the image generation ability with the given prompt and optional + * reference image for the refining flow. * - * @param {string} activePrompt The prompt to generate an image from. + * @param {string} activePrompt The prompt to generate an image from. + * @param {string|undefined} referenceImage Optional base64 image for refining. */ - async function generate( activePrompt: string ): Promise< void > { + async function generate( + activePrompt: string, + referenceImage?: string + ): Promise< void > { setError( null ); setState( 'generating' ); setProgress( __( 'Generating image…', 'ai' ) ); try { const input: ImageGenerationAbilityInput = { prompt: activePrompt }; + if ( referenceImage ) { + input.reference = referenceImage; + } else { + setOriginalImageSrc( null ); + } const response = ( await runAbility( 'ai/image-generation', @@ -84,7 +100,21 @@ export function GenerateImageInlineModal( { ); } - setGeneratedData( { ...response, prompt: activePrompt } ); + setGeneratedData( ( previousData ) => { + const previousPrompts = referenceImage + ? previousData?.prompts ?? [ previousData?.prompt ?? '' ] + : []; + const promptHistory = previousPrompts.filter( Boolean ); + const lastPrompt = promptHistory[ promptHistory.length - 1 ]; + return { + ...response, + prompt: activePrompt, + prompts: + lastPrompt === activePrompt + ? promptHistory + : [ ...promptHistory, activePrompt ], + }; + } ); setState( 'preview' ); } catch ( err: any ) { const message: string = @@ -94,7 +124,7 @@ export function GenerateImageInlineModal( { setError( message ); // Return to the previous state so the user can try again. - setState( 'idle' ); + setState( referenceImage ? 'refining' : 'idle' ); } } @@ -142,6 +172,11 @@ export function GenerateImageInlineModal( { const previewSrc = generatedData?.image?.data ? `data:image/png;base64,${ generatedData.image.data }` : null; + const hasRefinedResult = Boolean( + originalImageSrc && + generatedData?.prompts && + generatedData.prompts.length > 1 + ); return ( - { + { hasRefinedResult ? ( +
+
+

+ { __( 'Original image', 'ai' ) } +

+ { +
+
+

+ { __( 'Refined image', 'ai' ) } +

+ { +
+
+ ) : ( + { + ) }
+ @@ -229,6 +312,7 @@ export function GenerateImageInlineModal( { variant="tertiary" onClick={ () => { setGeneratedData( null ); + setOriginalImageSrc( null ); setState( 'idle' ); setError( null ); } } @@ -243,6 +327,52 @@ export function GenerateImageInlineModal( { ) }
) } + + { /* REFINING — show current image + follow-up prompt */ } + { state === 'refining' && previewSrc && ( +
+ { + +
+ + +
+ { error && ( + + { error } + + ) } +
+ ) }
); } diff --git a/src/experiments/image-generation/components/GenerateImageStandalone.tsx b/src/experiments/image-generation/components/GenerateImageStandalone.tsx index 17c1891f..86c3d63f 100644 --- a/src/experiments/image-generation/components/GenerateImageStandalone.tsx +++ b/src/experiments/image-generation/components/GenerateImageStandalone.tsx @@ -23,39 +23,52 @@ import type { const { aiImageGenerationData } = window as any; -type ModalState = 'idle' | 'generating' | 'preview' | 'success'; +type ModalState = 'idle' | 'generating' | 'preview' | 'refining' | 'success'; /** * Standalone component for AI image generation in the Media Library. * - * Supports a generate → preview → save flow. After saving, the user can - * generate another image or navigate to the saved attachment in the - * Media Library. + * Supports a generate → preview → refine → save flow. After saving, + * the user can generate another image or navigate to the saved attachment + * in the Media Library. */ export function GenerateImageStandalone() { const [ state, setState ] = useState< ModalState >( 'idle' ); const [ prompt, setPrompt ] = useState( '' ); - + const [ refinePrompt, setRefinePrompt ] = useState( '' ); const [ generatedData, setGeneratedData ] = useState< GeneratedImageData | null >( null ); const [ uploadedData, setUploadedData ] = useState< UploadedImage | null >( null ); + const [ originalImageSrc, setOriginalImageSrc ] = useState< string | null >( + null + ); const [ progress, setProgress ] = useState( '' ); const [ error, setError ] = useState< string | null >( null ); /** - * Runs the image generation ability with the given prompt. + * Runs the image generation ability with the given prompt and + * optional reference image for the refining flow. * - * @param {string} activePrompt The prompt to generate an image from. + * @param {string} activePrompt The prompt to generate an image from. + * @param {string|undefined} referenceImage Optional base64 image for refining. */ - async function generate( activePrompt: string ): Promise< void > { + async function generate( + activePrompt: string, + referenceImage?: string + ): Promise< void > { setError( null ); setState( 'generating' ); setProgress( __( 'Generating image…', 'ai' ) ); try { const input: ImageGenerationAbilityInput = { prompt: activePrompt }; + if ( referenceImage ) { + input.reference = referenceImage; + } else { + setOriginalImageSrc( null ); + } const response = ( await runAbility( 'ai/image-generation', @@ -68,7 +81,21 @@ export function GenerateImageStandalone() { ); } - setGeneratedData( { ...response, prompt: activePrompt } ); + setGeneratedData( ( previousData ) => { + const previousPrompts = referenceImage + ? previousData?.prompts ?? [ previousData?.prompt ?? '' ] + : []; + const promptHistory = previousPrompts.filter( Boolean ); + const lastPrompt = promptHistory[ promptHistory.length - 1 ]; + return { + ...response, + prompt: activePrompt, + prompts: + lastPrompt === activePrompt + ? promptHistory + : [ ...promptHistory, activePrompt ], + }; + } ); setState( 'preview' ); } catch ( err: any ) { const message: string = @@ -76,7 +103,7 @@ export function GenerateImageStandalone() { __( 'An error occurred during image generation.', 'ai' ); setError( message ); - setState( 'idle' ); + setState( referenceImage ? 'refining' : 'idle' ); } } @@ -109,6 +136,11 @@ export function GenerateImageStandalone() { const previewSrc = generatedData?.image?.data ? `data:image/png;base64,${ generatedData.image.data }` : null; + const hasRefinedResult = Boolean( + originalImageSrc && + generatedData?.prompts && + generatedData.prompts.length > 1 + ); return (
@@ -124,20 +156,8 @@ export function GenerateImageStandalone() { src={ uploadedData.url } alt={ uploadedData.title } className="ai-generate-image-standalone__preview-image" - style={ { - maxWidth: '400px', - display: 'block', - margin: '20px 0', - } } /> -
+
{ error && ( -
- - { error } - -
+ + { error } + ) }
) } @@ -211,83 +220,161 @@ export function GenerateImageStandalone() { src={ previewSrc } alt={ generatedData?.prompt ?? '' } className="ai-generate-image-standalone__preview-image" - style={ { - maxWidth: '400px', - opacity: 0.5, - display: 'block', - margin: '20px 0', - } } /> ) } -
+
{ progress }
{ error && ( -
- - { error } - -
+ + { error } + ) }
) } { state === 'preview' && previewSrc && (
- { -
+ { hasRefinedResult ? ( +
+
+

+ { __( 'Original image', 'ai' ) } +

+ { +
+
+

+ { __( 'Refined image', 'ai' ) } +

+ { +
+
+ ) : ( + { + ) } +
- + +
{ error && ( -
- - { error } - -
+ + { error } + + ) } +
+ ) } + + { state === 'refining' && previewSrc && ( +
+ { + +
+ + +
+ { error && ( + + { error } + ) }
) } diff --git a/src/experiments/image-generation/functions/generate-image.ts b/src/experiments/image-generation/functions/generate-image.ts index dec0431f..0c0deb28 100644 --- a/src/experiments/image-generation/functions/generate-image.ts +++ b/src/experiments/image-generation/functions/generate-image.ts @@ -63,8 +63,12 @@ export async function generateImage( return runAbility< GeneratedImageData >( 'ai/image-generation', params ) .then( ( response ) => { if ( response && typeof response === 'object' ) { - const result = response as { prompt?: string }; + const result = response as { + prompt?: string; + prompts?: string[]; + }; result.prompt = prompt; + result.prompts = [ prompt ]; return result as GeneratedImageData; } diff --git a/src/experiments/image-generation/functions/upload-image.ts b/src/experiments/image-generation/functions/upload-image.ts index 7791024b..c6fb0f96 100644 --- a/src/experiments/image-generation/functions/upload-image.ts +++ b/src/experiments/image-generation/functions/upload-image.ts @@ -28,10 +28,11 @@ const { aiImageGenerationData } = window as any; * @return {Promise} A promise that resolves to the uploaded image data. */ export async function uploadImage( - { image, prompt }: GeneratedImageData, + { image, prompt, prompts }: GeneratedImageData, options?: { onProgress?: ImageProgressCallback; altTextEnabled?: boolean } ): Promise< UploadedImage > { const onProgress = options?.onProgress; + const promptHistory = prompts?.length ? prompts : [ prompt ]; const params: ImageImportAbilityInput = { data: image.data, @@ -42,7 +43,7 @@ export async function uploadImage( image.provider_metadata.name, image.model_metadata.name, new Date().toLocaleDateString(), - prompt + promptHistory.join( ' | ' ) ), meta: [ { diff --git a/src/experiments/image-generation/index.scss b/src/experiments/image-generation/index.scss index af392767..e0bcb85e 100644 --- a/src/experiments/image-generation/index.scss +++ b/src/experiments/image-generation/index.scss @@ -1,3 +1,65 @@ +.ai-generate-image-standalone { + max-width: 950px; + + textarea { + max-width: 600px; + } + + .components-notice { + margin-top: 15px; + } + + &__idle { + .description { + margin-bottom: 10px; + } + } + + &__generating { + .ai-generate-image-standalone__preview-image { + opacity: 0.5; + } + + .ai-generate-image-standalone__spinner-row { + display: flex; + align-items: center; + gap: 10px; + } + } + + &__preview { + .ai-generate-image-standalone__comparison { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + } + + .ai-generate-image-standalone__comparison-label { + font-weight: 600; + margin: 10px 0 0; + } + } + + &__success { + .ai-generate-image-standalone__actions { + align-items: center; + } + } + + &__actions { + display: flex; + gap: 10px; + margin-top: 15px; + } + + &__preview-image { + max-width: 100%; + height: auto; + display: block; + margin: 15px 0; + } +} + .ai-generate-image-inline-modal { .components-notice { margin-top: 15px; @@ -13,6 +75,17 @@ height: auto; } + .ai-generate-image-inline-modal__comparison { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + } + + .ai-generate-image-inline-modal__comparison-label { + font-weight: 600; + margin: 0 0 8px; + } + .ai-generate-image-inline-modal__actions { display: flex; gap: 8px; @@ -35,4 +108,12 @@ } } } + + &__refining { + .ai-generate-image-inline-modal__preview-image { + margin-bottom: 15px; + max-width: 100%; + height: auto; + } + } } diff --git a/src/experiments/image-generation/inline.tsx b/src/experiments/image-generation/inline.tsx index c908dd09..ae572fda 100644 --- a/src/experiments/image-generation/inline.tsx +++ b/src/experiments/image-generation/inline.tsx @@ -4,7 +4,7 @@ * Registers block filters that add a "Generate Image" toolbar * button and inline button to supported core blocks. Clicking the * button opens a modal where the user can generate an image, preview - * it, and insert it into the block with a single click. + * it, refine it, and insert it into the block with a single click. */ /** diff --git a/src/experiments/image-generation/media-library.tsx b/src/experiments/image-generation/media-library.tsx index 560e05a5..ccf9f024 100644 --- a/src/experiments/image-generation/media-library.tsx +++ b/src/experiments/image-generation/media-library.tsx @@ -8,17 +8,19 @@ /** * WordPress dependencies */ -import { render } from '@wordpress/element'; +import { createRoot } from '@wordpress/element'; /** * Internal dependencies */ import { GenerateImageStandalone } from './components/GenerateImageStandalone'; +import './index.scss'; document.addEventListener( 'DOMContentLoaded', () => { - // Mount the Standalone app if we're on the new admin page. + // Mount the app if we're on the generate image page. const rootEl = document.getElementById( 'ai-image-generation-root' ); if ( rootEl ) { - render( , rootEl ); + const root = createRoot( rootEl ); + root.render( ); } } ); diff --git a/src/experiments/image-generation/types.ts b/src/experiments/image-generation/types.ts index 2e173573..c570ef2d 100644 --- a/src/experiments/image-generation/types.ts +++ b/src/experiments/image-generation/types.ts @@ -34,6 +34,7 @@ export interface GeneratedImage { export interface GeneratedImageData { image: GeneratedImage; prompt: string; + prompts?: string[]; } /** @@ -93,6 +94,7 @@ export interface ImageImportAbilityInput { */ export interface ImageGenerationAbilityInput { prompt: string; + reference?: string; [ key: string ]: string | undefined; } diff --git a/tests/Integration/Includes/Abilities/Image_GenerationTest.php b/tests/Integration/Includes/Abilities/Image_GenerationTest.php index 7ad398c8..9eced9c2 100644 --- a/tests/Integration/Includes/Abilities/Image_GenerationTest.php +++ b/tests/Integration/Includes/Abilities/Image_GenerationTest.php @@ -30,8 +30,8 @@ public static function get_id(): string { */ protected function load_metadata(): array { return array( - 'label' => 'Image Generation', - 'description' => 'Generates an image from a passed in prompt', + 'label' => 'Image Generation and Editing', + 'description' => 'Generate and edit featured images and inline images with AI', ); } @@ -129,6 +129,28 @@ public function test_input_schema_returns_expected_structure() { // Verify prompt property. $this->assertEquals( 'string', $schema['properties']['prompt']['type'], 'Prompt should be string type' ); $this->assertEquals( 'sanitize_text_field', $schema['properties']['prompt']['sanitize_callback'], 'Prompt should use sanitize_text_field' ); + + // Verify reference_image property. + $this->assertArrayHasKey( 'reference', $schema['properties'], 'Schema should have reference property' ); + $this->assertEquals( 'string', $schema['properties']['reference']['type'], 'reference should be string type' ); + $this->assertEquals( 'sanitize_text_field', $schema['properties']['reference']['sanitize_callback'], 'reference should use sanitize_text_field' ); + $this->assertNotContains( 'reference', $schema['required'], 'reference should not be required' ); + } + + /** + * Test that generate_image_edit() returns WP_Error for invalid base64. + * + * @since x.x.x + */ + public function test_generate_image_edit_with_invalid_base64(): void { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'generate_image' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability, 'a prompt', 'not-valid-base64!!!!' ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'invalid_reference', $result->get_error_code() ); } /** diff --git a/tests/Integration/Includes/Experiments/Image_Generation/Image_GenerationTest.php b/tests/Integration/Includes/Experiments/Image_Generation/Image_GenerationTest.php index 2027ab07..c0bdd963 100644 --- a/tests/Integration/Includes/Experiments/Image_Generation/Image_GenerationTest.php +++ b/tests/Integration/Includes/Experiments/Image_Generation/Image_GenerationTest.php @@ -69,7 +69,7 @@ public function test_experiment_registration() { $experiment = new Image_Generation(); $this->assertEquals( 'image-generation', $experiment->get_id() ); - $this->assertEquals( 'Image Generation', $experiment->get_label() ); + $this->assertEquals( 'Image Generation and Editing', $experiment->get_label() ); $this->assertEquals( Experiment_Category::EDITOR, $experiment->get_category() ); $this->assertTrue( $experiment->is_enabled() ); } diff --git a/tests/e2e/specs/experiments/image-generation.spec.js b/tests/e2e/specs/experiments/image-generation.spec.js index 5d7250d1..41ea1d0b 100644 --- a/tests/e2e/specs/experiments/image-generation.spec.js +++ b/tests/e2e/specs/experiments/image-generation.spec.js @@ -153,7 +153,7 @@ test.describe( 'Image Generation Experiment', () => { // Ensure the buttons we want are there. await expect( page.locator( '.ai-generate-image-inline-modal__actions button' ) - ).toHaveCount( 3 ); + ).toHaveCount( 4 ); let useImageButton = page.locator( '.ai-generate-image-inline-modal__actions button', @@ -163,6 +163,14 @@ test.describe( 'Image Generation Experiment', () => { ); await expect( useImageButton ).toBeVisible(); + const refineButton = page.locator( + '.ai-generate-image-inline-modal__actions button', + { + hasText: 'Refine Image', + } + ); + await expect( refineButton ).toBeVisible(); + const generateAnotherButton = page.locator( '.ai-generate-image-inline-modal__actions button', { @@ -213,7 +221,7 @@ test.describe( 'Image Generation Experiment', () => { // Ensure the buttons we want are there. await expect( page.locator( '.ai-generate-image-inline-modal__actions button' ) - ).toHaveCount( 3 ); + ).toHaveCount( 4 ); useImageButton = page.locator( '.ai-generate-image-inline-modal__actions button', @@ -316,7 +324,7 @@ test.describe( 'Image Generation Experiment', () => { // Ensure the buttons we want are there. await expect( page.locator( '.ai-generate-image-inline-modal__actions button' ) - ).toHaveCount( 3 ); + ).toHaveCount( 4 ); let useImageButton = page.locator( '.ai-generate-image-inline-modal__actions button', @@ -326,6 +334,14 @@ test.describe( 'Image Generation Experiment', () => { ); await expect( useImageButton ).toBeVisible(); + const refineButton = page.locator( + '.ai-generate-image-inline-modal__actions button', + { + hasText: 'Refine Image', + } + ); + await expect( refineButton ).toBeVisible(); + const generateAnotherButton = page.locator( '.ai-generate-image-inline-modal__actions button', { @@ -367,7 +383,7 @@ test.describe( 'Image Generation Experiment', () => { // Ensure the buttons we want are there. await expect( page.locator( '.ai-generate-image-inline-modal__actions button' ) - ).toHaveCount( 3 ); + ).toHaveCount( 4 ); useImageButton = page.locator( '.ai-generate-image-inline-modal__actions button', @@ -450,7 +466,7 @@ test.describe( 'Image Generation Experiment', () => { // Ensure the buttons we want are there. await expect( page.locator( '.ai-generate-image-standalone__actions button' ) - ).toHaveCount( 3 ); + ).toHaveCount( 5 ); let saveImageButton = page.locator( '.ai-generate-image-standalone__actions button', @@ -460,10 +476,18 @@ test.describe( 'Image Generation Experiment', () => { ); await expect( saveImageButton ).toBeVisible(); + const refineButton = page.locator( + '.ai-generate-image-standalone__actions button', + { + hasText: 'Refine Image', + } + ); + await expect( refineButton ).toBeVisible(); + const generateAnotherButton = page.locator( '.ai-generate-image-standalone__actions button', { - hasText: 'Regenerate', + hasText: 'Generate Another Image', } ); await expect( generateAnotherButton ).toBeVisible(); @@ -496,7 +520,7 @@ test.describe( 'Image Generation Experiment', () => { // Ensure the buttons we want are there. await expect( page.locator( '.ai-generate-image-standalone__actions button' ) - ).toHaveCount( 3 ); + ).toHaveCount( 5 ); saveImageButton = page.locator( '.ai-generate-image-standalone__actions button',