diff --git a/.wp-env.json b/.wp-env.json index fb5bc5d3..712a77db 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -1,7 +1,7 @@ { "$schema": "https://schemas.wp.org/trunk/wp-env.json", "core": "WordPress/WordPress#master", - "plugins": [ ".", "https://downloads.wordpress.org/plugin/ai-provider-for-openai.zip" ], + "plugins": [ ".", "https://downloads.wordpress.org/plugin/ai-provider-for-google.zip", "https://downloads.wordpress.org/plugin/ai-provider-for-openai.zip" ], "env": { "development": { "config": { diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index e894b031..d0a578b3 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -41,7 +41,7 @@ public static function get_id(): string { protected function load_metadata(): array { return array( 'label' => __( 'Image Generation and Editing', 'ai' ), - 'description' => __( 'Generate and edit featured images and inline images with AI', 'ai' ), + 'description' => __( 'Generate and edit images using 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 b4adc88b..7fe1d805 100644 --- a/src/experiments/image-generation/components/GenerateImageInlineModal.tsx +++ b/src/experiments/image-generation/components/GenerateImageInlineModal.tsx @@ -2,7 +2,7 @@ * WordPress dependencies */ import { useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { Modal, Button, @@ -10,7 +10,7 @@ import { Spinner, Notice, } from '@wordpress/components'; -import { image } from '@wordpress/icons'; +import { image, chevronLeft, chevronRight } from '@wordpress/icons'; /** * Internal dependencies @@ -19,6 +19,7 @@ import { runAbility } from '../../../utils/run-ability'; import { uploadImage } from '../functions/upload-image'; import { insertIntoBlock } from '../functions/insert-into-block'; import { openGalleryMediaLibraryWithImage } from '../functions/open-gallery-media-library'; +import { useImageHistory } from '../hooks/useImageHistory'; import type { GeneratedImageData, ImageGenerationAbilityInput, @@ -58,24 +59,33 @@ export function GenerateImageInlineModal( { 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 ); + const { + history, + historyIndex, + activeEntry, + canGoBack, + canGoForward, + addToHistory, + goBack, + goForward, + resetHistory, + } = useImageHistory(); + /** * 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|undefined} referenceImage Optional base64 image for refining. + * @param {string} activePrompt The prompt to generate an image from. + * @param {string|undefined} referenceImage Optional base64 image for refining. + * @param {number|undefined} refHistoryIndex History index of the entry whose image is the reference. */ async function generate( activePrompt: string, - referenceImage?: string + referenceImage?: string, + refHistoryIndex?: number ): Promise< void > { setError( null ); setState( 'generating' ); @@ -85,8 +95,6 @@ export function GenerateImageInlineModal( { const input: ImageGenerationAbilityInput = { prompt: activePrompt }; if ( referenceImage ) { input.reference = referenceImage; - } else { - setOriginalImageSrc( null ); } const response = ( await runAbility( @@ -100,21 +108,24 @@ export function GenerateImageInlineModal( { ); } - 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 ], - }; - } ); + const prevData = activeEntry?.generatedData; + const previousPrompts = referenceImage + ? prevData?.prompts ?? + ( prevData?.prompt ? [ prevData.prompt ] : [] ) + : []; + const promptHistory = previousPrompts.filter( Boolean ); + const lastPrompt = promptHistory[ promptHistory.length - 1 ]; + const prompts = + lastPrompt === activePrompt + ? promptHistory + : [ ...promptHistory, activePrompt ]; + + addToHistory( + { ...response, prompt: activePrompt, prompts }, + referenceImage, + !! referenceImage, + refHistoryIndex + ); setState( 'preview' ); } catch ( err: any ) { const message: string = @@ -132,7 +143,7 @@ export function GenerateImageInlineModal( { * Uploads the generated image and inserts it into the block. */ async function handleUseImage(): Promise< void > { - if ( ! generatedData ) { + if ( ! activeEntry ) { return; } @@ -141,10 +152,13 @@ export function GenerateImageInlineModal( { setProgress( __( 'Uploading image…', 'ai' ) ); try { - const uploaded: UploadedImage = await uploadImage( generatedData, { - onProgress: setProgress, - altTextEnabled: aiImageGenerationData?.altTextEnabled, - } ); + const uploaded: UploadedImage = await uploadImage( + activeEntry.generatedData, + { + onProgress: setProgress, + altTextEnabled: aiImageGenerationData?.altTextEnabled, + } + ); if ( blockName === 'core/gallery' ) { const openedMediaLibrary = openGalleryMediaLibraryWithImage( @@ -169,13 +183,21 @@ export function GenerateImageInlineModal( { } } - const previewSrc = generatedData?.image?.data - ? `data:image/png;base64,${ generatedData.image.data }` + const previewSrc = activeEntry?.generatedData?.image?.data + ? `data:image/png;base64,${ activeEntry.generatedData.image.data }` : null; - const hasRefinedResult = Boolean( - originalImageSrc && - generatedData?.prompts && - generatedData.prompts.length > 1 + + // Show comparison only when the active entry was a refinement. + const showComparison = Boolean( activeEntry?.referenceSrc ); + const comparisonLeftLabel = sprintf( + /* translators: %d: version number */ + __( 'Version %d', 'ai' ), + ( activeEntry?.referenceHistoryIndex ?? 0 ) + 1 + ); + const comparisonRightLabel = sprintf( + /* translators: %d: version number */ + __( 'Version %d', 'ai' ), + historyIndex + 1 ); return ( @@ -226,7 +248,7 @@ export function GenerateImageInlineModal( { { previewSrc && ( { ) } @@ -245,38 +267,70 @@ export function GenerateImageInlineModal( { { /* PREVIEW — show the generated image with action buttons */ } { state === 'preview' && previewSrc && (
- { hasRefinedResult ? ( -
-
-

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

- { -
-
-

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

+
+
- ) : ( - { +
+ { history.length > 1 && ( +

+ { sprintf( + /* translators: 1: current position, 2: total count */ + __( '%1$d / %2$d', 'ai' ), + historyIndex + 1, + history.length + ) } +

) }
- -
-
- ) } - { state === 'idle' && (

@@ -218,7 +205,7 @@ export function GenerateImageStandalone() { { previewSrc && ( { ) } @@ -236,38 +223,90 @@ export function GenerateImageStandalone() { { state === 'preview' && previewSrc && (

- { hasRefinedResult ? ( -
-
-

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

- { -
-
-

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

+ { lastSaved && ( + + setSavedUploads( ( prev ) => + prev.filter( + ( u ) => u.id !== lastSaved.id + ) + ) + } + > + { __( + 'Image successfully added to the Media Library.', + 'ai' + ) }{ ' ' } + + { __( 'View in Media Library', 'ai' ) } + + + ) } +
+
- ) : ( - { +
+ { history.length > 1 && ( +

+ { sprintf( + /* translators: 1: current position, 2: total count */ + __( '%1$d / %2$d', 'ai' ), + historyIndex + 1, + history.length + ) } +

) }
+ { PRESETS.map( ( preset ) => ( + + ) ) } +
+ { showPrompt && ( + <> + +
+ +
+ + ) } + { error && ( + + { error } + + ) } +
+ ) } + + { state === 'generating' && ( +
+ { previewSrc && ( + { + ) } +
+ + { __( 'Generating image…', 'ai' ) } +
+
+ ) } + + { state === 'preview' && previewSrc && ( +
+ { savedUpload && ( + setSavedUpload( null ) } + > + { __( 'Image saved!', 'ai' ) }{ ' ' } + + { __( 'View new image', 'ai' ) } + + + ) } +
+
+ { history.length > 1 && ( +

+ { sprintf( + /* translators: 1: current position, 2: total count */ + __( '%1$d / %2$d', 'ai' ), + historyIndex + 1, + history.length + ) } +

+ ) } +
+ + + + +
+ { error && ( + + { error } + + ) } +
+ ) } + + { state === 'refining' && previewSrc && ( +
+ { +
+ { PRESETS.map( ( preset ) => ( + + ) ) } +
+ +
+ + +
+ { error && ( + + { error } + + ) } +
+ ) } + + { state === 'saving' && ( +
+
+ + { __( 'Saving to Media Library…', 'ai' ) } +
+
+ ) } +
+ ); +} diff --git a/src/experiments/image-generation/hooks/useImageHistory.ts b/src/experiments/image-generation/hooks/useImageHistory.ts new file mode 100644 index 00000000..6cfa5589 --- /dev/null +++ b/src/experiments/image-generation/hooks/useImageHistory.ts @@ -0,0 +1,124 @@ +/** + * Custom hook for managing image generation history. + */ + +/** + * WordPress dependencies + */ +import { useReducer, useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { GeneratedImageData, HistoryEntry } from '../types'; + +interface UseImageHistoryReturn { + history: HistoryEntry[]; + historyIndex: number; + activeEntry: HistoryEntry | null; + canGoBack: boolean; + canGoForward: boolean; + addToHistory: ( + data: GeneratedImageData, + referenceSrc?: string, + isRefinement?: boolean, + referenceHistoryIndex?: number + ) => void; + goBack: () => void; + goForward: () => void; + resetHistory: () => void; +} + +type HistoryState = { + history: HistoryEntry[]; + historyIndex: number; +}; + +type HistoryAction = + | { type: 'ADD'; entry: HistoryEntry } + | { type: 'GO_BACK' } + | { type: 'GO_FORWARD' } + | { type: 'RESET' }; + +function historyReducer( + state: HistoryState, + action: HistoryAction +): HistoryState { + switch ( action.type ) { + case 'ADD': + return { + history: [ ...state.history, action.entry ], + historyIndex: state.history.length, + }; + case 'GO_BACK': + return { + ...state, + historyIndex: Math.max( 0, state.historyIndex - 1 ), + }; + case 'GO_FORWARD': + return { + ...state, + historyIndex: Math.min( + state.history.length - 1, + state.historyIndex + 1 + ), + }; + case 'RESET': + return { history: [], historyIndex: -1 }; + } +} + +/** + * Manages image generation history with navigation support. + * + * @return {UseImageHistoryReturn} History state and navigation helpers. + */ +export function useImageHistory(): UseImageHistoryReturn { + const [ { history, historyIndex }, dispatch ] = useReducer( + historyReducer, + { history: [], historyIndex: -1 } + ); + + const activeEntry = + historyIndex >= 0 ? history[ historyIndex ] ?? null : null; + const canGoBack = historyIndex > 0; + const canGoForward = historyIndex < history.length - 1; + + const addToHistory = useCallback( + ( + data: GeneratedImageData, + referenceSrc?: string, + isRefinement: boolean = false, + referenceHistoryIndex?: number + ) => { + const entry: HistoryEntry = { generatedData: data, isRefinement }; + if ( referenceSrc !== undefined ) { + entry.referenceSrc = referenceSrc; + } + if ( referenceHistoryIndex !== undefined ) { + entry.referenceHistoryIndex = referenceHistoryIndex; + } + dispatch( { type: 'ADD', entry } ); + }, + [] + ); + + const goBack = useCallback( () => dispatch( { type: 'GO_BACK' } ), [] ); + const goForward = useCallback( + () => dispatch( { type: 'GO_FORWARD' } ), + [] + ); + const resetHistory = useCallback( () => dispatch( { type: 'RESET' } ), [] ); + + return { + history, + historyIndex, + activeEntry, + canGoBack, + canGoForward, + addToHistory, + goBack, + goForward, + resetHistory, + }; +} diff --git a/src/experiments/image-generation/index.scss b/src/experiments/image-generation/index.scss index e0bcb85e..21ec806e 100644 --- a/src/experiments/image-generation/index.scss +++ b/src/experiments/image-generation/index.scss @@ -1,3 +1,37 @@ +// Shared history navigation wrapper — three-column grid: arrow | content | arrow. +.ai-image-history-nav { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 4px; + + &__arrow { + align-self: center; + + &:disabled { + opacity: 0.3; + } + } + + &__content { + min-width: 0; + } + + img.is-active { + outline: 2px solid #3858e9; + outline-offset: 3px; + border-radius: 2px; + } + + &__counter { + text-align: center; + font-size: 12px; + color: #757575; + font-variant-numeric: tabular-nums; + margin: 4px 0 0; + } +} + .ai-generate-image-standalone { max-width: 950px; @@ -60,6 +94,84 @@ } } +.ai-media-library-editor-btn-slot { + + .ai-media-library-editor__toggle-btn { + &:before { + content: '\f327'; + } + + &.active { + background: #f0f0f1; + border-color: #8c8f94; + box-shadow: inset 0 2px 5px -3px rgba(0, 0, 0, 0.5); + } + } +} + +.ai-media-library-editor { + padding-top: 0 !important; + max-width: 1050px; + min-width: 600px; + + .components-notice { + margin-top: 10px; + } + + &__presets { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 20px; + } + + &__actions { + display: flex; + gap: 10px; + margin-top: 15px; + } + + &__spinner-row { + display: flex; + align-items: center; + gap: 10px; + + span { + color: #757575; + } + } + + &__comparison { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + } + + &__comparison-label { + font-weight: 600; + margin: 0 0 8px; + } + + &__preview-image { + max-width: 100%; + height: auto; + display: block; + margin-bottom: 15px; + } + + &__generating { + .ai-media-library-editor__preview-image { + opacity: 0.5; + } + } + + &__refining { + .ai-media-library-editor__presets { + margin-top: 12px; + } + } +} + .ai-generate-image-inline-modal { .components-notice { margin-top: 15px; diff --git a/src/experiments/image-generation/index.ts b/src/experiments/image-generation/index.ts index 320ac433..7fe2ea6f 100644 --- a/src/experiments/image-generation/index.ts +++ b/src/experiments/image-generation/index.ts @@ -7,4 +7,5 @@ */ import './featured-image'; import './media-library'; +import './media-library-editor'; import './inline'; diff --git a/src/experiments/image-generation/media-library-editor.ts b/src/experiments/image-generation/media-library-editor.ts new file mode 100644 index 00000000..d2b25c15 --- /dev/null +++ b/src/experiments/image-generation/media-library-editor.ts @@ -0,0 +1,182 @@ +/** + * Media Library Image Editor — AI panel injection. + * + * Uses a MutationObserver to detect when the WordPress image editor opens then + * mounts the MediaLibraryImageEditor React component in a panel between the + * toolbar row and the image canvas. Unmounts and removes the panel when the + * editor closes. + */ + +/** + * WordPress dependencies + */ +import { createRoot, createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { MediaLibraryImageEditor } from './components/MediaLibraryImageEditor'; + +let currentRoot: ReturnType< typeof createRoot > | null = null; +let currentContainer: HTMLElement | null = null; + +/** + * Extracts the attachment post ID from the imgedit-panel element ID. + * + * The native WordPress image editor injects `
`. + * + * @param {Element} imgeditWrap The `.imgedit-wrap` element. + * @return {number|null} The post ID, or null if not found. + */ +function getPostIdFromWrap( imgeditWrap: Element ): number | null { + const panel = imgeditWrap.querySelector( '[id^="imgedit-panel-"]' ); + + if ( ! panel ) { + return null; + } + + const match = panel.id.match( /imgedit-panel-(\d+)/ ); + return match?.[ 1 ] ? parseInt( match[ 1 ], 10 ) : null; +} + +/** + * Retrieves the full-size URL for an attachment using the wp.media API. + * + * @param {number} postId The attachment post ID. + * @return {Promise} The attachment URL, or null if unavailable. + */ +async function getAttachmentUrl( postId: number ): Promise< string | null > { + const wp = ( window as any ).wp; + + if ( ! wp?.media?.attachment ) { + return null; + } + + const attachment = wp.media.attachment( postId ); + + // The URL may already be cached; fetch only if needed. + if ( ! attachment.get( 'url' ) ) { + await new Promise< void >( ( resolve ) => { + attachment.fetch( { success: resolve, error: resolve } ); + } ); + } + + return attachment.get( 'url' ) ?? null; +} + +/** + * Mounts the AI editing UI into the WordPress image editor. + * + * Appends a button slot to the `.imgedit-menu` toolbar and inserts a panel + * container between the toolbar row and the image canvas row. + * + * @param {Element} imgeditWrap The `.imgedit-wrap` element that just appeared. + */ +async function mountPanel( imgeditWrap: Element ): Promise< void > { + // Unmount any existing panel first. + unmountPanel(); + + const postId = getPostIdFromWrap( imgeditWrap ); + if ( ! postId ) { + return; + } + + const attachmentUrl = await getAttachmentUrl( postId ); + if ( ! attachmentUrl ) { + return; + } + + // Query the image canvas + settings row. + const imagePanel = imgeditWrap.querySelector< HTMLElement >( + '.imgedit-panel-content:not(.imgedit-panel-tools)' + ); + + // Inject panel container between the toolbar row and the image canvas row. + const toolbarRow = imgeditWrap.querySelector( + '.imgedit-panel-content.imgedit-panel-tools' + ); + + currentContainer = document.createElement( 'div' ); + currentContainer.className = 'ai-media-library-editor-root'; + + if ( toolbarRow ) { + toolbarRow.insertAdjacentElement( 'afterend', currentContainer ); + } else { + // Fallback: append after imgedit-wrap. + imgeditWrap.parentElement?.insertBefore( + currentContainer, + imgeditWrap.nextSibling + ); + } + + const props = { + postId, + attachmentUrl, + ...( imagePanel ? { imagePanel } : {} ), + }; + + currentRoot = createRoot( currentContainer ); + currentRoot.render( createElement( MediaLibraryImageEditor, props ) ); +} + +/** + * Unmounts the AI panel, removes the panel container, and removes the + * button slot from the toolbar. + */ +function unmountPanel(): void { + if ( currentRoot ) { + currentRoot.unmount(); + currentRoot = null; + } + + if ( currentContainer ) { + currentContainer.remove(); + currentContainer = null; + } +} + +/** + * Starts observing the document body for the image editor appearing/disappearing. + */ +function observeImageEditor(): void { + const observer = new MutationObserver( ( mutations ) => { + for ( const mutation of mutations ) { + // Check added nodes for .imgedit-wrap. + for ( const node of Array.from( mutation.addedNodes ) ) { + if ( ! ( node instanceof Element ) ) { + continue; + } + + // The node itself may be .imgedit-wrap, or it may contain one. + const wrap = node.classList.contains( 'imgedit-wrap' ) + ? node + : node.querySelector( '.imgedit-wrap' ); + + if ( wrap ) { + mountPanel( wrap ); + return; + } + } + + // Check removed nodes — unmount if .imgedit-wrap was removed. + for ( const node of Array.from( mutation.removedNodes ) ) { + if ( ! ( node instanceof Element ) ) { + continue; + } + + const hadWrap = + node.classList.contains( 'imgedit-wrap' ) || + node.querySelector( '.imgedit-wrap' ); + + if ( hadWrap && currentRoot ) { + unmountPanel(); + return; + } + } + } + } ); + + observer.observe( document.body, { childList: true, subtree: true } ); +} + +document.addEventListener( 'DOMContentLoaded', observeImageEditor ); diff --git a/src/experiments/image-generation/types.ts b/src/experiments/image-generation/types.ts index c570ef2d..b0600675 100644 --- a/src/experiments/image-generation/types.ts +++ b/src/experiments/image-generation/types.ts @@ -121,3 +121,16 @@ export interface GetPostDetailsAbilityInput { * Callback type for image generation progress messages. */ export type ImageProgressCallback = ( message: string ) => void; + +/** + * A single entry in the image generation history. + */ +export interface HistoryEntry { + generatedData: GeneratedImageData; + /** Data URI used as reference input; undefined for fresh text-prompt generations. */ + referenceSrc?: string; + /** History index of the entry whose image was used as the reference. */ + referenceHistoryIndex?: number; + /** True when this entry was generated by refining a previously generated image. */ + isRefinement: boolean; +} diff --git a/src/utils/image.ts b/src/utils/image.ts new file mode 100644 index 00000000..6a8918db --- /dev/null +++ b/src/utils/image.ts @@ -0,0 +1,84 @@ +/** + * Collection of image utilities. + */ + +/** + * Fetches an image from a URL and returns it as a base64 data URI + * (e.g. `data:image/jpeg;base64,...`). + * + * @param {string} url The URL of the image to convert. + * @return {Promise} Base64 data URI string. + */ +export async function urlToBase64( url: string ): Promise< string > { + const response = await fetch( url ); + const blob = await response.blob(); + const mimeType = blob.type || 'image/jpeg'; + const buffer = await blob.arrayBuffer(); + const bytes = new Uint8Array( buffer ); + let binary = ''; + + for ( let i = 0; i < bytes.byteLength; i++ ) { + binary += String.fromCharCode( bytes[ i ] as number ); + } + + return `data:${ mimeType };base64,${ btoa( binary ) }`; +} + +/** + * Loads an image URL onto a larger canvas (with transparent borders) and + * returns the result as a PNG data URI. The original image is centered on the + * new canvas. The transparent borders act as an implicit mask so image-editing + * models know which areas to fill. + * + * @param {string} url URL of the source image. Must be CORS-accessible. + * @param {number} scale Factor by which to multiply each dimension (default 1.5). + * @return {Promise} PNG data URI of the expanded canvas. + */ +export async function prepareExpandCanvas( + url: string, + scale: number = 1.5 +): Promise< string > { + const MAX_DIMENSION = 4096; + + const img = await new Promise< HTMLImageElement >( ( resolve, reject ) => { + const el = new Image(); + el.crossOrigin = 'anonymous'; + el.onload = () => resolve( el ); + el.onerror = () => + reject( new Error( `Failed to load image: ${ url }` ) ); + el.src = url; + } ); + + const srcW = img.naturalWidth; + const srcH = img.naturalHeight; + + const rawW = Math.round( srcW * scale ); + const rawH = Math.round( srcH * scale ); + + // Cap both dimensions at MAX_DIMENSION while preserving aspect ratio. + const capScale = Math.min( 1, MAX_DIMENSION / rawW, MAX_DIMENSION / rawH ); + const canvasW = Math.round( rawW * capScale ); + const canvasH = Math.round( rawH * capScale ); + + // Scale the original image to fit the new canvas proportionally. + const imgScale = capScale * scale; // combined scaling from src to canvas slot + const slotW = Math.round( srcW * imgScale ); + const slotH = Math.round( srcH * imgScale ); + + const offsetX = Math.round( ( canvasW - slotW ) / 2 ); + const offsetY = Math.round( ( canvasH - slotH ) / 2 ); + + const canvas = document.createElement( 'canvas' ); + canvas.width = canvasW; + canvas.height = canvasH; + + const ctx = canvas.getContext( '2d' ); + if ( ! ctx ) { + throw new Error( 'Could not get 2D canvas context.' ); + } + + // Leave canvas transparent (default) — transparent borders are the mask. + ctx.drawImage( img, offsetX, offsetY, slotW, slotH ); + + return canvas.toDataURL( 'image/png' ); +} diff --git a/tests/Integration/Includes/Abilities/Image_GenerationTest.php b/tests/Integration/Includes/Abilities/Image_GenerationTest.php index 9eced9c2..99dea7ee 100644 --- a/tests/Integration/Includes/Abilities/Image_GenerationTest.php +++ b/tests/Integration/Includes/Abilities/Image_GenerationTest.php @@ -31,7 +31,7 @@ public static function get_id(): string { protected function load_metadata(): array { return array( 'label' => 'Image Generation and Editing', - 'description' => 'Generate and edit featured images and inline images with AI', + 'description' => 'Generate and edit images using AI', ); } diff --git a/tests/bin/add-mocking-plugin.js b/tests/bin/add-mocking-plugin.js index 4ea43610..9f6c37d3 100644 --- a/tests/bin/add-mocking-plugin.js +++ b/tests/bin/add-mocking-plugin.js @@ -12,6 +12,7 @@ const config = fs.existsSync( path ) ? require( path ) : {}; config.plugins = [ '.', + 'https://downloads.wordpress.org/plugin/ai-provider-for-google.zip', 'https://downloads.wordpress.org/plugin/ai-provider-for-openai.zip', './tests/e2e-request-mocking', ]; diff --git a/tests/e2e-request-mocking/e2e-request-mocking.php b/tests/e2e-request-mocking/e2e-request-mocking.php index c4a78aa0..f6009ab9 100644 --- a/tests/e2e-request-mocking/e2e-request-mocking.php +++ b/tests/e2e-request-mocking/e2e-request-mocking.php @@ -39,6 +39,29 @@ function ai_e2e_test_request_mocking( $preempt, $parsed_args, $url ) { $response = file_get_contents( __DIR__ . '/responses/OpenAI/models.json' ); } + // Mock the Google models API response. + if ( str_contains( $url, 'https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000' ) ) { + // Handle invalid API key. + if ( + isset( $parsed_args['headers']['X-Goog-Api-Key'] ) && + str_contains( $parsed_args['headers']['X-Goog-Api-Key'], 'invalid-api-key' ) + ) { + return $preempt; + } + + $response = file_get_contents( __DIR__ . '/responses/Google/models.json' ); + } + + // Mock the Google Imagen API response. + if ( str_contains( $url, 'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict' ) ) { + $response = file_get_contents( __DIR__ . '/responses/Google/imagen.json' ); + } + + // Mock the Google Gemini image API response. + if ( str_contains( $url, 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image-preview:generateContent' ) ) { + $response = file_get_contents( __DIR__ . '/responses/Google/gemini-image.json' ); + } + // Mock the OpenAI responses API response. if ( str_contains( $url, 'https://api.openai.com/v1/responses' ) ) { $body = $parsed_args['body'] ?? ''; diff --git a/tests/e2e-request-mocking/responses/Google/gemini-image.json b/tests/e2e-request-mocking/responses/Google/gemini-image.json new file mode 100644 index 00000000..2ca26cb4 --- /dev/null +++ b/tests/e2e-request-mocking/responses/Google/gemini-image.json @@ -0,0 +1,38 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "inlineData": { + "mimeType": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=" + } + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ], + "usageMetadata": { + "promptTokenCount": 6, + "candidatesTokenCount": 1259, + "totalTokenCount": 1265, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 6 + } + ], + "candidatesTokensDetails": [ + { + "modality": "IMAGE", + "tokenCount": 1120 + } + ] + }, + "modelVersion": "gemini-3.1-flash-image-preview", + "responseId": "mzSzaZL3LO2r_PUP2bfGkAE" +} diff --git a/tests/e2e-request-mocking/responses/Google/imagen.json b/tests/e2e-request-mocking/responses/Google/imagen.json new file mode 100644 index 00000000..e33abbe1 --- /dev/null +++ b/tests/e2e-request-mocking/responses/Google/imagen.json @@ -0,0 +1,8 @@ +{ + "predictions": [ + { + "mimeType": "image/png", + "bytesBase64Encoded": "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=" + } + ] +} diff --git a/tests/e2e-request-mocking/responses/Google/models.json b/tests/e2e-request-mocking/responses/Google/models.json new file mode 100644 index 00000000..95dc73f1 --- /dev/null +++ b/tests/e2e-request-mocking/responses/Google/models.json @@ -0,0 +1,724 @@ +{ + "models": [ + { + "name": "models/gemini-2.5-flash", + "version": "001", + "displayName": "Gemini 2.5 Flash", + "description": "Stable version of Gemini 2.5 Flash, our mid-size multimodal model that supports up to 1 million tokens, released in June of 2025.", + "inputTokenLimit": 1048576, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-2.5-pro", + "version": "2.5", + "displayName": "Gemini 2.5 Pro", + "description": "Stable release (June 17th, 2025) of Gemini 2.5 Pro", + "inputTokenLimit": 1048576, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-2.0-flash", + "version": "2.0", + "displayName": "Gemini 2.0 Flash", + "description": "Gemini 2.0 Flash", + "inputTokenLimit": 1048576, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 40, + "maxTemperature": 2 + }, + { + "name": "models/gemini-2.0-flash-001", + "version": "2.0", + "displayName": "Gemini 2.0 Flash 001", + "description": "Stable version of Gemini 2.0 Flash, our fast and versatile multimodal model for scaling across diverse tasks, released in January of 2025.", + "inputTokenLimit": 1048576, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 40, + "maxTemperature": 2 + }, + { + "name": "models/gemini-2.0-flash-lite-001", + "version": "2.0", + "displayName": "Gemini 2.0 Flash-Lite 001", + "description": "Stable version of Gemini 2.0 Flash-Lite", + "inputTokenLimit": 1048576, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 40, + "maxTemperature": 2 + }, + { + "name": "models/gemini-2.0-flash-lite", + "version": "2.0", + "displayName": "Gemini 2.0 Flash-Lite", + "description": "Gemini 2.0 Flash-Lite", + "inputTokenLimit": 1048576, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 40, + "maxTemperature": 2 + }, + { + "name": "models/gemini-2.5-flash-preview-tts", + "version": "gemini-2.5-flash-exp-tts-2025-05-19", + "displayName": "Gemini 2.5 Flash Preview TTS", + "description": "Gemini 2.5 Flash Preview TTS", + "inputTokenLimit": 8192, + "outputTokenLimit": 16384, + "supportedGenerationMethods": [ + "countTokens", + "generateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2 + }, + { + "name": "models/gemini-2.5-pro-preview-tts", + "version": "gemini-2.5-pro-preview-tts-2025-05-19", + "displayName": "Gemini 2.5 Pro Preview TTS", + "description": "Gemini 2.5 Pro Preview TTS", + "inputTokenLimit": 8192, + "outputTokenLimit": 16384, + "supportedGenerationMethods": [ + "countTokens", + "generateContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2 + }, + { + "name": "models/gemma-3-1b-it", + "version": "001", + "displayName": "Gemma 3 1B", + "inputTokenLimit": 32768, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "generateContent", + "countTokens" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64 + }, + { + "name": "models/gemma-3-4b-it", + "version": "001", + "displayName": "Gemma 3 4B", + "inputTokenLimit": 32768, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "generateContent", + "countTokens" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64 + }, + { + "name": "models/gemma-3-12b-it", + "version": "001", + "displayName": "Gemma 3 12B", + "inputTokenLimit": 32768, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "generateContent", + "countTokens" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64 + }, + { + "name": "models/gemma-3-27b-it", + "version": "001", + "displayName": "Gemma 3 27B", + "inputTokenLimit": 131072, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "generateContent", + "countTokens" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64 + }, + { + "name": "models/gemma-3n-e4b-it", + "version": "001", + "displayName": "Gemma 3n E4B", + "inputTokenLimit": 8192, + "outputTokenLimit": 2048, + "supportedGenerationMethods": [ + "generateContent", + "countTokens" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64 + }, + { + "name": "models/gemma-3n-e2b-it", + "version": "001", + "displayName": "Gemma 3n E2B", + "inputTokenLimit": 8192, + "outputTokenLimit": 2048, + "supportedGenerationMethods": [ + "generateContent", + "countTokens" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64 + }, + { + "name": "models/gemini-flash-latest", + "version": "Gemini Flash Latest", + "displayName": "Gemini Flash Latest", + "description": "Latest release of Gemini Flash", + "inputTokenLimit": 1048576, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-flash-lite-latest", + "version": "Gemini Flash-Lite Latest", + "displayName": "Gemini Flash-Lite Latest", + "description": "Latest release of Gemini Flash-Lite", + "inputTokenLimit": 1048576, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-pro-latest", + "version": "Gemini Pro Latest", + "displayName": "Gemini Pro Latest", + "description": "Latest release of Gemini Pro", + "inputTokenLimit": 1048576, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-2.5-flash-lite", + "version": "001", + "displayName": "Gemini 2.5 Flash-Lite", + "description": "Stable version of Gemini 2.5 Flash-Lite, released in July of 2025", + "inputTokenLimit": 1048576, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-2.5-flash-image", + "version": "2.0", + "displayName": "Nano Banana", + "description": "Gemini 2.5 Flash Preview Image", + "inputTokenLimit": 32768, + "outputTokenLimit": 32768, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 1 + }, + { + "name": "models/gemini-2.5-flash-lite-preview-09-2025", + "version": "2.5-preview-09-25", + "displayName": "Gemini 2.5 Flash-Lite Preview Sep 2025", + "description": "Preview release (Septempber 25th, 2025) of Gemini 2.5 Flash-Lite", + "inputTokenLimit": 1048576, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-3-pro-preview", + "version": "3-pro-preview-11-2025", + "displayName": "Gemini 3 Pro Preview", + "description": "Gemini 3 Pro Preview", + "inputTokenLimit": 1048576, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-3-flash-preview", + "version": "3-flash-preview-12-2025", + "displayName": "Gemini 3 Flash Preview", + "description": "Gemini 3 Flash Preview", + "inputTokenLimit": 1048576, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-3.1-pro-preview", + "version": "3.1-pro-preview-01-2026", + "displayName": "Gemini 3.1 Pro Preview", + "description": "Gemini 3.1 Pro Preview", + "inputTokenLimit": 1048576, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-3.1-pro-preview-customtools", + "version": "3.1-pro-preview-01-2026", + "displayName": "Gemini 3.1 Pro Preview Custom Tools", + "description": "Gemini 3.1 Pro Preview optimized for custom tool usage", + "inputTokenLimit": 1048576, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-3.1-flash-lite-preview", + "version": "3.1-flash-lite-preview-03-2026", + "displayName": "Gemini 3.1 Flash Lite Preview", + "description": "Gemini 3.1 Flash Lite Preview", + "inputTokenLimit": 1048576, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "createCachedContent", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-3-pro-image-preview", + "version": "3.0", + "displayName": "Nano Banana Pro", + "description": "Gemini 3 Pro Image Preview", + "inputTokenLimit": 131072, + "outputTokenLimit": 32768, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 1, + "thinking": true + }, + { + "name": "models/nano-banana-pro-preview", + "version": "3.0", + "displayName": "Nano Banana Pro", + "description": "Gemini 3 Pro Image Preview", + "inputTokenLimit": 131072, + "outputTokenLimit": 32768, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 1, + "thinking": true + }, + { + "name": "models/gemini-3.1-flash-image-preview", + "version": "3.0", + "displayName": "Nano Banana 2", + "description": "Gemini 3.1 Flash Image Preview.", + "inputTokenLimit": 65536, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens", + "batchGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 1, + "thinking": true + }, + { + "name": "models/gemini-robotics-er-1.5-preview", + "version": "1.5-preview", + "displayName": "Gemini Robotics-ER 1.5 Preview", + "description": "Gemini Robotics-ER 1.5 Preview", + "inputTokenLimit": 1048576, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-2.5-computer-use-preview-10-2025", + "version": "Gemini 2.5 Computer Use Preview 10-2025", + "displayName": "Gemini 2.5 Computer Use Preview 10-2025", + "description": "Gemini 2.5 Computer Use Preview 10-2025", + "inputTokenLimit": 131072, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/deep-research-pro-preview-12-2025", + "version": "deepthink-exp-05-20", + "displayName": "Deep Research Pro Preview (Dec-12-2025)", + "description": "Preview release (December 12th, 2025) of Deep Research Pro", + "inputTokenLimit": 131072, + "outputTokenLimit": 65536, + "supportedGenerationMethods": [ + "generateContent", + "countTokens" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-embedding-001", + "version": "001", + "displayName": "Gemini Embedding 001", + "description": "Obtain a distributed representation of a text.", + "inputTokenLimit": 2048, + "outputTokenLimit": 1, + "supportedGenerationMethods": [ + "embedContent", + "countTextTokens", + "countTokens", + "asyncBatchEmbedContent" + ] + }, + { + "name": "models/gemini-embedding-2-preview", + "version": "2", + "displayName": "Gemini Embedding 2 Preview", + "description": "Obtain a distributed representation of multimodal content.", + "inputTokenLimit": 8192, + "outputTokenLimit": 1, + "supportedGenerationMethods": [ + "embedContent", + "countTextTokens", + "countTokens", + "asyncBatchEmbedContent" + ] + }, + { + "name": "models/aqa", + "version": "001", + "displayName": "Model that performs Attributed Question Answering.", + "description": "Model trained to return answers to questions that are grounded in provided sources, along with estimating answerable probability.", + "inputTokenLimit": 7168, + "outputTokenLimit": 1024, + "supportedGenerationMethods": [ + "generateAnswer" + ], + "temperature": 0.2, + "topP": 1, + "topK": 40 + }, + { + "name": "models/imagen-4.0-generate-001", + "version": "001", + "displayName": "Imagen 4", + "description": "Vertex served Imagen 4.0 model", + "inputTokenLimit": 480, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "predict" + ] + }, + { + "name": "models/imagen-4.0-ultra-generate-001", + "version": "001", + "displayName": "Imagen 4 Ultra", + "description": "Vertex served Imagen 4.0 ultra model", + "inputTokenLimit": 480, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "predict" + ] + }, + { + "name": "models/imagen-4.0-fast-generate-001", + "version": "001", + "displayName": "Imagen 4 Fast", + "description": "Vertex served Imagen 4.0 Fast model", + "inputTokenLimit": 480, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "predict" + ] + }, + { + "name": "models/veo-2.0-generate-001", + "version": "2.0", + "displayName": "Veo 2", + "description": "Vertex served Veo 2 model. Access to this model requires billing to be enabled on the associated Google Cloud Platform account. Please visit https://console.cloud.google.com/billing to enable it.", + "inputTokenLimit": 480, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "predictLongRunning" + ] + }, + { + "name": "models/veo-3.0-generate-001", + "version": "3.0", + "displayName": "Veo 3", + "description": "Veo 3", + "inputTokenLimit": 480, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "predictLongRunning" + ] + }, + { + "name": "models/veo-3.0-fast-generate-001", + "version": "3.0", + "displayName": "Veo 3 fast", + "description": "Veo 3 fast", + "inputTokenLimit": 480, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "predictLongRunning" + ] + }, + { + "name": "models/veo-3.1-generate-preview", + "version": "3.1", + "displayName": "Veo 3.1", + "description": "Veo 3.1", + "inputTokenLimit": 480, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "predictLongRunning" + ] + }, + { + "name": "models/veo-3.1-fast-generate-preview", + "version": "3.1", + "displayName": "Veo 3.1 fast", + "description": "Veo 3.1 fast", + "inputTokenLimit": 480, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "predictLongRunning" + ] + }, + { + "name": "models/gemini-2.5-flash-native-audio-latest", + "version": "Gemini 2.5 Flash Native Audio Latest", + "displayName": "Gemini 2.5 Flash Native Audio Latest", + "description": "Latest release of Gemini 2.5 Flash Native Audio", + "inputTokenLimit": 131072, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "countTokens", + "bidiGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-2.5-flash-native-audio-preview-09-2025", + "version": "gemini-2.5-flash-preview-native-audio-dialog-2025-05-19", + "displayName": "Gemini 2.5 Flash Native Audio Preview 09-2025", + "description": "Gemini 2.5 Flash Native Audio Preview 09-2025", + "inputTokenLimit": 131072, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "countTokens", + "bidiGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + }, + { + "name": "models/gemini-2.5-flash-native-audio-preview-12-2025", + "version": "12-2025", + "displayName": "Gemini 2.5 Flash Native Audio Preview 12-2025", + "description": "Gemini 2.5 Flash Native Audio Preview 12-2025", + "inputTokenLimit": 131072, + "outputTokenLimit": 8192, + "supportedGenerationMethods": [ + "countTokens", + "bidiGenerateContent" + ], + "temperature": 1, + "topP": 0.95, + "topK": 64, + "maxTemperature": 2, + "thinking": true + } + ] +} diff --git a/tests/e2e/specs/experiments/image-editing.spec.js b/tests/e2e/specs/experiments/image-editing.spec.js new file mode 100644 index 00000000..a7395f1c --- /dev/null +++ b/tests/e2e/specs/experiments/image-editing.spec.js @@ -0,0 +1,595 @@ +/** + * External dependencies + */ +const path = require( 'path' ); + +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { + clearConnector, + enableExperiment, + enableExperiments, + visitAdminPage, + visitConnectorsPage, +} = require( '../../utils/helpers' ); + +// Path to a test image (1x1 PNG) used for media upload in E2E tests. +const TEST_IMAGE_PATH = path.join( __dirname, '../../../data/sample.png' ); + +test.describe( 'Image Editing Experiment', () => { + test( 'Can enable the image generation/editing experiment', async ( { + admin, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Image Generation Experiment (which contains editing). + await enableExperiment( admin, page, 'image-generation' ); + + await visitConnectorsPage( admin ); + + // Add dummy credentials for Google. + await page + .locator( '.connector-item--ai-provider-for-google button' ) + .click(); + await page + .locator( + '.connector-item--ai-provider-for-google input[type="text"]' + ) + .fill( 'valid-api-key' ); + + // Save the credentials. + await page + .locator( + '.connector-item--ai-provider-for-google .connector-settings button' + ) + .click(); + } ); + + test( 'Can refine an image within a block', async ( { + admin, + editor, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Image Generation Experiment. + await enableExperiment( admin, page, 'image-generation' ); + + // Create a new post. + await admin.createNewPost( { + postType: 'post', + title: 'Test Inline Image Editing Experiment', + content: + 'This is some test content for the Image Editing Experiment.', + } ); + + // Save the post. + await editor.saveDraft(); + + // Insert a blank image block. + await editor.insertBlock( { + name: 'core/image', + } ); + + // Find the inline Generate Image button. + const generateImageButton = editor.canvas.locator( + '.wp-block-image button', + { + hasText: 'Generate Image', + } + ); + await expect( generateImageButton ).toBeVisible(); + + // Click the generate image inline button. + await generateImageButton.click(); + + // Add a prompt and generate the image. + await page + .locator( '.ai-generate-image-inline-modal__idle textarea' ) + .fill( 'A smiling face emoji' ); + await page + .locator( '.ai-generate-image-inline-modal__idle button' ) + .click(); + + // Ensure the Refine Image button is visible. + const refineButton = page.locator( + '.ai-generate-image-inline-modal__actions button', + { + hasText: 'Refine Image', + } + ); + await expect( refineButton ).toBeVisible(); + + // Click the Refine Image button. + await refineButton.click(); + + // Ensure the modal is in the refining state. + await expect( + page.locator( '.ai-generate-image-inline-modal__refining' ) + ).toBeVisible(); + + // Ensure the refine prompt textarea is visible. + await expect( + page.locator( '.ai-generate-image-inline-modal__refining textarea' ) + ).toBeVisible(); + + // Add our refine prompt and generate the image. + await page + .locator( '.ai-generate-image-inline-modal__refining textarea' ) + .fill( 'Add a red hat' ); + await page + .locator( '.ai-generate-image-inline-modal__refining button' ) + .filter( { hasText: 'Refine' } ) + .first() + .click(); + + // Ensure the images are visible in the modal. + await expect( + page.locator( '.ai-generate-image-inline-modal__preview-image' ) + ).toHaveCount( 2 ); + + // Click the previous image navigation arrow. + const previousBtn = page + .locator( '.ai-image-history-nav' ) + .getByRole( 'button', { + name: 'Previous version', + } ); + await previousBtn.click(); + + // Ensure the navigation shows correctly. + await expect( + page.locator( '.ai-image-history-nav__counter' ) + ).toHaveText( '1 / 2' ); + + // Click the next image navigation arrow. + const nextBtn = page + .locator( '.ai-image-history-nav' ) + .getByRole( 'button', { + name: 'Next version', + } ); + await nextBtn.click(); + + // Ensure the navigation shows correctly. + await expect( + page.locator( '.ai-image-history-nav__counter' ) + ).toHaveText( '2 / 2' ); + + const useImageButton = page.locator( + '.ai-generate-image-inline-modal__actions button', + { + hasText: 'Use Image', + } + ); + await expect( useImageButton ).toBeVisible(); + + useImageButton.click(); + + // Ensure the image is inserted into the block. + await expect( + editor.canvas.locator( '.wp-block-image img' ) + ).toBeVisible(); + + // Ensure the image is in the Media Library. + await visitAdminPage( admin, 'upload.php' ); + + const imageContainer = page + .locator( '.attachments-wrapper li' ) + .first(); + + await expect( imageContainer ).toHaveAttribute( + 'aria-label', + 'Add a red hat' + ); + + await expect( imageContainer.locator( 'img' ) ).toBeVisible(); + } ); + + test( 'Can refine an image in the stand-alone Generate Image page', async ( { + admin, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Image Generation Experiment. + await enableExperiment( admin, page, 'image-generation' ); + + // Visit the Media Library. + await visitAdminPage( admin, 'upload.php' ); + + // Click the Generate Image button. + await page.locator( '.ai-generate-image-btn' ).click(); + + // Add a prompt and generate the image. + await page + .locator( '.ai-generate-image-standalone__idle textarea' ) + .fill( 'A smiley face' ); + await page + .locator( '.ai-generate-image-standalone__actions button' ) + .click(); + + const refineButton = page.locator( + '.ai-generate-image-standalone__actions button', + { + hasText: 'Refine Image', + } + ); + await expect( refineButton ).toBeVisible(); + + // Click the refine button. + await refineButton.click(); + + // Ensure the modal is in the refining state. + await expect( + page.locator( '.ai-generate-image-standalone__refining' ) + ).toBeVisible(); + + // Ensure the refine prompt textarea is visible. + await expect( + page.locator( '.ai-generate-image-standalone__refining textarea' ) + ).toBeVisible(); + + // Add our refine prompt and generate the image. + await page + .locator( '.ai-generate-image-standalone__refining textarea' ) + .fill( 'Add a red hat' ); + await page + .locator( '.ai-generate-image-standalone__refining button' ) + .filter( { hasText: 'Refine' } ) + .first() + .click(); + + // Ensure the images are visible in the modal. + await expect( + page.locator( '.ai-generate-image-standalone__preview-image' ) + ).toHaveCount( 2 ); + + // Click the previous image navigation arrow. + const previousBtn = page + .locator( '.ai-image-history-nav' ) + .getByRole( 'button', { + name: 'Previous version', + } ); + await previousBtn.click(); + + // Ensure the navigation shows correctly. + await expect( + page.locator( '.ai-image-history-nav__counter' ) + ).toHaveText( '1 / 2' ); + + // Click the next image navigation arrow. + const nextBtn = page + .locator( '.ai-image-history-nav' ) + .getByRole( 'button', { + name: 'Next version', + } ); + await nextBtn.click(); + + // Ensure the navigation shows correctly. + await expect( + page.locator( '.ai-image-history-nav__counter' ) + ).toHaveText( '2 / 2' ); + + const saveImageButton = page.locator( + '.ai-generate-image-standalone__actions button', + { + hasText: 'Save to Media Library', + } + ); + await expect( saveImageButton ).toBeVisible(); + + saveImageButton.click(); + + // Ensure a success message is visible. + await expect( + page.locator( '.components-notice.is-success' ) + ).toBeVisible(); + + // View the image in the Media Library. + page.locator( '.components-notice.is-success a' ).click(); + + // Ensure alt text is set. + await expect( + page.locator( '#attachment-details-two-column-alt-text' ) + ).toHaveValue( 'Add a red hat' ); + } ); + + test( 'Can refine an existing image in the Media Library', async ( { + admin, + page, + requestUtils, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Image Generation Experiment. + await enableExperiment( admin, page, 'image-generation' ); + + // Upload a test image so we have a URL the editor can load. + await requestUtils.uploadMedia( TEST_IMAGE_PATH ); + + // Visit the Media Library. + await visitAdminPage( admin, 'upload.php' ); + + // Click on the first image in the Media Library. + await page + .locator( '.media-frame-content ul.attachments li:first-child' ) + .click(); + + // Find the Edit Image button and click on it. + const editImageButton = page + .locator( '.attachment-actions button', { hasText: 'Edit Image' } ) + .first(); + await editImageButton.click(); + + // Find and click on the Refine Image button. + const refineImageButton = page.locator( + '.ai-media-library-editor__presets button', + { + hasText: 'Refine Image', + } + ); + await refineImageButton.click(); + + // Add a prompt and generate the image. + await page + .locator( '.ai-media-library-editor__idle textarea' ) + .fill( 'A smiley face' ); + await page + .locator( '.ai-media-library-editor__actions button' ) + .click(); + + // Ensure the images are visible in the modal. + await expect( + page.locator( '.ai-media-library-editor__preview-image' ) + ).toHaveCount( 2 ); + + // Ensure the buttons we want are there. + await expect( + page.locator( '.ai-media-library-editor__actions button' ) + ).toHaveCount( 4 ); + + let saveImageBtn = page.locator( + '.ai-media-library-editor__actions button', + { + hasText: 'Save to Media Library', + } + ); + await expect( saveImageBtn ).toBeVisible(); + + const refineBtn = page.locator( + '.ai-media-library-editor__actions button', + { + hasText: 'Refine Image', + } + ); + await expect( refineBtn ).toBeVisible(); + + const generateAnotherBtn = page.locator( + '.ai-media-library-editor__actions button', + { + hasText: 'Generate Another Image', + } + ); + await expect( generateAnotherBtn ).toBeVisible(); + + // Ensure there's a Start over button. + const startOverBtn = page.locator( + '.ai-media-library-editor__actions button', + { + hasText: 'Start over', + } + ); + await expect( startOverBtn ).toBeVisible(); + + startOverBtn.click(); + + // Add a prompt and generate the image. + await page + .locator( '.ai-media-library-editor__idle textarea' ) + .fill( 'A smiley face' ); + await page + .locator( '.ai-media-library-editor__actions button' ) + .click(); + + // Ensure the images are visible in the modal. + await expect( + page.locator( '.ai-media-library-editor__preview-image' ) + ).toHaveCount( 2 ); + + // Ensure the buttons we want are there. + await expect( + page.locator( '.ai-media-library-editor__actions button' ) + ).toHaveCount( 4 ); + + saveImageBtn = page.locator( + '.ai-media-library-editor__actions button', + { + hasText: 'Save to Media Library', + } + ); + await expect( saveImageBtn ).toBeVisible(); + + saveImageBtn.click(); + + // Ensure a success message is visible. + await expect( + page.locator( '.components-notice.is-success' ) + ).toBeVisible(); + + // View the image in the Media Library. + page.locator( '.components-notice.is-success a' ).click(); + + // Ensure alt text is set. + await expect( + page.locator( '#attachment-details-two-column-alt-text' ) + ).toHaveValue( 'A smiley face' ); + } ); + + test( 'Can use preset refine buttons on an existing image in the Media Library', async ( { + admin, + page, + requestUtils, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Image Generation Experiment. + await enableExperiment( admin, page, 'image-generation' ); + + // Upload a test image so we have a URL the editor can load. + await requestUtils.uploadMedia( TEST_IMAGE_PATH ); + + // Visit the Media Library. + await visitAdminPage( admin, 'upload.php' ); + + // Click on the first image in the Media Library. + await page + .locator( '.media-frame-content ul.attachments li:first-child' ) + .click(); + + // Find the Edit Image button and click on it. + const editImageButton = page + .locator( '.attachment-actions button', { hasText: 'Edit Image' } ) + .first(); + await editImageButton.click(); + + // Ensure there are three preset buttons. + await expect( + page.locator( '.ai-media-library-editor__presets button' ) + ).toHaveCount( 3 ); + + // Find the Expand Background button and click on it. + const expandBGBtn = page + .locator( '.ai-media-library-editor__presets button', { + hasText: 'Expand Background', + } ) + .first(); + await expandBGBtn.click(); + + // Ensure the images are visible in the modal. + await expect( + page.locator( '.ai-media-library-editor__preview-image' ) + ).toHaveCount( 2 ); + + // Ensure the buttons we want are there. + await expect( + page.locator( '.ai-media-library-editor__actions button' ) + ).toHaveCount( 4 ); + + // Ensure there's a Start over button. + const startOverBtn = page.locator( + '.ai-media-library-editor__actions button', + { + hasText: 'Start over', + } + ); + await expect( startOverBtn ).toBeVisible(); + + startOverBtn.click(); + + // Find the Remove background button and click on it. + const removeBGBtn = page + .locator( '.ai-media-library-editor__presets button', { + hasText: 'Remove Background', + } ) + .first(); + await removeBGBtn.click(); + + // Ensure the images are visible in the modal. + await expect( + page.locator( '.ai-media-library-editor__preview-image' ) + ).toHaveCount( 2 ); + + // Ensure the buttons we want are there. + await expect( + page.locator( '.ai-media-library-editor__actions button' ) + ).toHaveCount( 4 ); + + const generateAnotherBtn = page.locator( + '.ai-media-library-editor__actions button', + { + hasText: 'Generate Another Image', + } + ); + await expect( generateAnotherBtn ).toBeVisible(); + + generateAnotherBtn.click(); + + // Ensure the images are visible in the modal. + await expect( + page.locator( '.ai-media-library-editor__preview-image' ) + ).toHaveCount( 2 ); + + // Click the previous image navigation arrow. + let previousImgBtn = page + .locator( '.ai-image-history-nav' ) + .getByRole( 'button', { + name: 'Previous version', + } ); + await previousImgBtn.click(); + + // Ensure the navigation shows correctly. + await expect( + page.locator( '.ai-image-history-nav__counter' ) + ).toHaveText( '1 / 2' ); + + // Click the next image navigation arrow. + let nextImgBtn = page + .locator( '.ai-image-history-nav' ) + .getByRole( 'button', { + name: 'Next version', + } ); + await nextImgBtn.click(); + + // Ensure the navigation shows correctly. + await expect( + page.locator( '.ai-image-history-nav__counter' ) + ).toHaveText( '2 / 2' ); + + // Generate another image. + await generateAnotherBtn.click(); + + // Ensure the images are visible in the modal. + await expect( + page.locator( '.ai-media-library-editor__preview-image' ) + ).toHaveCount( 2 ); + + // Click the previous image navigation arrow. + previousImgBtn = page + .locator( '.ai-image-history-nav' ) + .getByRole( 'button', { + name: 'Previous version', + } ); + await previousImgBtn.click(); + + // Ensure the navigation shows correctly. + await expect( + page.locator( '.ai-image-history-nav__counter' ) + ).toHaveText( '2 / 3' ); + + // Click the next image navigation arrow. + nextImgBtn = page + .locator( '.ai-image-history-nav' ) + .getByRole( 'button', { + name: 'Next version', + } ); + await nextImgBtn.click(); + + // Ensure the navigation shows correctly. + await expect( + page.locator( '.ai-image-history-nav__counter' ) + ).toHaveText( '3 / 3' ); + + await clearConnector( admin, page, 'ai-provider-for-google' ); + } ); +} ); diff --git a/tests/e2e/specs/experiments/image-generation.spec.js b/tests/e2e/specs/experiments/image-generation.spec.js index 41ea1d0b..678fa33e 100644 --- a/tests/e2e/specs/experiments/image-generation.spec.js +++ b/tests/e2e/specs/experiments/image-generation.spec.js @@ -534,20 +534,11 @@ test.describe( 'Image Generation Experiment', () => { // Ensure a success message is visible. await expect( - page.locator( '.ai-generate-image-standalone__success' ) + page.locator( '.components-notice.is-success' ) ).toBeVisible(); - // Ensure we have two new buttons. - await expect( - page.locator( '.ai-generate-image-standalone__success button' ) - ).toHaveCount( 1 ); - - await expect( - page.locator( '.ai-generate-image-standalone__success a' ) - ).toHaveCount( 1 ); - // View the image in the Media Library. - page.locator( '.ai-generate-image-standalone__success a' ).click(); + page.locator( '.components-notice.is-success a' ).click(); // Ensure alt text is set. await expect( diff --git a/tests/e2e/utils/helpers.ts b/tests/e2e/utils/helpers.ts index c87f07a8..c3ac4ea9 100644 --- a/tests/e2e/utils/helpers.ts +++ b/tests/e2e/utils/helpers.ts @@ -48,12 +48,53 @@ export const clearConnectors = async ( admin: Admin, page: Page ) => { // Wait for page to fully load before finding button await page.waitForTimeout( 1000 ); - const editBtn = page.locator( - '.connector-item--ai-provider-for-openai button', - { + const providers = [ + 'ai-provider-for-openai', + 'ai-provider-for-google', + 'ai-provider-for-anthropic', + ]; + + for ( const provider of providers ) { + const editBtn = page.locator( `.connector-item--${ provider } button`, { hasText: 'Edit', + } ); + + if ( ( await editBtn.count() ) === 0 ) { + continue; } - ); + + await editBtn.click(); + await page + .locator( + `.connector-item--${ provider } .connector-settings button` + ) + .click(); + } + + // Wait for save. + await page.waitForTimeout( 1000 ); +}; + +/** + * Clears out a specific existing Connector. + * + * @param admin The admin fixture from the test context. + * @param page The page object. + * @param connectorId The ID of the connector to clear. + */ +export const clearConnector = async ( + admin: Admin, + page: Page, + connectorId: string +) => { + await visitConnectorsPage( admin ); + + // Wait for page to fully load before finding button + await page.waitForTimeout( 1000 ); + + const editBtn = page.locator( `.connector-item--${ connectorId } button`, { + hasText: 'Edit', + } ); if ( ( await editBtn.count() ) === 0 ) { return; @@ -62,7 +103,7 @@ export const clearConnectors = async ( admin: Admin, page: Page ) => { await editBtn.click(); await page .locator( - '.connector-item--ai-provider-for-openai .connector-settings button' + `.connector-item--${ connectorId } .connector-settings button` ) .click();