Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.20% covered (success)
90.20%
184 / 204
76.92% covered (warning)
76.92%
20 / 26
CRAP
n/a
0 / 0
Automattic\Jetpack\Extensions\ImageStudio\is_image_studio_environment_available
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
Automattic\Jetpack\Extensions\ImageStudio\is_image_studio_enabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\Extensions\ImageStudio\is_current_user_connected
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\Extensions\ImageStudio\is_big_sky_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\Extensions\ImageStudio\is_ciab_environment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\Extensions\ImageStudio\signal_image_studio_active
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\Extensions\ImageStudio\has_jetpack_ai_features
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
Automattic\Jetpack\Extensions\ImageStudio\image_studio_can_generate_video_clips
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
Automattic\Jetpack\Extensions\ImageStudio\is_block_editor
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
Automattic\Jetpack\Extensions\ImageStudio\is_media_library
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
Automattic\Jetpack\Extensions\ImageStudio\register_plugin
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\Extensions\ImageStudio\feature_clip_meta_auth_callback
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\Extensions\ImageStudio\register_feature_clip_post_meta
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
Automattic\Jetpack\Extensions\ImageStudio\get_asset_data
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
Automattic\Jetpack\Extensions\ImageStudio\get_asset_data_from_file
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
Automattic\Jetpack\Extensions\ImageStudio\get_asset_data_from_remote
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
Automattic\Jetpack\Extensions\ImageStudio\get_tracking_blog_id
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\Extensions\ImageStudio\get_tracking_site_type
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
Automattic\Jetpack\Extensions\ImageStudio\is_tracking_automattician
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
13.12
Automattic\Jetpack\Extensions\ImageStudio\do_enqueue_assets
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
6
Automattic\Jetpack\Extensions\ImageStudio\enqueue_image_studio
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\Extensions\ImageStudio\enqueue_image_studio_admin
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
Automattic\Jetpack\Extensions\ImageStudio\add_image_studio_row_action
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
6
Automattic\Jetpack\Extensions\ImageStudio\register_row_action
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
Automattic\Jetpack\Extensions\ImageStudio\get_ai_image_extensions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\Extensions\ImageStudio\disable_jetpack_ai_image_extensions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Block Editor & Media Library - Image Studio plugin feature.
4 *
5 * @package automattic/jetpack
6 */
7
8namespace Automattic\Jetpack\Extensions\ImageStudio;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Automattic\Jetpack\Status;
12use Automattic\Jetpack\Status\Host;
13use Automattic\Jetpack\Status\Visitor;
14use function Automattic\Jetpack\Extensions\Shared\determine_iso_639_locale;
15
16if ( ! defined( 'ABSPATH' ) ) {
17    exit( 0 );
18}
19
20require_once __DIR__ . '/../../shared/cdn-locale.php';
21// Required directly rather than relying on the plugin bootstrap: on
22// WordPress.com Simple the extension files load through wpcom's own loader
23// and load-jetpack.php never runs.
24require_once __DIR__ . '/../../../_inc/lib/class-jetpack-ai-settings.php';
25
26const FEATURE_NAME           = 'image-studio';
27const FEATURE_CLIP_META_KEY  = '_jetpack_feature_clip_id';
28const ASSET_BASE_PATH        = 'widgets.wp.com/agents-manager/';
29const ASSET_JS_URL           = 'https://' . ASSET_BASE_PATH . 'image-studio.min.js';
30const ASSET_CSS_URL          = 'https://' . ASSET_BASE_PATH . 'image-studio.css';
31const ASSET_RTL_URL          = 'https://' . ASSET_BASE_PATH . 'image-studio.rtl.css';
32const ASSET_JSON_URL         = 'https://' . ASSET_BASE_PATH . 'image-studio.asset.json';
33const ASSET_JSON_PATH        = ASSET_BASE_PATH . 'image-studio.asset.json';
34const ASSET_TRANSLATIONS_URL = 'https://' . ASSET_BASE_PATH . 'languages/';
35const ASSET_TRANSIENT        = 'jetpack_image_studio_asset';
36
37/**
38 * Whether the environment offers Image Studio at all: the host and master
39 * gates plus the platform checks, with no per-feature toggles. Shared by the
40 * image surfaces and clip generation, which each apply their own toggle on
41 * top — the toggles are independent of each other by contract, so neither
42 * belongs in this shared half.
43 *
44 * @return bool
45 */
46function is_image_studio_environment_available() {
47    // The host and master switches win over every environment-based enable
48    // below: off must mean Jetpack loads nothing, even in Big Sky or CIAB.
49    if ( ! \Jetpack_AI_Settings::apply_master_gates( true ) ) {
50        return false;
51    }
52
53    if ( is_ciab_environment() || is_big_sky_enabled() ) {
54        return true;
55    }
56
57    return has_jetpack_ai_features();
58}
59
60/**
61 * Check whether Image Studio's image surfaces are offered on this site.
62 *
63 * This is a site-level ownership check and intentionally does not consider the
64 * current user's connection (see is_current_user_connected() for that). It
65 * drives the Big Sky stand-down signal and the suppression of the legacy AI
66 * image extensions, so it must stay true for the whole site even when a given
67 * visitor can't use the feature.
68 *
69 * @return bool
70 */
71function is_image_studio_enabled() {
72    // The image editor toggle on the AI settings page wins over every
73    // environment-based enable: off must mean no Jetpack image surfaces,
74    // even when Big Sky or CIAB would otherwise turn Image Studio on.
75    return \Jetpack_AI_Settings::is_feature_enabled( 'image_editor' )
76        && is_image_studio_environment_available();
77}
78
79/**
80 * Whether the current user may load Image Studio's editor assets.
81 *
82 * True on WordPress.com Simple, which has no per-user Jetpack connection, so the
83 * current user is always treated as connected. Atomic (WoA), self-hosted and VIP
84 * all have per-user connections, so there the current user must have connected
85 * their own WordPress.com account — a user who has disconnected is correctly
86 * treated as not connected. Gates the asset enqueue and the media-library entry
87 * point so non-connected users aren't shown tools that would only error out.
88 *
89 * @return bool
90 */
91function is_current_user_connected() {
92    // Simple has no per-user connection; Atomic/WoA does, so it uses the real
93    // per-user check like self-hosted and VIP rather than short-circuiting.
94    if ( ( new Host() )->is_wpcom_simple() ) {
95        return true;
96    }
97
98    return ( new Connection_Manager( 'jetpack' ) )->is_user_connected();
99}
100
101/**
102 * Check if the Big Sky plugin is active and enabled.
103 *
104 * @return bool
105 */
106function is_big_sky_enabled() {
107    return class_exists( 'Big_Sky' ) && get_option( 'big_sky_enable', '1' );
108}
109
110/**
111 * Check if current environment is CIAB (Commerce in a Box) / Next Admin.
112 *
113 * Uses the same detection method as Help Center and Agents Manager
114 *
115 * @return bool True if CIAB/Next Admin environment.
116 */
117function is_ciab_environment() {
118    return (bool) did_action( 'next_admin_init' );
119}
120
121/**
122 * Signal to Big Sky that Jetpack is handling Image Studio.
123 *
124 * Sets the jetpack_image_studio_enabled filter to true so that
125 * Big Sky skips its own Image Studio loading when Jetpack has
126 * AI features available.
127 *
128 * @return void
129 */
130function signal_image_studio_active() {
131    if ( is_image_studio_enabled() ) {
132        add_filter( 'jetpack_image_studio_enabled', '__return_true', 5 );
133    }
134}
135add_action( 'init', __NAMESPACE__ . '\signal_image_studio_active' );
136
137/**
138 * Check whether AI features are available.
139 *
140 * - wpcom simple: always available.
141 * - Otherwise requires a connected owner with AI not disabled
142 *   (same conditions the AI Assistant plugin uses to register).
143 *
144 * @return bool
145 */
146function has_jetpack_ai_features() {
147    $host = new Host();
148
149    if ( $host->is_wpcom_simple() ) {
150        return true;
151    }
152
153    return ( new Connection_Manager( 'jetpack' ) )->has_connected_owner()
154        && ! ( new Status() )->is_offline_mode()
155        && \Jetpack_AI_Settings::is_ai_enabled();
156}
157
158/**
159 * Check whether the video clip generation flow can run on the current site.
160 *
161 * Feature Clip is nested under the image editor: generation requires the image
162 * editor to be enabled (`is_image_studio_enabled()` — the shared environment
163 * plus the image editor toggle) as well as clips' own toggle. Image Studio is
164 * the same set of plans/environments on WPCOM and off.
165 * On WPCOM the helper also mirrors the server-side
166 * `wpcom_site_can_upload_videos()` capability check so the client and server
167 * agree. Off-WPCOM (self-hosted Jetpack, standalone VideoPress, dev
168 * environments) that helper isn't loaded, so only the Image Studio gate
169 * applies; the server is the source of truth if generation is unsupported.
170 *
171 * @return bool
172 */
173function image_studio_can_generate_video_clips() {
174    // Feature Clip is nested under the image editor: the image editor toggle
175    // (via is_image_studio_enabled(), which also covers the shared environment)
176    // and clips' own toggle both gate generation. With the image editor off,
177    // clips cannot generate.
178    if ( ! is_image_studio_enabled() ) {
179        return false;
180    }
181
182    if ( ! \Jetpack_AI_Settings::is_feature_enabled( 'feature_clip' ) ) {
183        return false;
184    }
185
186    if ( function_exists( 'wpcom_site_can_upload_videos' ) && ! wpcom_site_can_upload_videos() ) {
187        return false;
188    }
189
190    /**
191     * Filter the video clip generation capability. Consulted only after the
192     * Image Studio and `wpcom_site_can_upload_videos()` hard gates pass.
193     *
194     * @since 15.9
195     *
196     * @param bool|null $override Override value, or null to use default detection.
197     */
198    $override = apply_filters( 'jetpack_image_studio_can_generate_video_clips', null );
199    if ( null !== $override ) {
200        return (bool) $override;
201    }
202
203    return true;
204}
205
206/**
207 * Check if the current screen is a block editor (Post Editor or Site Editor).
208 *
209 * @return bool
210 */
211function is_block_editor() {
212    if ( ! function_exists( 'get_current_screen' ) ) {
213        return false;
214    }
215
216    $screen = get_current_screen();
217    return $screen && $screen->is_block_editor();
218}
219
220/**
221 * Check if the current screen is the Media Library.
222 *
223 * @return bool
224 */
225function is_media_library() {
226    if ( ! function_exists( 'get_current_screen' ) ) {
227        return false;
228    }
229
230    $screen = get_current_screen();
231    return $screen && 'upload' === $screen->base;
232}
233
234/**
235 * Register the Image Studio plugin.
236 *
237 * Registers unconditionally when either filter is true. Screen-level gating
238 * happens at enqueue time since get_current_screen() is not available here.
239 *
240 * @return void
241 */
242function register_plugin() {
243    if ( ! is_image_studio_enabled() ) {
244        return;
245    }
246
247    \Jetpack_Gutenberg::set_extension_available( FEATURE_NAME );
248}
249add_action( 'jetpack_register_gutenberg_extensions', __NAMESPACE__ . '\register_plugin' );
250
251/**
252 * Permission check for reading or writing the feature clip meta on a given
253 * post. WordPress runs `auth_callback` for both REST GET and POST against the
254 * meta key, so this gate determines visibility as well as mutability.
255 *
256 * @param bool   $allowed   Whether the user is allowed (unused — recomputed here).
257 * @param string $meta_key  Meta key being checked.
258 * @param int    $object_id Post ID.
259 * @return bool
260 */
261function feature_clip_meta_auth_callback( $allowed, $meta_key, $object_id ) {
262    unset( $allowed, $meta_key );
263    return current_user_can( 'edit_post', (int) $object_id );
264}
265
266/**
267 * Register the post meta that links a generated video clip to a post.
268 *
269 * Stored as the attachment ID; the URL is resolved client-side via the
270 * Media Library so deletes/replacements stay consistent. Registered for
271 * `post` only — pages can be added later if there's a use case.
272 *
273 * @return void
274 */
275function register_feature_clip_post_meta() {
276    // The feature clip meta belongs to clip generation, not the image
277    // surfaces: it follows the clips toggle and the shared environment. It
278    // deliberately ignores the transient video-upload capability — posts that
279    // already carry a clip must keep their meta readable even if the plan or
280    // capability changes later; generation itself stays gated elsewhere.
281    if ( ! is_image_studio_environment_available()
282        || ! \Jetpack_AI_Settings::is_feature_enabled( 'feature_clip' )
283    ) {
284        return;
285    }
286
287    register_post_meta(
288        'post',
289        FEATURE_CLIP_META_KEY,
290        array(
291            'type'              => 'integer',
292            'description'       => 'Attachment ID of the generated video clip designated as this post\'s feature clip.',
293            'single'            => true,
294            'default'           => 0,
295            'show_in_rest'      => true,
296            'sanitize_callback' => 'absint',
297            'auth_callback'     => __NAMESPACE__ . '\feature_clip_meta_auth_callback',
298        )
299    );
300}
301add_action( 'init', __NAMESPACE__ . '\register_feature_clip_post_meta' );
302
303/**
304 * Fetch and cache the remote asset manifest.
305 *
306 * On WordPress.com, the asset file may be accessible on the local filesystem
307 * (under ABSPATH). This avoids an HTTP round-trip and works on sandboxes where
308 * outbound requests to widgets.wp.com may be blocked.
309 *
310 * @return array|false The decoded asset data, or false on failure.
311 */
312function get_asset_data() {
313    $cached = get_transient( ASSET_TRANSIENT );
314    if ( false !== $cached ) {
315        return $cached;
316    }
317
318    $data = get_asset_data_from_file();
319    if ( false === $data ) {
320        $data = get_asset_data_from_remote();
321    }
322
323    if ( false === $data ) {
324        return false;
325    }
326
327    if ( ! ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ) {
328        set_transient( ASSET_TRANSIENT, $data, HOUR_IN_SECONDS );
329    }
330    return $data;
331}
332
333/**
334 * Try to read the asset manifest from the local filesystem.
335 *
336 * On WordPress.com, widgets.wp.com assets are available at ABSPATH.
337 *
338 * @return array|false The decoded asset data, or false if not available locally.
339 */
340function get_asset_data_from_file() {
341    $local_path = ABSPATH . ASSET_JSON_PATH;
342    if ( ! file_exists( $local_path ) ) {
343        return false;
344    }
345
346    // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a local file, not a remote URL.
347    $contents = file_get_contents( $local_path );
348    if ( false === $contents ) {
349        return false;
350    }
351
352    $data = json_decode( $contents, true );
353    if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $data ) ) {
354        return false;
355    }
356
357    return $data;
358}
359
360/**
361 * Fetch the asset manifest via HTTP.
362 *
363 * Used as a fallback when the file is not available locally (e.g. self-hosted sites).
364 *
365 * @return array|false The decoded asset data, or false on failure.
366 */
367function get_asset_data_from_remote() {
368    $response = wp_safe_remote_get( ASSET_JSON_URL );
369    if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
370        return false;
371    }
372
373    $content_type = wp_remote_retrieve_header( $response, 'content-type' );
374    if ( is_string( $content_type ) && false === stripos( $content_type, 'json' ) ) {
375        return false;
376    }
377
378    $body = wp_remote_retrieve_body( $response );
379    $data = json_decode( $body, true );
380    if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $data ) ) {
381        return false;
382    }
383
384    return $data;
385}
386
387/**
388 * Get the current site's WordPress.com blog ID for tracking.
389 *
390 * @return int The WordPress.com blog ID, or 0 when unavailable.
391 */
392function get_tracking_blog_id() {
393    $blog_id = ( new Host() )->get_wpcom_site_id();
394
395    return $blog_id ? absint( $blog_id ) : 0;
396}
397
398/**
399 * Get the current site type for Image Studio tracking.
400 *
401 * @return string The site type: simple, atomic, or jetpack.
402 */
403function get_tracking_site_type() {
404    $host = new Host();
405
406    if ( $host->is_wpcom_simple() ) {
407        return 'simple';
408    }
409
410    if ( $host->is_woa_site() ) {
411        return 'atomic';
412    }
413
414    return 'jetpack';
415}
416
417/**
418 * Check whether the current visitor should be treated as an Automattician.
419 *
420 * @return bool True when the current visitor is an Automattician.
421 */
422function is_tracking_automattician() {
423    $visitor = new Visitor();
424
425    // Another plugin can supply an older copy of jetpack-status through its own
426    // autoloader, where this method does not exist yet.
427    if ( ! method_exists( $visitor, 'is_tracking_automattician' ) ) {
428        return ( function_exists( 'is_automattician' ) && \is_automattician() )
429            || ( function_exists( 'wpcom_is_proxied_request' ) && \wpcom_is_proxied_request() )
430            || ( defined( 'AT_PROXIED_REQUEST' ) && AT_PROXIED_REQUEST );
431    }
432
433    return $visitor->is_tracking_automattician();
434}
435
436/**
437 * Enqueue Image Studio script and style assets.
438 *
439 * @return void
440 */
441function do_enqueue_assets() {
442    // Enqueue still follows the image surfaces only. Clips-only enqueue (image
443    // editor off, clips on) needs the client bundle to gate its image entry
444    // points on a per-feature flag first — until that ships on the widgets.wp.com
445    // side, loading the bundle here would surface image tools the user disabled.
446    if ( ! is_image_studio_enabled() || ! is_current_user_connected() ) {
447        return;
448    }
449
450    $asset_data = get_asset_data();
451    if ( ! $asset_data ) {
452        return;
453    }
454
455    $version      = $asset_data['version'] ?? false;
456    $dependencies = $asset_data['dependencies'] ?? array();
457    $locale       = determine_iso_639_locale();
458
459    if ( 'en' !== $locale ) {
460        // Shared handle with the Agents Manager package, which enqueues the same
461        // file. Both register it, so WordPress de-duplicates when both load and
462        // each still works on its own.
463        wp_enqueue_script(
464            'agents-manager-translations',
465            ASSET_TRANSLATIONS_URL . $locale . '-v1.js',
466            array( 'wp-i18n' ),
467            $version,
468            true
469        );
470
471        $dependencies[] = 'agents-manager-translations';
472    }
473
474    wp_enqueue_script(
475        FEATURE_NAME,
476        ASSET_JS_URL,
477        $dependencies,
478        $version,
479        true
480    );
481
482    $image_studio_data = array(
483        'enabled'               => true,
484        'version'               => '1.0',
485        'blogId'                => get_tracking_blog_id(),
486        'siteType'              => get_tracking_site_type(),
487        'isA11n'                => is_tracking_automattician(),
488        'isDevMode'             => jetpack_is_internal_testing_environment(),
489        'canGenerateVideoClips' => image_studio_can_generate_video_clips(),
490    );
491
492    wp_add_inline_script(
493        FEATURE_NAME,
494        'if ( typeof window.imageStudioData === "undefined" ) { window.imageStudioData = ' . wp_json_encode( $image_studio_data, JSON_HEX_TAG | JSON_HEX_AMP ) . '; }',
495        'before'
496    );
497
498    wp_enqueue_style(
499        FEATURE_NAME . '-style',
500        is_rtl() ? ASSET_RTL_URL : ASSET_CSS_URL,
501        array( 'wp-components' ),
502        $version
503    );
504}
505
506/**
507 * Enqueue Image Studio assets in the block editor.
508 *
509 * @return void
510 */
511function enqueue_image_studio() {
512    if ( ! is_block_editor() ) {
513        return;
514    }
515
516    do_enqueue_assets();
517}
518add_action( 'enqueue_block_editor_assets', __NAMESPACE__ . '\enqueue_image_studio' );
519
520/**
521 * Enqueue Image Studio assets on admin screens (Media Library).
522 *
523 * @return void
524 */
525function enqueue_image_studio_admin() {
526    if ( ! is_media_library() ) {
527        return;
528    }
529
530    do_enqueue_assets();
531}
532add_action( 'admin_enqueue_scripts', __NAMESPACE__ . '\enqueue_image_studio_admin' );
533
534/**
535 * Adds an "Edit with AI" row action for supported image types in the media library list view.
536 *
537 * Inserts the action before the default "Edit" link so it's prominently visible.
538 * Only appears for image MIME types that Image Studio supports.
539 *
540 * @param array    $actions Row actions array.
541 * @param \WP_Post $post    The attachment post object.
542 * @return array Modified row actions.
543 */
544function add_image_studio_row_action( $actions, $post ) {
545    // Keep in sync with IMAGE_STUDIO_SUPPORTED_MIME_TYPES in wp-calypso/packages/image-studio/src/types/index.ts.
546    $supported_mime_types = array(
547        'image/jpeg',
548        'image/jpg',
549        'image/png',
550        'image/webp',
551        'image/bmp',
552        'image/tiff',
553    );
554
555    if ( ! in_array( $post->post_mime_type, $supported_mime_types, true ) ) {
556        return $actions;
557    }
558
559    if ( ! current_user_can( 'edit_post', $post->ID ) ) {
560        return $actions;
561    }
562
563    $link = sprintf(
564        '<a href="#" class="big-sky-image-studio-link" data-attachment-id="%d">%s</a>',
565        absint( $post->ID ),
566        esc_html__( 'Edit with AI', 'jetpack' )
567    );
568
569    // Insert before the 'edit' action, or append if 'edit' is not present.
570    $new_actions = array();
571    foreach ( $actions as $key => $value ) {
572        if ( 'edit' === $key ) {
573            $new_actions['edit-with-ai'] = $link;
574        }
575        $new_actions[ $key ] = $value;
576    }
577
578    if ( ! isset( $new_actions['edit-with-ai'] ) ) {
579        $new_actions['edit-with-ai'] = $link;
580    }
581
582    return $new_actions;
583}
584
585/**
586 * Register the "Edit with AI" row action on the Media Library screen.
587 *
588 * @return void
589 */
590function register_row_action() {
591    if ( ! is_image_studio_enabled() || ! is_current_user_connected() || ! is_media_library() ) {
592        return;
593    }
594
595    add_filter( 'media_row_actions', __NAMESPACE__ . '\add_image_studio_row_action', 10, 2 );
596}
597add_action( 'current_screen', __NAMESPACE__ . '\register_row_action' );
598
599/**
600 * Get the list of AI image extensions that conflict with Image Studio.
601 *
602 * @return array
603 */
604function get_ai_image_extensions() {
605    return array(
606        'ai-featured-image-generator',
607        'ai-assistant-image-extension',
608        'ai-general-purpose-image-generator',
609        'ai-assistant-experimental-image-generation-support',
610    );
611}
612
613/**
614 * Disable Jetpack AI image extensions when Image Studio is available.
615 *
616 * When Image Studio is available (via Jetpack_Gutenberg::is_available), AI image
617 * extensions are disabled globally to avoid duplicate functionality.
618 *
619 * @return void
620 */
621function disable_jetpack_ai_image_extensions() {
622    if ( ! \Jetpack_Gutenberg::is_available( FEATURE_NAME ) ) {
623        return;
624    }
625
626    foreach ( get_ai_image_extensions() as $extension ) {
627        \Jetpack_Gutenberg::set_extension_unavailable( $extension, 'image_studio_active' );
628    }
629}
630// Priority 99 ensures this runs after all AI extensions are registered at default priority.
631add_action( 'jetpack_register_gutenberg_extensions', __NAMESPACE__ . '\disable_jetpack_ai_image_extensions', 99 );