diff --git a/projects/plugins/jetpack/changelog/forno-194-image-studio-feature-parity b/projects/plugins/jetpack/changelog/forno-194-image-studio-feature-parity
new file mode 100644
index 00000000000..3568a53a290
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/forno-194-image-studio-feature-parity
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Image Studio: Add an "Edit with AI" row action in the Media Library list view and disable overlapping Jetpack AI image extensions globally when Image Studio is available.
diff --git a/projects/plugins/jetpack/extensions/plugins/image-studio/image-studio.php b/projects/plugins/jetpack/extensions/plugins/image-studio/image-studio.php
index 8430c2985d8..0097fcd51e8 100644
--- a/projects/plugins/jetpack/extensions/plugins/image-studio/image-studio.php
+++ b/projects/plugins/jetpack/extensions/plugins/image-studio/image-studio.php
@@ -281,6 +281,71 @@ function enqueue_image_studio_admin() {
}
add_action( 'admin_enqueue_scripts', __NAMESPACE__ . '\enqueue_image_studio_admin' );
+/**
+ * Adds an "Edit with AI" row action for supported image types in the media library list view.
+ *
+ * Inserts the action before the default "Edit" link so it's prominently visible.
+ * Only appears for image MIME types that Image Studio supports.
+ *
+ * @param array $actions Row actions array.
+ * @param \WP_Post $post The attachment post object.
+ * @return array Modified row actions.
+ */
+function add_image_studio_row_action( $actions, $post ) {
+ // Keep in sync with IMAGE_STUDIO_SUPPORTED_MIME_TYPES in wp-calypso/packages/image-studio/src/types/index.ts.
+ $supported_mime_types = array(
+ 'image/jpeg',
+ 'image/jpg',
+ 'image/png',
+ 'image/webp',
+ 'image/bmp',
+ 'image/tiff',
+ );
+
+ if ( ! in_array( $post->post_mime_type, $supported_mime_types, true ) ) {
+ return $actions;
+ }
+
+ if ( ! current_user_can( 'edit_post', $post->ID ) ) {
+ return $actions;
+ }
+
+ $link = sprintf(
+ '%s',
+ absint( $post->ID ),
+ esc_html__( 'Edit with AI', 'jetpack' )
+ );
+
+ // Insert before the 'edit' action, or append if 'edit' is not present.
+ $new_actions = array();
+ foreach ( $actions as $key => $value ) {
+ if ( 'edit' === $key ) {
+ $new_actions['edit-with-ai'] = $link;
+ }
+ $new_actions[ $key ] = $value;
+ }
+
+ if ( ! isset( $new_actions['edit-with-ai'] ) ) {
+ $new_actions['edit-with-ai'] = $link;
+ }
+
+ return $new_actions;
+}
+
+/**
+ * Register the "Edit with AI" row action on the Media Library screen.
+ *
+ * @return void
+ */
+function register_row_action() {
+ if ( ! is_image_studio_enabled() || ! is_media_library() ) {
+ return;
+ }
+
+ add_filter( 'media_row_actions', __NAMESPACE__ . '\add_image_studio_row_action', 10, 2 );
+}
+add_action( 'current_screen', __NAMESPACE__ . '\register_row_action' );
+
/**
* Get the list of AI image extensions that conflict with Image Studio.
*
@@ -296,30 +361,15 @@ function get_ai_image_extensions() {
}
/**
- * Disable Jetpack AI image extensions when Image Studio is active on the current screen.
- *
- * This hook fires on `jetpack_register_gutenberg_extensions` which may run multiple
- * times: once during initial module load (before get_current_screen() is available)
- * and again inside Jetpack_Gutenberg::get_availability() during enqueue (where the
- * screen IS available).
- *
- * Only disables AI extensions when we can confirm Image Studio will actually load
- * on the current screen (i.e. screen is available and should_load_on_current_screen()
- * returns true). If the screen is not available or Image Studio won't load on this
- * screen, AI extensions remain enabled.
+ * Disable Jetpack AI image extensions when Image Studio is available.
*
- * This ensures AI extensions are available on screens where Image Studio won't load
- * (e.g. dashboard, other non-editor screens, or early initialization).
+ * When Image Studio is available (via Jetpack_Gutenberg::is_available), AI image
+ * extensions are disabled globally to avoid duplicate functionality.
*
* @return void
*/
function disable_jetpack_ai_image_extensions() {
- if ( ! is_image_studio_enabled() ) {
- return;
- }
-
- // Only disable if screen is available and Image Studio will actually load.
- if ( ! function_exists( 'get_current_screen' ) || ! get_current_screen() || ! should_load_on_current_screen() ) {
+ if ( ! \Jetpack_Gutenberg::is_available( FEATURE_NAME ) ) {
return;
}
diff --git a/projects/plugins/jetpack/tests/php/extensions/plugins/image-studio/Image_Studio_Test.php b/projects/plugins/jetpack/tests/php/extensions/plugins/image-studio/Image_Studio_Test.php
index e144f848b85..62d3b1d7523 100644
--- a/projects/plugins/jetpack/tests/php/extensions/plugins/image-studio/Image_Studio_Test.php
+++ b/projects/plugins/jetpack/tests/php/extensions/plugins/image-studio/Image_Studio_Test.php
@@ -852,10 +852,11 @@ public function test_get_asset_data_returns_false_on_non_json_content_type() {
// -------------------------------------------------------------------------
/**
- * Test AI image extensions are disabled when Image Studio is enabled.
+ * Test AI image extensions are disabled when Image Studio is available.
*/
- public function test_ai_extensions_disabled_when_enabled() {
+ public function test_ai_extensions_disabled_when_available() {
$this->enable_image_studio();
+ ImageStudio\register_plugin();
$this->make_ai_extensions_available();
$this->set_block_editor_screen();
@@ -864,7 +865,7 @@ public function test_ai_extensions_disabled_when_enabled() {
foreach ( self::get_ai_image_extensions() as $ext ) {
$this->assertFalse(
\Jetpack_Gutenberg::is_available( $ext ),
- "Extension $ext should be unavailable when Image Studio is enabled."
+ "Extension $ext should be unavailable when Image Studio is available."
);
}
}
@@ -874,6 +875,7 @@ public function test_ai_extensions_disabled_when_enabled() {
*/
public function test_ai_extensions_disabled_when_unified_experience() {
$this->enable_unified_experience();
+ ImageStudio\register_plugin();
$this->make_ai_extensions_available();
$this->set_block_editor_screen();
@@ -888,10 +890,11 @@ public function test_ai_extensions_disabled_when_unified_experience() {
}
/**
- * Test AI image extensions are NOT disabled when Image Studio is disabled.
+ * Test AI image extensions are NOT disabled when Image Studio is not available.
*/
- public function test_ai_extensions_not_disabled_when_disabled() {
+ public function test_ai_extensions_not_disabled_when_not_available() {
$this->disable_image_studio();
+ ImageStudio\register_plugin();
$this->make_ai_extensions_available();
ImageStudio\disable_jetpack_ai_image_extensions();
@@ -913,6 +916,7 @@ public function test_ai_extensions_not_disabled_when_disabled() {
*/
public function test_ai_extensions_disabled_on_block_editor() {
$this->enable_image_studio();
+ ImageStudio\register_plugin();
$this->make_ai_extensions_available();
$this->set_block_editor_screen();
@@ -931,6 +935,7 @@ public function test_ai_extensions_disabled_on_block_editor() {
*/
public function test_ai_extensions_disabled_on_media_library() {
$this->enable_image_studio();
+ ImageStudio\register_plugin();
$this->make_ai_extensions_available();
$this->set_media_library_screen();
@@ -945,40 +950,45 @@ public function test_ai_extensions_disabled_on_media_library() {
}
/**
- * Test AI extensions are NOT disabled on non-editor, non-media screen.
+ * Test AI extensions ARE disabled on dashboard when Image Studio is available.
+ *
+ * Since the screen guard was removed, AI extensions are disabled globally
+ * when Image Studio is available, regardless of screen.
*/
- public function test_ai_extensions_not_disabled_on_dashboard() {
+ public function test_ai_extensions_disabled_on_dashboard() {
$this->enable_image_studio();
+ ImageStudio\register_plugin();
$this->make_ai_extensions_available();
set_current_screen( 'dashboard' );
ImageStudio\disable_jetpack_ai_image_extensions();
foreach ( self::get_ai_image_extensions() as $ext ) {
- $this->assertTrue(
+ $this->assertFalse(
\Jetpack_Gutenberg::is_available( $ext ),
- "Extension $ext should stay available on dashboard."
+ "Extension $ext should be disabled on dashboard when Image Studio is available."
);
}
}
/**
- * Test AI extensions remain available when no screen is available.
+ * Test AI extensions ARE disabled when no screen is available.
*
- * When get_current_screen() is not available (early in module load),
- * Image Studio won't load either, so AI extensions remain available.
+ * Since the screen guard was removed, AI extensions are disabled globally
+ * when Image Studio is available, regardless of screen availability.
*/
- public function test_ai_extensions_not_disabled_when_no_screen() {
+ public function test_ai_extensions_disabled_when_no_screen() {
$this->enable_image_studio();
+ ImageStudio\register_plugin();
$this->make_ai_extensions_available();
$GLOBALS['current_screen'] = null;
ImageStudio\disable_jetpack_ai_image_extensions();
foreach ( self::get_ai_image_extensions() as $ext ) {
- $this->assertTrue(
+ $this->assertFalse(
\Jetpack_Gutenberg::is_available( $ext ),
- "Extension $ext should remain available when no screen is available (Image Studio won't load)."
+ "Extension $ext should be disabled when no screen is available."
);
}
}
@@ -1410,6 +1420,188 @@ function () {
$this->assertStringContainsString( 'languages/pt-br-v1.js', $script->src );
}
+ // -------------------------------------------------------------------------
+ // add_image_studio_row_action() tests
+ // -------------------------------------------------------------------------
+
+ /**
+ * Create a mock attachment post with a given MIME type.
+ *
+ * @param string $mime_type The MIME type for the attachment.
+ * @return \WP_Post
+ */
+ private function create_attachment_post( $mime_type = 'image/jpeg' ) {
+ $attachment_id = self::factory()->attachment->create(
+ array(
+ 'post_mime_type' => $mime_type,
+ 'post_type' => 'attachment',
+ )
+ );
+ return get_post( $attachment_id );
+ }
+
+ /**
+ * Test row action is added for supported JPEG image.
+ */
+ public function test_row_action_added_for_jpeg() {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+ $post = $this->create_attachment_post( 'image/jpeg' );
+ $actions = ImageStudio\add_image_studio_row_action( array( 'edit' => 'Edit' ), $post );
+
+ $this->assertArrayHasKey( 'edit-with-ai', $actions );
+ $this->assertStringContainsString( 'Edit with AI', $actions['edit-with-ai'] );
+ $this->assertStringContainsString( 'big-sky-image-studio-link', $actions['edit-with-ai'] );
+ $this->assertStringContainsString( 'data-attachment-id="' . $post->ID . '"', $actions['edit-with-ai'] );
+ }
+
+ /**
+ * Test row action is added for supported PNG image.
+ */
+ public function test_row_action_added_for_png() {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+ $post = $this->create_attachment_post( 'image/png' );
+ $actions = ImageStudio\add_image_studio_row_action( array( 'edit' => 'Edit' ), $post );
+
+ $this->assertArrayHasKey( 'edit-with-ai', $actions );
+ }
+
+ /**
+ * Test row action is added for supported WebP image.
+ */
+ public function test_row_action_added_for_webp() {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+ $post = $this->create_attachment_post( 'image/webp' );
+ $actions = ImageStudio\add_image_studio_row_action( array( 'edit' => 'Edit' ), $post );
+
+ $this->assertArrayHasKey( 'edit-with-ai', $actions );
+ }
+
+ /**
+ * Test row action is added for supported JPG image.
+ */
+ public function test_row_action_added_for_jpg() {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+ $post = $this->create_attachment_post( 'image/jpg' );
+ $actions = ImageStudio\add_image_studio_row_action( array( 'edit' => 'Edit' ), $post );
+
+ $this->assertArrayHasKey( 'edit-with-ai', $actions );
+ }
+
+ /**
+ * Test row action is added for supported BMP image.
+ */
+ public function test_row_action_added_for_bmp() {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+ $post = $this->create_attachment_post( 'image/bmp' );
+ $actions = ImageStudio\add_image_studio_row_action( array( 'edit' => 'Edit' ), $post );
+
+ $this->assertArrayHasKey( 'edit-with-ai', $actions );
+ }
+
+ /**
+ * Test row action is added for supported TIFF image.
+ */
+ public function test_row_action_added_for_tiff() {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+ $post = $this->create_attachment_post( 'image/tiff' );
+ $actions = ImageStudio\add_image_studio_row_action( array( 'edit' => 'Edit' ), $post );
+
+ $this->assertArrayHasKey( 'edit-with-ai', $actions );
+ }
+
+ /**
+ * Test row action is NOT added for unsupported MIME type (PDF).
+ */
+ public function test_row_action_not_added_for_pdf() {
+ $post = $this->create_attachment_post( 'application/pdf' );
+ $actions = ImageStudio\add_image_studio_row_action( array( 'edit' => 'Edit' ), $post );
+
+ $this->assertArrayNotHasKey( 'edit-with-ai', $actions );
+ }
+
+ /**
+ * Test row action is NOT added for unsupported MIME type (video).
+ */
+ public function test_row_action_not_added_for_video() {
+ $post = $this->create_attachment_post( 'video/mp4' );
+ $actions = ImageStudio\add_image_studio_row_action( array( 'edit' => 'Edit' ), $post );
+
+ $this->assertArrayNotHasKey( 'edit-with-ai', $actions );
+ }
+
+ /**
+ * Test row action is inserted before the 'edit' action.
+ */
+ public function test_row_action_inserted_before_edit() {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+ $post = $this->create_attachment_post( 'image/jpeg' );
+ $actions = ImageStudio\add_image_studio_row_action(
+ array(
+ 'trash' => 'Trash',
+ 'edit' => 'Edit',
+ 'view' => 'View',
+ ),
+ $post
+ );
+
+ $keys = array_keys( $actions );
+ $this->assertSame( array( 'trash', 'edit-with-ai', 'edit', 'view' ), $keys );
+ }
+
+ /**
+ * Test row action is appended when 'edit' action is not present.
+ */
+ public function test_row_action_appended_when_no_edit_action() {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+ $post = $this->create_attachment_post( 'image/jpeg' );
+ $actions = ImageStudio\add_image_studio_row_action(
+ array(
+ 'trash' => 'Trash',
+ 'view' => 'View',
+ ),
+ $post
+ );
+
+ $keys = array_keys( $actions );
+ $this->assertSame( array( 'trash', 'view', 'edit-with-ai' ), $keys );
+ }
+
+ /**
+ * Test row action preserves all existing actions.
+ */
+ public function test_row_action_preserves_existing_actions() {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+ $post = $this->create_attachment_post( 'image/jpeg' );
+ $actions = ImageStudio\add_image_studio_row_action(
+ array(
+ 'edit' => 'Edit',
+ 'trash' => 'Trash',
+ ),
+ $post
+ );
+
+ $this->assertArrayHasKey( 'edit', $actions );
+ $this->assertArrayHasKey( 'trash', $actions );
+ $this->assertArrayHasKey( 'edit-with-ai', $actions );
+ }
+
+ /**
+ * Test row action is not added when user cannot edit the attachment.
+ */
+ public function test_row_action_not_added_without_edit_permission() {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) );
+
+ $post = $this->create_attachment_post( 'image/jpeg' );
+ $original_actions = array(
+ 'trash' => 'Trash',
+ );
+
+ $actions = ImageStudio\add_image_studio_row_action( $original_actions, $post );
+
+ $this->assertSame( $original_actions, $actions );
+ $this->assertArrayNotHasKey( 'edit-with-ai', $actions );
+ }
+
// -------------------------------------------------------------------------
// Constants tests
// -------------------------------------------------------------------------