Plugin Directory

Changeset 3490444


Ignore:
Timestamp:
03/25/2026 01:31:10 AM (3 days ago)
Author:
clipai
Message:

Version 1.2.0 Release

Location:
clipcloud-image-generation
Files:
33 added
6 edited

Legend:

Unmodified
Added
Removed
  • clipcloud-image-generation/trunk/assets/js/admin.js

    r3418668 r3490444  
    6969        }
    7070
    71         function setBalance( bal, free ) {
    72             if ( ! balanceEl ) {
    73                 return;
    74             }
    75             if ( null === bal && null === free ) {
    76                 balanceEl.innerHTML = '';
    77                 return;
    78             }
    79             const b = null !== bal && undefined !== bal ? bal : '—';
    80             const f = null !== free && undefined !== free ? free : '—';
    81             balanceEl.innerHTML = L.balance + ' <span class="value">' + b + '</span>🖼️ (+' + f + ' ' + L.freeToday + ')';
    82         }
    83 
    84         function setFreeModeHint( bal ) {
    85             if ( ! freeHintEl ) {
    86                 return;
    87             }
    88             if ( null !== bal && undefined !== bal && ! isNaN( bal ) && Number( bal ) < 20 ) {
    89                 freeHintEl.innerHTML = L.freeModeLabel + ' <span class="ccfi-tooltip"><span class="ccfi-helpicon">?</span><span class="ccfi-tooltip-box">' + ( L.freeModeTooltip || '' ).replace( /\n/g, '<br>' ) + '</span></span>';
    90             } else {
    91                 freeHintEl.innerHTML = '';
    92             }
    93         }
     71            function setBalance( bal, free ) {
     72                if ( ! balanceEl ) {
     73                    return;
     74                }
     75                if ( null === bal && null === free ) {
     76                    balanceEl.textContent = '';
     77                    return;
     78                }
     79                const b = null !== bal && undefined !== bal ? bal : '—';
     80                const f = null !== free && undefined !== free ? free : '—';
     81
     82                while ( balanceEl.firstChild ) {
     83                    balanceEl.removeChild( balanceEl.firstChild );
     84                }
     85
     86                balanceEl.appendChild( document.createTextNode( ( L.balance || '' ) + ' ' ) );
     87
     88                const valSpan = document.createElement( 'span' );
     89                valSpan.className = 'value';
     90                valSpan.textContent = String( b );
     91                balanceEl.appendChild( valSpan );
     92
     93                balanceEl.appendChild( document.createTextNode( '🖼️ (+' + String( f ) + ' ' + ( L.freeToday || '' ) + ')' ) );
     94            }
     95
     96            function setFreeModeHint( bal ) {
     97                if ( ! freeHintEl ) {
     98                    return;
     99                }
     100                while ( freeHintEl.firstChild ) {
     101                    freeHintEl.removeChild( freeHintEl.firstChild );
     102                }
     103                if ( null !== bal && undefined !== bal && ! isNaN( bal ) && Number( bal ) < 20 ) {
     104                    freeHintEl.appendChild( document.createTextNode( String( L.freeModeLabel || '' ) + ' ' ) );
     105
     106                    const tooltip = document.createElement( 'span' );
     107                    tooltip.className = 'ccfi-tooltip';
     108
     109                    const helpIcon = document.createElement( 'span' );
     110                    helpIcon.className = 'ccfi-helpicon';
     111                    helpIcon.textContent = '?';
     112
     113                    const tooltipBox = document.createElement( 'span' );
     114                    tooltipBox.className = 'ccfi-tooltip-box';
     115                    tooltipBox.textContent = String( L.freeModeTooltip || '' );
     116
     117                    tooltip.appendChild( helpIcon );
     118                    tooltip.appendChild( tooltipBox );
     119                    freeHintEl.appendChild( tooltip );
     120                }
     121            }
    94122
    95123        function rebuildStyles( names, labelsMap ) {
  • clipcloud-image-generation/trunk/assets/js/status.js

    r3418668 r3490444  
    2929        const pollActive   = parseInt( vars.pollInterval, 10 ) || 15000;
    3030        const pollIdle     = parseInt( vars.pollIntervalIdle, 10 ) || 60000;
    31         const cronInterval = parseInt( vars.cronInterval, 10 ) || 30000;
     31        const cronInterval = parseInt( vars.cronInterval, 10 ) || 20000;
    3232
    3333        const labelsPost  = vars.i18n && vars.i18n.post ? vars.i18n.post : {};
     
    257257        if ( hasPendingDom() ) {
    258258            lastPending = true;
    259             doPoll();
    260         }
    261     } );
    262 })();
     259        }
     260        doPoll();
     261        } );
     262    })();
  • clipcloud-image-generation/trunk/clipcloud-image-generation.php

    r3460288 r3490444  
    33 * Plugin Name: ClipCloud - Image Generation
    44 * Description: Generates images using AI for your posts.
    5  * Version:     1.1.2
     5 * Version:     1.2.0
    66 * Author:      ClipAI
    77 * Text Domain: clipcloud-image-generation
     
    3636    // Polling.
    3737    const POLL_INTERVAL_SEC    = 25;
    38     const MAX_ATTEMPTS         = 20;  // 20 minutes total.
     38    const MAX_ATTEMPTS         = 40;  // Per-slot result polling attempts cap.
    3939    const HTTP_TIMEOUT_SEC     = 25;
    4040    const MAX_SLOTS_PER_START  = 2;   // Hard cap on slots per single run.
    4141    const MAX_CREATION_ATTEMPTS = 3;
     42    const RUNTIME_START_COST_SEC = 15;
     43    const RUNTIME_START_COST_AUTO_ENHANCE_SEC = 30;
     44    const RUNTIME_POLL_COST_SEC = 20;
    4245
    4346    // Meta / options.
     
    5558    const META_SLOT_CREATION_ATTEMPTS = '_ccfi_slot_creation_attempts'; // slot => attempts to start job.
    5659    const META_APPLY_PENDING  = '_ccfi_apply_pending';
     60    const META_APPLY_ATTEMPTS = '_ccfi_apply_attempts';
     61    const META_SLOT_PROMPT_CONTEXT = '_ccfi_slot_prompt_context'; // slot => [prompt, enhanced].
     62    const MAX_APPLY_ATTEMPTS = 12;
    5763
    5864    // Cron.
     
    6066    const CRON_HOOK_START    = 'clipcloud_fi_start_generation';
    6167    const CRON_HOOK_WATCHDOG = 'clipcloud_fi_watchdog';
     68    const CRON_HOOK_VALIDATE_KEY = 'clipcloud_fi_validate_saved_key';
     69    const CRON_HOOK_REFRESH_ROUTE = 'clipcloud_fi_refresh_api_route';
    6270    const WATCHDOG_BATCH_LIMIT = 20;
    6371
     
    9199        add_action( self::CRON_HOOK_START, [ $this, 'cron_start_generation' ], 10, 2 );
    92100        add_action( self::CRON_HOOK_WATCHDOG, [ $this, 'cron_watchdog' ] );
     101        add_action( self::CRON_HOOK_VALIDATE_KEY, [ $this, 'cron_validate_saved_key' ] );
     102        add_action( self::CRON_HOOK_REFRESH_ROUTE, [ $this, 'cron_refresh_api_route' ] );
    93103
    94104        add_action( 'wp_ajax_' . self::AJAX_ACTION_VALIDATE, [ $this, 'ajax_validate_key' ] );
     
    117127        wp_clear_scheduled_hook( self::CRON_HOOK_START );
    118128        wp_clear_scheduled_hook( self::CRON_HOOK_WATCHDOG );
     129        wp_clear_scheduled_hook( self::CRON_HOOK_VALIDATE_KEY );
     130        wp_clear_scheduled_hook( self::CRON_HOOK_REFRESH_ROUTE );
    119131    }
    120132
     
    148160            self::META_SLOTS_HASH,
    149161            self::META_APPLY_PENDING,
    150         ];
     162            self::META_APPLY_ATTEMPTS,
     163            self::META_SLOT_PROMPT_CONTEXT,
     164            ];
    151165
    152166        foreach ( $meta_keys as $meta_key ) {
     
    164178        wp_clear_scheduled_hook( self::CRON_HOOK_START );
    165179        wp_clear_scheduled_hook( self::CRON_HOOK_WATCHDOG );
     180        wp_clear_scheduled_hook( self::CRON_HOOK_VALIDATE_KEY );
     181        wp_clear_scheduled_hook( self::CRON_HOOK_REFRESH_ROUTE );
    166182    }
    167183
     
    252268    }
    253269
    254     private function refresh_api_route_on_settings_save( $opts ) {
    255         return self::refresh_api_route( $opts );
    256     }
    257 
    258270    private function get_api_server_status_message( $opts = null ) {
    259271        $o = is_array( $opts ) ? $opts : $this->get_opts();
     
    261273
    262274        if ( 'backup' === $state ) {
    263             return '🟡 Backup ClipCloud server is in use.';
     275            return __( '🟡 Backup ClipCloud server is in use.', 'clipcloud-image-generation' );
    264276        }
    265277        if ( 'down' === $state ) {
    266             return '⚠️ 🔴 ClipCloud servers are unavailable. Please contact your hosting provider and ask: "Why is there no access to clipcloud.clipai.pro?"';
     278            return __( '⚠️ 🔴 ClipCloud servers are unavailable. Please contact your hosting provider and ask: "Why is there no access to clipcloud.clipai.pro?"', 'clipcloud-image-generation' );
    267279        }
    268280
    269281        // Also used before the first check on a fresh install.
    270         return '🟢 Primary ClipCloud server is in use.';
     282        return __( '🟢 Primary ClipCloud server is in use.', 'clipcloud-image-generation' );
     283    }
     284
     285    private function schedule_api_key_validation() {
     286        if ( ! wp_next_scheduled( self::CRON_HOOK_VALIDATE_KEY ) ) {
     287            wp_schedule_single_event( time() + 1, self::CRON_HOOK_VALIDATE_KEY, [] );
     288        }
     289    }
     290
     291    private function schedule_api_route_refresh() {
     292        if ( ! wp_next_scheduled( self::CRON_HOOK_REFRESH_ROUTE ) ) {
     293            wp_schedule_single_event( time() + 1, self::CRON_HOOK_REFRESH_ROUTE, [] );
     294        }
     295    }
     296
     297    private function build_validated_key_opts( array $opts ) {
     298        $out     = $opts;
     299        $api_key = isset( $out['api_key'] ) ? trim( (string) $out['api_key'] ) : '';
     300
     301        $out['api_key_valid']    = false;
     302        $out['styles_cache']     = [];
     303        $out['styles_cache_ts']  = 0;
     304        $out['styles_labels']    = [];
     305        $out['styles_labels_ts'] = 0;
     306
     307        if ( '' === $api_key || false === strpos( $api_key, 'clip-' ) ) {
     308            return $out;
     309        }
     310
     311        $res = wp_remote_request(
     312            $this->build_url(
     313                self::EP_STYLES,
     314                $this->get_effective_api_base( $out )
     315            ),
     316            [
     317                'method'  => 'GET',
     318                'headers' => $this->http_headers( $api_key ),
     319                'timeout' => 10,
     320            ]
     321        );
     322
     323        $code = is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res );
     324        if ( is_wp_error( $res ) || 200 !== $code ) {
     325            return $out;
     326        }
     327
     328        $b = json_decode( wp_remote_retrieve_body( $res ), true );
     329        if ( ! is_array( $b ) || empty( $b['styles'] ) ) {
     330            return $out;
     331        }
     332
     333        $names = [];
     334        foreach ( (array) $b['styles'] as $item ) {
     335            if ( is_array( $item ) && isset( $item['name'] ) ) {
     336                $names[] = (string) $item['name'];
     337            } elseif ( is_string( $item ) ) {
     338                $names[] = $item;
     339            }
     340        }
     341        $names = array_values( array_filter( array_unique( $names ) ) );
     342        if ( empty( $names ) ) {
     343            return $out;
     344        }
     345
     346        $out['api_key_valid']   = true;
     347        $out['styles_cache']    = $names;
     348        $out['styles_cache_ts'] = time();
     349
     350        $labels = $this->fetch_style_labels_for_locale();
     351        if ( ! empty( $labels ) ) {
     352            $out['styles_labels']    = $labels;
     353            $out['styles_labels_ts'] = time();
     354        }
     355
     356        return $out;
     357    }
     358
     359    public function cron_validate_saved_key() {
     360        $lock_key = 'ccfi_validate_key_lock';
     361        if ( get_transient( $lock_key ) ) {
     362            return;
     363        }
     364        set_transient( $lock_key, 1, MINUTE_IN_SECONDS );
     365
     366        $opts = $this->get_opts();
     367        $opts = self::refresh_api_route( $opts );
     368        $opts = $this->build_validated_key_opts( $opts );
     369        $this->save_opts( $opts );
     370
     371        delete_transient( $lock_key );
     372    }
     373
     374    public function cron_refresh_api_route() {
     375        $lock_key = 'ccfi_refresh_route_lock';
     376        if ( get_transient( $lock_key ) ) {
     377            return;
     378        }
     379        set_transient( $lock_key, 1, MINUTE_IN_SECONDS );
     380
     381        $opts = $this->get_opts();
     382        $opts = self::refresh_api_route( $opts );
     383        $this->save_opts( $opts );
     384
     385        delete_transient( $lock_key );
    271386    }
    272387
     
    478593
    479594                    $out['post_types'] = $post_types_clean;
    480                     $out               = $this->refresh_api_route_on_settings_save( $out );
    481595
    482596                    // Reset validation/cache by default to avoid stale "valid" flags.
     
    492606                    $api_key_changed    = ( $out['api_key'] !== $prev_key );
    493607
    494                     if ( '' === $out['api_key'] ) {
    495                         $out['api_key_valid'] = false;
    496                     } elseif ( ! $api_key_changed && $prev_key_was_valid ) {
    497                         $out['api_key_valid']    = true;
    498                         $out['styles_cache']     = isset( $prev_opts['styles_cache'] ) && is_array( $prev_opts['styles_cache'] ) ? $prev_opts['styles_cache'] : [];
    499                         $out['styles_cache_ts']  = isset( $prev_opts['styles_cache_ts'] ) ? (int) $prev_opts['styles_cache_ts'] : 0;
    500                         $out['styles_labels']    = isset( $prev_opts['styles_labels'] ) && is_array( $prev_opts['styles_labels'] ) ? $prev_opts['styles_labels'] : [];
    501                         $out['styles_labels_ts'] = isset( $prev_opts['styles_labels_ts'] ) ? (int) $prev_opts['styles_labels_ts'] : 0;
    502                         } else {
    503                             $res  = wp_remote_request(
    504                                 $this->build_url(
    505                                     self::EP_STYLES,
    506                                     $this->get_effective_api_base( $out )
    507                                 ),
    508                                 [
    509                                     'method'  => 'GET',
    510                                     'headers' => $this->http_headers( $out['api_key'] ),
    511                                     'timeout' => 10,
    512                                 ]
    513                             );
    514                         $code = is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res );
    515 
    516                         if ( ! is_wp_error( $res ) && 200 === $code ) {
    517                             $b = json_decode( wp_remote_retrieve_body( $res ), true );
    518                             if ( is_array( $b ) && ! empty( $b['styles'] ) ) {
    519                                 $names = [];
    520                                 foreach ( (array) $b['styles'] as $item ) {
    521                                     if ( is_array( $item ) && isset( $item['name'] ) ) {
    522                                         $names[] = (string) $item['name'];
    523                                     } elseif ( is_string( $item ) ) {
    524                                         $names[] = $item;
    525                                     }
    526                                 }
    527                                 $names = array_values( array_filter( array_unique( $names ) ) );
    528                                 if ( ! empty( $names ) ) {
    529                                     $out['api_key_valid']    = true;
    530                                     $out['styles_cache']     = $names;
    531                                     $out['styles_cache_ts']  = time();
    532 
    533                                     $labels = $this->fetch_style_labels_for_locale();
    534                                     if ( ! empty( $labels ) ) {
    535                                         $out['styles_labels']    = $labels;
    536                                         $out['styles_labels_ts'] = time();
    537                                     }
    538                                 }
    539                             }
     608                        if ( '' === $out['api_key'] ) {
     609                            $out['api_key_valid'] = false;
     610                        } elseif ( ! $api_key_changed && $prev_key_was_valid ) {
     611                            $out['api_key_valid']    = true;
     612                            $out['styles_cache']     = isset( $prev_opts['styles_cache'] ) && is_array( $prev_opts['styles_cache'] ) ? $prev_opts['styles_cache'] : [];
     613                            $out['styles_cache_ts']  = isset( $prev_opts['styles_cache_ts'] ) ? (int) $prev_opts['styles_cache_ts'] : 0;
     614                            $out['styles_labels']    = isset( $prev_opts['styles_labels'] ) && is_array( $prev_opts['styles_labels'] ) ? $prev_opts['styles_labels'] : [];
     615                            $out['styles_labels_ts'] = isset( $prev_opts['styles_labels_ts'] ) ? (int) $prev_opts['styles_labels_ts'] : 0;
     616                        } elseif ( false !== strpos( $out['api_key'], 'clip-' ) ) {
     617                            // Validate asynchronously to avoid blocking/sometimes interrupted settings save.
     618                            $this->schedule_api_key_validation();
    540619                        }
    541                     }
    542 
    543                     return $out;
    544                 },
    545             ]
    546         );
     620
     621                        // Refresh API route on every settings save, even if API key was not changed.
     622                        $this->schedule_api_route_refresh();
     623
     624                        return $out;
     625                    },
     626                ]
     627            );
    547628
    548629        add_settings_section( 'clipcloud_fi_main', __( 'Main settings', 'clipcloud-image-generation' ), '__return_false', 'clipcloud_fi' );
     
    681762                    'pollInterval'     => 15000,
    682763                    'pollIntervalIdle' => 60000,
    683                     'cronInterval'     => 30000,
     764                    'cronInterval'     => 20000,
    684765                    'i18n'             => [
    685766                        'post' => $labels_post,
     
    11961277        $post_id = isset( $_GET['post_id'] ) ? (int) $_GET['post_id'] : 0;
    11971278        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- sanitized and verified below.
    1198         $slot = isset( $_GET['slot'] ) ? sanitize_key( wp_unslash( (string) $_GET['slot'] ) ) : '';
    1199         $slot_safe = ( 'featured' === $slot || preg_match( '/^h[2-4]:[a-z0-9\\-]+$/i', $slot ) );
     1279        $slot      = isset( $_GET['slot'] ) ? strtolower( trim( sanitize_text_field( wp_unslash( (string) $_GET['slot'] ) ) ) ) : '';
     1280        $slot_safe = ( 'featured' === $slot || 1 === preg_match( '/^h[2-4]:[a-z0-9\\-]+$/', $slot ) );
    12001281
    12011282        if ( $post_id <= 0 || '' === $slot || ! $slot_safe ) {
     
    17111792        }
    17121793
    1713         $payload = [
    1714             'model'       => $r['model'],
    1715             'messages'    => [
    1716                 [
    1717                     'role'    => 'system',
    1718                     'content' => $system_text,
     1794            $payload = [
     1795                'model'       => $r['model'],
     1796                'messages'    => [
     1797                    [
     1798                        'role'    => 'system',
     1799                        'content' => $system_text,
     1800                    ],
     1801                    [
     1802                        'role'    => 'user',
     1803                        'content' => $q,
     1804                    ],
    17191805                ],
    1720                 [
    1721                     'role'    => 'user',
    1722                     'content' => $q,
     1806                'chat_template_kwargs' => [
     1807                    'enable_thinking' => false,
     1808                    'clear_thinking'  => true,
    17231809                ],
    1724             ],
    1725             'temperature' => 0.25,
    1726             'top_p'       => 0.85,
    1727             'stream'      => false,
    1728         ];
     1810                'temperature' => 0.25,
     1811                'top_p'       => 0.85,
     1812                'stream'      => false,
     1813            ];
    17291814        if ( '' !== $r['rk'] && '' !== $r['rv'] ) {
    17301815            $payload[ $r['rk'] ] = $r['rv'];
     
    18081893            }
    18091894
    1810             return [ $final_prompt, $final_style, '' ];
     1895            return [ $final_prompt, $final_style, '', false ];
    18111896        }
    18121897
     
    18221907            $final_style  = $is_auto_style ? ( $p_style ?: 'HD-HQ' ) : $sel;
    18231908            $final_negative_prompt = $p_negative_prompt ? $p_negative_prompt : '';
    1824             return [ $final_prompt, $final_style, $final_negative_prompt ];
     1909            return [ $final_prompt, $final_style, $final_negative_prompt, true ];
    18251910        }
    18261911
     
    18321917        $final_style = $is_auto_style ? 'HD-HQ' : $sel;
    18331918
    1834         return [ $final_prompt, $final_style, '' ];
     1919        return [ $final_prompt, $final_style, '', false ];
    18351920    }
    18361921
     
    19622047
    19632048    /**
     2049     * Persist slot runtime maps immediately to survive interrupted cron runs.
     2050     *
     2051     * @param int   $post_id  Post ID.
     2052     * @param array $states   Slot states map.
     2053     * @param array $jobs     Slot jobs map.
     2054     * @param array $attempts Slot attempts map.
     2055     */
     2056    private function persist_slot_runtime_maps( $post_id, array $states, array $jobs, array $attempts ) {
     2057        $this->save_slot_states_map( $post_id, $states );
     2058
     2059        if ( empty( $jobs ) ) {
     2060            delete_post_meta( $post_id, self::META_SLOT_JOBS );
     2061        } else {
     2062            update_post_meta( $post_id, self::META_SLOT_JOBS, $jobs );
     2063        }
     2064
     2065        if ( empty( $attempts ) ) {
     2066            delete_post_meta( $post_id, self::META_SLOT_ATTEMPTS );
     2067        } else {
     2068            update_post_meta( $post_id, self::META_SLOT_ATTEMPTS, $attempts );
     2069        }
     2070    }
     2071
     2072    /**
     2073     * Read PHP max_execution_time.
     2074     *
     2075     * @return int 0 means "no limit".
     2076     */
     2077    private function get_max_execution_time_seconds() {
     2078        $raw_exec = function_exists( 'ini_get' ) ? ini_get( 'max_execution_time' ) : '';
     2079        $max_exec = is_numeric( $raw_exec ) ? (int) $raw_exec : 0;
     2080        return max( 0, $max_exec );
     2081    }
     2082
     2083    /**
     2084     * Remaining runtime for the current watchdog cycle.
     2085     *
     2086     * @param float $watchdog_started_at Watchdog start timestamp (microtime(true)).
     2087     * @param int   $safety_margin       Seconds to keep as reserve.
     2088     * @return int Remaining seconds, or PHP_INT_MAX when max_execution_time is unlimited.
     2089     */
     2090    private function get_runtime_remaining_seconds( $watchdog_started_at = 0.0, $safety_margin = 0 ) {
     2091        $max_exec = $this->get_max_execution_time_seconds();
     2092        if ( $max_exec <= 0 ) {
     2093            return PHP_INT_MAX;
     2094        }
     2095
     2096        $elapsed = 0.0;
     2097        if ( $watchdog_started_at > 0 ) {
     2098            $elapsed = max( 0.0, microtime( true ) - (float) $watchdog_started_at );
     2099        }
     2100
     2101        $remaining = (int) floor( $max_exec - $elapsed - max( 0, (int) $safety_margin ) );
     2102        return max( 0, $remaining );
     2103    }
     2104
     2105    /**
     2106     * Whether there is enough runtime left to safely start one more remote request.
     2107     *
     2108     * @param float $watchdog_started_at Watchdog start timestamp (microtime(true)).
     2109     * @param int   $required_seconds    Required seconds to allow one more request.
     2110     * @return bool
     2111     */
     2112    private function has_runtime_for_next_request( $watchdog_started_at = 0.0, $required_seconds = 15 ) {
     2113        $required  = max( 1, (int) $required_seconds );
     2114        $remaining = $this->get_runtime_remaining_seconds( $watchdog_started_at, 0 );
     2115
     2116        if ( PHP_INT_MAX === $remaining ) {
     2117            return true;
     2118        }
     2119
     2120        return $remaining >= $required;
     2121    }
     2122
     2123    /**
     2124     * Compute a safe per-run slot budget based on PHP execution time limits.
     2125     *
     2126     * @param float $watchdog_started_at Watchdog start timestamp (microtime(true)); 0 for legacy/static mode.
     2127     * @param int   $per_slot_cost       Estimated cost per one remote request in seconds.
     2128     * @param int   $default             Upper cap.
     2129     * @return int
     2130     */
     2131    private function get_runtime_slot_budget( $watchdog_started_at = 0.0, $per_slot_cost = 0, $default = 10 ) {
     2132        $default       = max( 1, (int) $default );
     2133        $per_slot_cost = (int) $per_slot_cost;
     2134        if ( $per_slot_cost <= 0 ) {
     2135            $per_slot_cost = self::RUNTIME_START_COST_SEC;
     2136        }
     2137
     2138        // Legacy/static mode used outside watchdog-aware flow.
     2139        if ( $watchdog_started_at <= 0 ) {
     2140            $max_exec = $this->get_max_execution_time_seconds();
     2141            if ( $max_exec <= 0 ) {
     2142                return $default;
     2143            }
     2144            $available = max( 1, $max_exec - 5 );
     2145            $budget    = (int) floor( $available / max( 1, $per_slot_cost ) );
     2146            return max( 1, min( $default, $budget ) );
     2147        }
     2148
     2149        $remaining = $this->get_runtime_remaining_seconds( $watchdog_started_at, 0 );
     2150        if ( PHP_INT_MAX === $remaining ) {
     2151            return $default;
     2152        }
     2153
     2154        $budget = (int) floor( $remaining / max( 1, $per_slot_cost ) );
     2155        return max( 0, min( $default, $budget ) );
     2156    }
     2157
     2158    /**
     2159     * Compute a safe cap for how many slots can be started in one run.
     2160     *
     2161     * @param array|null $opts               Options array to check auto_enhance mode.
     2162     * @param float      $watchdog_started_at Watchdog start timestamp (microtime(true)); 0 for legacy/static mode.
     2163     * @return int
     2164     */
     2165    private function get_runtime_start_budget( $opts = null, $watchdog_started_at = 0.0 ) {
     2166        $default      = self::MAX_SLOTS_PER_START;
     2167        $o            = is_array( $opts ) ? $opts : $this->get_opts();
     2168        $auto_enhance = ! empty( $o['auto_enhance'] );
     2169        $per_slot_cost = $auto_enhance ? self::RUNTIME_START_COST_AUTO_ENHANCE_SEC : self::RUNTIME_START_COST_SEC;
     2170
     2171        return $this->get_runtime_slot_budget( (float) $watchdog_started_at, $per_slot_cost, $default );
     2172    }
     2173
     2174    /**
    19642175     * Collect all H2/H3/H4 headings from post content and return slots.
    19652176     *
     
    21582369        }
    21592370
     2371        // Prompt context.
     2372        $slot_prompt_context         = $this->get_slot_prompt_context_map( $post_id );
     2373        $slot_prompt_context_changed = false;
     2374        foreach ( $slot_prompt_context as $slot => $context ) {
     2375            if ( $this->is_heading_slot_key( $slot ) && ! isset( $allowed_map[ $slot ] ) ) {
     2376                unset( $slot_prompt_context[ $slot ] );
     2377                $slot_prompt_context_changed = true;
     2378            }
     2379        }
     2380        if ( $slot_prompt_context_changed ) {
     2381            $this->save_slot_prompt_context_map( $post_id, $slot_prompt_context );
     2382        }
     2383
    21602384        // Generated attachments per slot.
    21612385        if ( ! empty( $slots_to_purge ) ) {
     
    21962420        delete_post_meta( $post_id, self::META_GENERATED_ATTACHMENTS );
    21972421        delete_post_meta( $post_id, self::META_SLOTS_HASH );
     2422        delete_post_meta( $post_id, self::META_APPLY_ATTEMPTS );
     2423        delete_post_meta( $post_id, self::META_SLOT_PROMPT_CONTEXT );
    21982424
    21992425        $this->strip_plugin_figures_from_content( $post_id, $this->get_opts() );
     
    22492475                update_post_meta( $post_id, self::META_SLOT_CREATION_ATTEMPTS, $creation_attempts );
    22502476            }
     2477        }
     2478
     2479        $slot_prompt_context = $this->get_slot_prompt_context_map( $post_id );
     2480        if ( isset( $slot_prompt_context[ $slot ] ) ) {
     2481            unset( $slot_prompt_context[ $slot ] );
     2482            $this->save_slot_prompt_context_map( $post_id, $slot_prompt_context );
    22512483        }
    22522484    }
     
    23812613     * @param bool $force   Force regeneration flag.
    23822614     * @param int  $max_slots Maximum slots to start in this run (0 = no extra cap).
     2615     * @param float $watchdog_started_at Watchdog start timestamp (microtime(true)); 0 when not called from watchdog.
    23832616     */
    2384     private function start_generation_for_post( $post_id, $force = false, $max_slots = 0 ) {
    2385         $post_id = (int) $post_id;
    2386         $max_slots = max( 0, (int) $max_slots );
     2617    private function start_generation_for_post( $post_id, $force = false, $max_slots = 0, $watchdog_started_at = 0.0 ) {
     2618        $post_id             = (int) $post_id;
     2619        $max_slots           = max( 0, (int) $max_slots );
     2620        $watchdog_started_at = (float) $watchdog_started_at;
    23872621        if ( $post_id <= 0 || ! get_post( $post_id ) ) {
    23882622            return 0;
     
    24092643        $creation_attempts = is_array( $creation_attempts ) ? $creation_attempts : [];
    24102644        $creation_attempts_changed = false;
     2645        $slot_prompt_context = $this->get_slot_prompt_context_map( $post_id );
     2646        $slot_prompt_context_changed = false;
    24112647
    24122648        $slots_to_start = [];
     
    24952731        $had_slots_to_start = ! empty( $slots_to_start );
    24962732
    2497         // Limit number of slots per run.
    2498         if ( ! empty( $slots_to_start ) && count( $slots_to_start ) > self::MAX_SLOTS_PER_START ) {
    2499             $slots_to_start = array_slice( $slots_to_start, 0, self::MAX_SLOTS_PER_START );
    2500             $pending_retry  = true;
     2733        // Legacy/static mode: cap upfront by runtime estimate.
     2734        // Watchdog-aware mode decides continuation only after each remote request.
     2735        if ( $watchdog_started_at <= 0 ) {
     2736            $runtime_start_limit = $this->get_runtime_start_budget( $o, 0 );
     2737            if ( ! empty( $slots_to_start ) && count( $slots_to_start ) > $runtime_start_limit ) {
     2738                $slots_to_start = array_slice( $slots_to_start, 0, $runtime_start_limit );
     2739                $pending_retry  = true;
     2740            }
    25012741        }
    25022742        if ( $max_slots > 0 && ! empty( $slots_to_start ) && count( $slots_to_start ) > $max_slots ) {
     
    25072747        $started         = false;
    25082748        $attempts_dummy  = [];
     2749        $slot_request_cost = ! empty( $o['auto_enhance'] ) ? self::RUNTIME_START_COST_AUTO_ENHANCE_SEC : self::RUNTIME_START_COST_SEC;
     2750        $stop_due_runtime = false;
    25092751
    25102752        foreach ( $slots_to_start as $slot_info ) {
     
    25132755                break;
    25142756            }
    2515             $slot         = $slot_info['slot'];
    2516             $heading_text = $slot_info['heading_text'];
     2757            if ( $stop_due_runtime ) {
     2758                $pending_retry = true;
     2759                break;
     2760            }
     2761
     2762            $slot           = $slot_info['slot'];
     2763            $heading_text   = $slot_info['heading_text'];
    25172764            $start_attempts = isset( $creation_attempts[ $slot ] ) ? (int) $creation_attempts[ $slot ] : 0;
    25182765
    2519             list( $final_prompt, $final_style, $final_negative_prompt ) = $this->prepare_prompt_and_style( $post_id, $o, $heading_text );
     2766            list( $final_prompt, $final_style, $final_negative_prompt, $enhanced_prompt_used ) = $this->prepare_prompt_and_style( $post_id, $o, $heading_text );
    25202767
    25212768            $payload = [
     
    25372784                ]
    25382785            );
     2786            $stop_due_runtime = ( $watchdog_started_at > 0 && ! $this->has_runtime_for_next_request( $watchdog_started_at, $slot_request_cost ) );
    25392787
    25402788            if ( is_wp_error( $res ) ) {
     
    25462794                } else {
    25472795                    $creation_attempts[ $slot ] = $start_attempts;
    2548                     $pending_retry               = true;
     2796                    $pending_retry              = true;
    25492797                }
    25502798                $creation_attempts_changed = true;
     2799                if ( $stop_due_runtime ) {
     2800                    $pending_retry = true;
     2801                }
    25512802                continue;
    25522803            }
     
    25552806            $body = json_decode( wp_remote_retrieve_body( $res ), true );
    25562807
    2557                 if ( $code >= 200 && $code < 300 && is_array( $body ) ) {
    2558                     $job_id_raw = isset( $body[ self::FIELD_CREATION_ID ] ) ? (string) $body[ self::FIELD_CREATION_ID ] : '';
    2559                         if ( '' !== $job_id_raw ) {
     2808            if ( $code >= 200 && $code < 300 && is_array( $body ) ) {
     2809                $job_id_raw = isset( $body[ self::FIELD_CREATION_ID ] ) ? (string) $body[ self::FIELD_CREATION_ID ] : '';
     2810                if ( '' !== $job_id_raw ) {
    25602811                    $jobs[ $slot ]   = $job_id_raw;
    25612812                    $states[ $slot ] = 'generating';
    2562                     $started         = true;
     2813                    $slot_prompt_context[ $slot ] = [
     2814                        'prompt'   => trim( wp_strip_all_tags( (string) $final_prompt ) ),
     2815                        'enhanced' => $enhanced_prompt_used ? 1 : 0,
     2816                    ];
     2817                    $slot_prompt_context_changed = true;
     2818                    $started                     = true;
    25632819                    $started_slots++;
    25642820                    if ( isset( $creation_attempts[ $slot ] ) ) {
     
    25662822                        $creation_attempts_changed = true;
    25672823                    }
     2824                    if ( $stop_due_runtime ) {
     2825                        $pending_retry = true;
     2826                    }
    25682827                    continue;
    2569                         }
    2570                 }
    2571 
    2572                 // Some API nodes may reject optional negative_prompt; retry once without it.
    2573                 if ( isset( $payload['negative_prompt'] ) ) {
    2574                     $retry_payload = $payload;
    2575                     unset( $retry_payload['negative_prompt'] );
    2576 
    2577                     $retry_res = wp_remote_request(
    2578                         $this->build_url( self::EP_CREATION ),
    2579                         [
    2580                             'method'  => 'POST',
    2581                             'headers' => $this->http_headers( $o['api_key'], true ),
    2582                             'timeout' => self::HTTP_TIMEOUT_SEC,
    2583                             'body'    => wp_json_encode( $retry_payload, JSON_UNESCAPED_UNICODE ),
    2584                         ]
    2585                     );
    2586 
    2587                     if ( ! is_wp_error( $retry_res ) ) {
    2588                         $retry_code = wp_remote_retrieve_response_code( $retry_res );
    2589                         $retry_body = json_decode( wp_remote_retrieve_body( $retry_res ), true );
    2590 
    2591                         if ( $retry_code >= 200 && $retry_code < 300 && is_array( $retry_body ) ) {
    2592                             $job_id_raw = isset( $retry_body[ self::FIELD_CREATION_ID ] ) ? (string) $retry_body[ self::FIELD_CREATION_ID ] : '';
    2593                             if ( '' !== $job_id_raw ) {
    2594                                 $jobs[ $slot ]   = $job_id_raw;
    2595                                 $states[ $slot ] = 'generating';
    2596                                 $started         = true;
    2597                                 $started_slots++;
    2598                                 if ( isset( $creation_attempts[ $slot ] ) ) {
    2599                                     unset( $creation_attempts[ $slot ] );
    2600                                     $creation_attempts_changed = true;
    2601                                 }
    2602                                 continue;
     2828                }
     2829            }
     2830
     2831            // Some API nodes may reject optional negative_prompt; retry once without it.
     2832            if ( isset( $payload['negative_prompt'] ) ) {
     2833                if ( $stop_due_runtime ) {
     2834                    $pending_retry = true;
     2835                    continue;
     2836                }
     2837
     2838                $retry_payload = $payload;
     2839                unset( $retry_payload['negative_prompt'] );
     2840
     2841                $retry_res = wp_remote_request(
     2842                    $this->build_url( self::EP_CREATION ),
     2843                    [
     2844                        'method'  => 'POST',
     2845                        'headers' => $this->http_headers( $o['api_key'], true ),
     2846                        'timeout' => self::HTTP_TIMEOUT_SEC,
     2847                        'body'    => wp_json_encode( $retry_payload, JSON_UNESCAPED_UNICODE ),
     2848                    ]
     2849                );
     2850                $stop_due_runtime = ( $watchdog_started_at > 0 && ! $this->has_runtime_for_next_request( $watchdog_started_at, $slot_request_cost ) );
     2851
     2852                if ( ! is_wp_error( $retry_res ) ) {
     2853                    $retry_code = wp_remote_retrieve_response_code( $retry_res );
     2854                    $retry_body = json_decode( wp_remote_retrieve_body( $retry_res ), true );
     2855
     2856                    if ( $retry_code >= 200 && $retry_code < 300 && is_array( $retry_body ) ) {
     2857                        $job_id_raw = isset( $retry_body[ self::FIELD_CREATION_ID ] ) ? (string) $retry_body[ self::FIELD_CREATION_ID ] : '';
     2858                        if ( '' !== $job_id_raw ) {
     2859                            $jobs[ $slot ]   = $job_id_raw;
     2860                            $states[ $slot ] = 'generating';
     2861                            $slot_prompt_context[ $slot ] = [
     2862                                'prompt'   => trim( wp_strip_all_tags( (string) $final_prompt ) ),
     2863                                'enhanced' => $enhanced_prompt_used ? 1 : 0,
     2864                            ];
     2865                            $slot_prompt_context_changed = true;
     2866                            $started                     = true;
     2867                            $started_slots++;
     2868                            if ( isset( $creation_attempts[ $slot ] ) ) {
     2869                                unset( $creation_attempts[ $slot ] );
     2870                                $creation_attempts_changed = true;
    26032871                            }
     2872                            if ( $stop_due_runtime ) {
     2873                                $pending_retry = true;
     2874                            }
     2875                            continue;
    26042876                        }
    26052877                    }
    26062878                }
    2607 
    2608                 // If we reach here the slot failed.
    2609                 $start_attempts++;
     2879            }
     2880
     2881            // If we reach here the slot failed.
     2882            $start_attempts++;
    26102883            if ( $start_attempts >= self::MAX_CREATION_ATTEMPTS ) {
    26112884                $this->handle_slot_failure( $slot, $states, $jobs, $attempts_dummy );
     
    26132886            } else {
    26142887                $creation_attempts[ $slot ] = $start_attempts;
    2615                 $pending_retry               = true;
     2888                $pending_retry              = true;
    26162889            }
    26172890            $creation_attempts_changed = true;
     2891            if ( $stop_due_runtime ) {
     2892                $pending_retry = true;
     2893            }
    26182894        }
    26192895
     
    26342910        }
    26352911
     2912        if ( $slot_prompt_context_changed ) {
     2913            $this->save_slot_prompt_context_map( $post_id, $slot_prompt_context );
     2914        }
     2915
    26362916        // If we actually started something, set status to generating and schedule polling.
    26372917        if ( $started ) {
     
    26472927                wp_schedule_single_event( time(), self::CRON_HOOK_WATCHDOG, [] );
    26482928            }
     2929        } else {
     2930            // Nothing to start or everything is finished: treat as completed.
     2931            $has_done   = in_array( 'done', $states, true );
     2932            $has_failed = in_array( 'failed', $states, true );
     2933
     2934            if ( ! $had_slots_to_start && ! $has_done && ! $has_failed ) {
     2935                delete_post_meta( $post_id, self::META_STATUS );
    26492936            } else {
    2650                 // Nothing to start or everything is finished: treat as completed.
    2651                 $has_done = in_array( 'done', $states, true );
    2652                 $has_failed = in_array( 'failed', $states, true );
    2653 
    2654                 if ( ! $had_slots_to_start && ! $has_done && ! $has_failed ) {
    2655                     delete_post_meta( $post_id, self::META_STATUS );
    2656                 } else {
    2657                     update_post_meta( $post_id, self::META_STATUS, $has_failed ? 'failed' : 'done' );
    2658                 }
    2659                 delete_post_meta( $post_id, self::META_FORCE );
    2660             }
     2937                update_post_meta( $post_id, self::META_STATUS, $has_failed ? 'failed' : 'done' );
     2938            }
     2939            delete_post_meta( $post_id, self::META_FORCE );
     2940        }
    26612941
    26622942        delete_transient( $lock_key );
     
    26732953     * @param int $post_id Post ID.
    26742954     * @param int $max_slots Maximum slots/jobs to poll in this run (0 = no limit).
     2955     * @param float $watchdog_started_at Watchdog start timestamp (microtime(true)); 0 when not called from watchdog.
    26752956     */
    2676     public function cron_check_result( $post_id, $max_slots = 0 ) {
    2677         $post_id = (int) $post_id;
    2678         $max_slots = max( 0, (int) $max_slots );
     2957    public function cron_check_result( $post_id, $max_slots = 0, $watchdog_started_at = 0.0 ) {
     2958        $post_id             = (int) $post_id;
     2959        $max_slots           = max( 0, (int) $max_slots );
     2960        $watchdog_started_at = (float) $watchdog_started_at;
    26792961        if ( $post_id <= 0 || ! get_post( $post_id ) ) {
    26802962            return 0;
     
    27002982        $images = $this->get_heading_images_map( $post_id );
    27012983
     2984        // Heal stale/partial state so existing jobs are not skipped forever.
     2985        $runtime_maps_changed = false;
     2986        foreach ( $jobs as $slot => $remote_id_raw ) {
     2987            $slot      = (string) $slot;
     2988            $remote_id = trim( (string) $remote_id_raw );
     2989
     2990            if ( '' === $slot || '' === $remote_id ) {
     2991                unset( $jobs[ $slot ], $attempts[ $slot ] );
     2992                $runtime_maps_changed = true;
     2993                continue;
     2994            }
     2995
     2996            if ( $remote_id !== (string) $remote_id_raw ) {
     2997                $jobs[ $slot ] = $remote_id;
     2998                $runtime_maps_changed = true;
     2999            }
     3000
     3001            $slot_state = isset( $states[ $slot ] ) ? (string) $states[ $slot ] : '';
     3002            if ( in_array( $slot_state, [ 'done', 'failed' ], true ) ) {
     3003                unset( $jobs[ $slot ], $attempts[ $slot ] );
     3004                $runtime_maps_changed = true;
     3005                continue;
     3006            }
     3007
     3008            if ( 'generating' !== $slot_state ) {
     3009                $states[ $slot ] = 'generating';
     3010                $runtime_maps_changed = true;
     3011            }
     3012        }
     3013
     3014        if ( $runtime_maps_changed ) {
     3015            $this->persist_slot_runtime_maps( $post_id, $states, $jobs, $attempts );
     3016        }
     3017        if ( empty( $jobs ) ) {
     3018            return 0;
     3019        }
     3020
    27023021        $should_apply_content = false;
    27033022        $processed_slots      = 0;
     3023        $stop_due_runtime     = false;
    27043024
    27053025        foreach ( $jobs as $slot => $remote_id ) {
     
    27073027                break;
    27083028            }
     3029            if ( $stop_due_runtime ) {
     3030                break;
     3031            }
    27093032            $processed_slots++;
    2710             $slot       = (string) $slot;
    2711             $remote_id  = (string) $remote_id;
    2712             $slot_state = isset( $states[ $slot ] ) ? $states[ $slot ] : '';
    2713 
    2714             if ( 'generating' !== $slot_state || '' === $remote_id ) {
     3033            $slot      = (string) $slot;
     3034            $remote_id = (string) $remote_id;
     3035
     3036            if ( '' === $remote_id ) {
    27153037                continue;
    27163038            }
     
    27193041            if ( $slot_attempts >= self::MAX_ATTEMPTS ) {
    27203042                $this->handle_slot_failure( $slot, $states, $jobs, $attempts );
     3043                $this->persist_slot_runtime_maps( $post_id, $states, $jobs, $attempts );
    27213044                continue;
    27223045            }
     
    27243047            $slot_attempts++;
    27253048            $attempts[ $slot ] = $slot_attempts;
     3049            $this->persist_slot_runtime_maps( $post_id, $states, $jobs, $attempts );
    27263050
    27273051            $path = str_replace( '{creation_id}', rawurlencode( $remote_id ), self::EP_CREATION_BY_ID );
     
    27353059                ]
    27363060            );
     3061            $stop_due_runtime = ( $watchdog_started_at > 0 && ! $this->has_runtime_for_next_request( $watchdog_started_at, self::RUNTIME_POLL_COST_SEC ) );
    27373062
    27383063            if ( is_wp_error( $res ) ) {
     
    27443069            if ( 401 === $code || 404 === $code ) {
    27453070                $this->handle_slot_failure( $slot, $states, $jobs, $attempts );
     3071                $this->persist_slot_runtime_maps( $post_id, $states, $jobs, $attempts );
    27463072                continue;
    27473073            }
     
    27593085            if ( array_key_exists( 'success', $body ) && false === $body['success'] ) {
    27603086                $this->handle_slot_failure( $slot, $states, $jobs, $attempts );
     3087                $this->persist_slot_runtime_maps( $post_id, $states, $jobs, $attempts );
    27613088                continue;
    27623089            }
     
    27713098            if ( ! $first_url ) {
    27723099                $this->handle_slot_failure( $slot, $states, $jobs, $attempts );
     3100                $this->persist_slot_runtime_maps( $post_id, $states, $jobs, $attempts );
    27733101                continue;
    27743102            }
     
    27783106            if ( ! $attach_id ) {
    27793107                $this->handle_slot_failure( $slot, $states, $jobs, $attempts );
     3108                $this->persist_slot_runtime_maps( $post_id, $states, $jobs, $attempts );
    27803109                continue;
    27813110            }
     
    28043133            // Drop job and attempts for this slot.
    28053134            unset( $jobs[ $slot ], $attempts[ $slot ] );
     3135            $this->persist_slot_runtime_maps( $post_id, $states, $jobs, $attempts );
    28063136
    28073137            $should_apply_content = true;
     
    28333163        $more_slots     = $this->has_more_slots_to_start( $post_id, $force, $states, $images, $jobs, $o );
    28343164
    2835         if ( $more_slots ) {
    2836             $this->start_generation_for_post( $post_id, $force );
     3165            if ( $more_slots ) {
     3166                $start_budget = 0;
     3167                if ( $max_slots > 0 ) {
     3168                    $start_budget = max( 0, $max_slots - $processed_slots );
     3169                }
     3170
     3171                if ( 0 === $max_slots || $start_budget > 0 ) {
     3172                    $this->start_generation_for_post( $post_id, $force, $start_budget, $watchdog_started_at );
     3173                }
    28373174
    28383175            // Refresh state after attempting to start more slots.
     
    29043241    }
    29053242
     3243    private function get_slot_prompt_context_map( $post_id ) {
     3244        $raw = get_post_meta( $post_id, self::META_SLOT_PROMPT_CONTEXT, true );
     3245        if ( ! is_array( $raw ) ) {
     3246            return [];
     3247        }
     3248
     3249        $map = [];
     3250        foreach ( $raw as $slot => $context ) {
     3251            $key = (string) $slot;
     3252            if ( '' === $key || ! is_array( $context ) ) {
     3253                continue;
     3254            }
     3255
     3256            $prompt = isset( $context['prompt'] ) && is_string( $context['prompt'] ) ? trim( $context['prompt'] ) : '';
     3257            $map[ $key ] = [
     3258                'prompt'   => $prompt,
     3259                'enhanced' => ! empty( $context['enhanced'] ) ? 1 : 0,
     3260            ];
     3261        }
     3262
     3263        return $map;
     3264    }
     3265
     3266    private function save_slot_prompt_context_map( $post_id, array $map ) {
     3267        $clean = [];
     3268        foreach ( $map as $slot => $context ) {
     3269            $key = (string) $slot;
     3270            if ( '' === $key || ! is_array( $context ) ) {
     3271                continue;
     3272            }
     3273
     3274            $prompt = isset( $context['prompt'] ) && is_string( $context['prompt'] ) ? trim( $context['prompt'] ) : '';
     3275            $clean[ $key ] = [
     3276                'prompt'   => $prompt,
     3277                'enhanced' => ! empty( $context['enhanced'] ) ? 1 : 0,
     3278            ];
     3279        }
     3280
     3281        if ( empty( $clean ) ) {
     3282            delete_post_meta( $post_id, self::META_SLOT_PROMPT_CONTEXT );
     3283            return;
     3284        }
     3285
     3286        update_post_meta( $post_id, self::META_SLOT_PROMPT_CONTEXT, $clean );
     3287    }
     3288
     3289    private function get_slot_prompt_context( $post_id, $slot ) {
     3290        $slot = (string) $slot;
     3291        if ( '' === $slot ) {
     3292            $slot = 'featured';
     3293        }
     3294
     3295        $map = $this->get_slot_prompt_context_map( $post_id );
     3296        if ( isset( $map[ $slot ] ) && is_array( $map[ $slot ] ) ) {
     3297            return $map[ $slot ];
     3298        }
     3299
     3300        return [
     3301            'prompt'   => '',
     3302            'enhanced' => 0,
     3303        ];
     3304    }
     3305
     3306    private function get_heading_text_by_slot( $post_id, $slot ) {
     3307        $slot = (string) $slot;
     3308        if ( '' === $slot || ! $this->is_heading_slot_key( $slot ) ) {
     3309            return '';
     3310        }
     3311
     3312        $headings = $this->collect_heading_slots( $post_id );
     3313        foreach ( $headings as $heading ) {
     3314            $heading_slot = isset( $heading['slot'] ) ? (string) $heading['slot'] : '';
     3315            if ( $heading_slot !== $slot ) {
     3316                continue;
     3317            }
     3318
     3319            $text = isset( $heading['text'] ) && is_string( $heading['text'] ) ? trim( wp_strip_all_tags( $heading['text'] ) ) : '';
     3320            return $text;
     3321        }
     3322
     3323        return '';
     3324    }
     3325
     3326    private function build_attachment_title_for_slot( $post_id, $slot ) {
     3327        $slot       = (string) $slot;
     3328        $post_title = get_the_title( $post_id );
     3329        $post_title = is_string( $post_title ) ? trim( wp_strip_all_tags( $post_title ) ) : '';
     3330
     3331        if ( '' === $slot || 'featured' === $slot ) {
     3332            return $post_title;
     3333        }
     3334
     3335        $heading_title = $this->get_heading_text_by_slot( $post_id, $slot );
     3336        if ( '' !== $heading_title && '' !== $post_title ) {
     3337            return $heading_title . ' — ' . $post_title;
     3338        }
     3339        if ( '' !== $heading_title ) {
     3340            return $heading_title;
     3341        }
     3342
     3343        return $post_title;
     3344    }
     3345
     3346    private function apply_generated_attachment_texts( $post_id, $slot, $attach_id ) {
     3347        $post_id   = (int) $post_id;
     3348        $slot      = (string) $slot;
     3349        $attach_id = (int) $attach_id;
     3350
     3351        if ( $post_id <= 0 || $attach_id <= 0 ) {
     3352            return;
     3353        }
     3354
     3355        if ( '' === $slot ) {
     3356            $slot = 'featured';
     3357        }
     3358
     3359        $attachment_title = $this->build_attachment_title_for_slot( $post_id, $slot );
     3360        $attachment_title = trim( wp_strip_all_tags( (string) $attachment_title ) );
     3361        if ( '' === $attachment_title ) {
     3362            $attachment_title = __( 'ClipCloud generated image', 'clipcloud-image-generation' );
     3363        }
     3364
     3365        $alt_text = $attachment_title;
     3366        $context  = $this->get_slot_prompt_context( $post_id, $slot );
     3367        if ( ! empty( $context['enhanced'] ) ) {
     3368            $prompt = isset( $context['prompt'] ) && is_string( $context['prompt'] ) ? trim( wp_strip_all_tags( $context['prompt'] ) ) : '';
     3369            if ( '' !== $prompt ) {
     3370                $alt_text = $prompt;
     3371            }
     3372        }
     3373
     3374        if ( '' === $alt_text ) {
     3375            $alt_text = $attachment_title;
     3376        }
     3377
     3378        wp_update_post(
     3379            [
     3380                'ID'         => $attach_id,
     3381                'post_title' => $attachment_title,
     3382            ]
     3383        );
     3384
     3385        update_post_meta( $attach_id, '_wp_attachment_image_alt', $alt_text );
     3386    }
     3387
    29063388    private function save_generated_attachments_map( $post_id, array $map ) {
    29073389        $clean = [];
     
    29523434    private function download_and_attach( $url, $post_id, $slot ) {
    29533435        $slot = (string) $slot;
     3436        $attachment_title = $this->build_attachment_title_for_slot( $post_id, $slot );
     3437        $attachment_title = trim( wp_strip_all_tags( (string) $attachment_title ) );
     3438        if ( '' === $attachment_title ) {
     3439            $attachment_title = __( 'ClipCloud generated image', 'clipcloud-image-generation' );
     3440        }
    29543441
    29553442        require_once ABSPATH . 'wp-admin/includes/file.php';
     
    30343521            wp_delete_file( $tmp );
    30353522            $aid = (int) $recent[0];
     3523            $this->apply_generated_attachment_texts( $post_id, $slot, $aid );
    30363524            $this->track_generated_attachment( $post_id, $slot, $aid );
    30373525            return $aid;
     
    30433531        ];
    30443532
    3045         $attach_id = media_handle_sideload( $file_array, $post_id, __( 'ClipCloud generated image', 'clipcloud-image-generation' ) );
     3533        $attach_id = media_handle_sideload( $file_array, $post_id, $attachment_title );
    30463534        if ( is_wp_error( $attach_id ) ) {
    30473535            wp_delete_file( $file_array['tmp_name'] );
     
    30493537        }
    30503538        update_post_meta( $attach_id, '_ccfi_source_name', $filename );
     3539        $this->apply_generated_attachment_texts( $post_id, $slot, $attach_id );
    30513540        $this->track_generated_attachment( $post_id, $slot, $attach_id );
    30523541        return $attach_id;
     
    33963885            $has_jobs          = ! empty( $jobs );
    33973886
    3398             $is_pending = $has_active_status || $has_jobs || $has_generating_state;
    3399             if ( ! $is_pending && $apply_pending ) {
    3400                 // Apply pending is left for cron to handle; it should not keep browser polling.
    3401                 $apply_pending = false;
    3402             }
     3887            $is_pending = $has_active_status || $has_jobs || $has_generating_state || $apply_pending;
    34033888
    34043889            if ( $is_pending ) {
     
    37744259        $gen_h3 = ! empty( $opts['gen_h3'] );
    37754260        $gen_h4 = ! empty( $opts['gen_h4'] );
    3776 
    3777         if ( ! $gen_h2 && ! $gen_h3 && ! $gen_h4 ) {
     4261        $enabled_levels = [];
     4262        if ( $gen_h2 ) {
     4263            $enabled_levels[] = 'h2';
     4264        }
     4265        if ( $gen_h3 ) {
     4266            $enabled_levels[] = 'h3';
     4267        }
     4268        if ( $gen_h4 ) {
     4269            $enabled_levels[] = 'h4';
     4270        }
     4271
     4272        if ( empty( $enabled_levels ) ) {
    37784273            return $result;
    37794274        }
     
    37864281
    37874282        if ( $replace_existing ) {
     4283            $level_pattern = implode( '|', array_map( 'preg_quote', $enabled_levels ) );
    37884284            $patterns = [
    3789                 '~<!--\s*wp:image[^>]*ccfi-heading-figure[^>]*-->.*?<!--\s*/wp:image\s*-->~is',
    3790                 '~<figure[^>]*class="[^"]*ccfi-heading-figure[^"]*"[^>]*>.*?</figure>~is',
     4285                '~<!--\s*wp:image[^>]*ccfi-heading-figure-(?:' . $level_pattern . ')[^>]*-->.*?<!--\s*/wp:image\s*-->~is',
     4286                '~<figure[^>]*class="[^"]*ccfi-heading-figure-(?:' . $level_pattern . ')[^"]*"[^>]*>.*?</figure>~is',
    37914287            ];
    37924288            foreach ( $patterns as $pattern ) {
     
    38144310            $cursor = $start + strlen( $full );
    38154311
     4312            if ( '' === $text ) {
     4313                $output .= $full;
     4314                continue;
     4315            }
     4316
     4317            $slot = $this->build_slot_id( $level, $text, $counters );
     4318
     4319            // Keep slot counters in sync with collection logic even for disabled levels.
    38164320            if ( ( 2 === $level && ! $gen_h2 ) || ( 3 === $level && ! $gen_h3 ) || ( 4 === $level && ! $gen_h4 ) ) {
    38174321                $output .= $full;
    38184322                continue;
    38194323            }
    3820 
    3821             if ( '' === $text ) {
    3822                 $output .= $full;
    3823                 continue;
    3824             }
    3825 
    3826             $slot = $this->build_slot_id( $level, $text, $counters );
    38274324
    38284325            if ( empty( $map[ $slot ] ) ) {
     
    38724369    }
    38734370
    3874     private function is_plugin_image_block( $block ) {
     4371    private function is_plugin_image_block( $block, array $enabled_levels = [] ) {
    38754372        $haystack = '';
    38764373
     
    38854382        }
    38864383
    3887         return ( false !== strpos( $haystack, 'ccfi-heading-figure' ) );
     4384        if ( empty( $enabled_levels ) ) {
     4385            return ( false !== strpos( $haystack, 'ccfi-heading-figure' ) );
     4386        }
     4387
     4388        $level_pattern = implode( '|', array_map( 'preg_quote', $enabled_levels ) );
     4389        if ( '' === $level_pattern ) {
     4390            return ( false !== strpos( $haystack, 'ccfi-heading-figure' ) );
     4391        }
     4392
     4393        return (bool) preg_match( '~ccfi-heading-figure-(?:' . $level_pattern . ')~i', $haystack );
    38884394    }
    38894395
     
    39714477    private function process_blocks_for_injection( array $blocks, $opts, $map, array &$counters, $position, &$modified ) {
    39724478        $output = [];
     4479        $gen_h2 = ! empty( $opts['gen_h2'] );
     4480        $gen_h3 = ! empty( $opts['gen_h3'] );
     4481        $gen_h4 = ! empty( $opts['gen_h4'] );
     4482        $enabled_levels = [];
     4483        if ( $gen_h2 ) {
     4484            $enabled_levels[] = 'h2';
     4485        }
     4486        if ( $gen_h3 ) {
     4487            $enabled_levels[] = 'h3';
     4488        }
     4489        if ( $gen_h4 ) {
     4490            $enabled_levels[] = 'h4';
     4491        }
    39734492
    39744493        foreach ( $blocks as $block ) {
     
    39764495
    39774496            // Drop previously inserted plugin figures to avoid duplicates.
    3978             if ( 'core/image' === $name && $this->is_plugin_image_block( $block ) ) {
     4497            if ( 'core/image' === $name && $this->is_plugin_image_block( $block, $enabled_levels ) ) {
    39794498                $modified = true;
    39804499                continue;
     
    39974516                $slot = $this->build_slot_id( $level, $text, $counters );
    39984517
     4518                // Keep slot counters in sync with collection logic even for disabled levels.
     4519                if ( ( 2 === $level && ! $gen_h2 ) || ( 3 === $level && ! $gen_h3 ) || ( 4 === $level && ! $gen_h4 ) ) {
     4520                    $output[] = $block;
     4521                    continue;
     4522                }
     4523
    39994524                $image_block = null;
    40004525                if ( ! empty( $map[ $slot ] ) ) {
     
    40464571
    40474572    private function apply_heading_images_to_content( $post_id, $opts, $map ) {
     4573        $post_id        = (int) $post_id;
     4574        $apply_attempts = (int) get_post_meta( $post_id, self::META_APPLY_ATTEMPTS, true );
     4575
    40484576        if ( empty( $map ) ) {
    40494577            delete_post_meta( $post_id, self::META_APPLY_PENDING );
     4578            delete_post_meta( $post_id, self::META_APPLY_ATTEMPTS );
    40504579            return;
    40514580        }
     
    40614590        if ( ! $post ) {
    40624591            delete_post_meta( $post_id, self::META_APPLY_PENDING );
     4592            delete_post_meta( $post_id, self::META_APPLY_ATTEMPTS );
     4593            if ( isset( $lock_set ) && $lock_set ) {
     4594                delete_transient( $lock_key );
     4595            }
     4596            return;
     4597        }
     4598
     4599        $gen_h2 = ! empty( $opts['gen_h2'] );
     4600        $gen_h3 = ! empty( $opts['gen_h3'] );
     4601        $gen_h4 = ! empty( $opts['gen_h4'] );
     4602        if ( ! $gen_h2 && ! $gen_h3 && ! $gen_h4 ) {
     4603            delete_post_meta( $post_id, self::META_APPLY_PENDING );
     4604            delete_post_meta( $post_id, self::META_APPLY_ATTEMPTS );
     4605            if ( isset( $lock_set ) && $lock_set ) {
     4606                delete_transient( $lock_key );
     4607            }
     4608            return;
     4609        }
     4610
     4611        $heading_map = [];
     4612        foreach ( $map as $slot_key => $attachment_id ) {
     4613            $slot_key      = (string) $slot_key;
     4614            $attachment_id = (int) $attachment_id;
     4615            if ( $attachment_id <= 0 ) {
     4616                continue;
     4617            }
     4618            if ( $gen_h2 && 0 === strpos( $slot_key, 'h2:' ) ) {
     4619                $heading_map[ $slot_key ] = $attachment_id;
     4620            } elseif ( $gen_h3 && 0 === strpos( $slot_key, 'h3:' ) ) {
     4621                $heading_map[ $slot_key ] = $attachment_id;
     4622            } elseif ( $gen_h4 && 0 === strpos( $slot_key, 'h4:' ) ) {
     4623                $heading_map[ $slot_key ] = $attachment_id;
     4624            }
     4625        }
     4626
     4627        if ( empty( $heading_map ) ) {
     4628            delete_post_meta( $post_id, self::META_APPLY_PENDING );
     4629            delete_post_meta( $post_id, self::META_APPLY_ATTEMPTS );
    40634630            if ( isset( $lock_set ) && $lock_set ) {
    40644631                delete_transient( $lock_key );
     
    40874654        $is_block = function_exists( 'has_blocks' ) && has_blocks( $post );
    40884655
    4089         $has_heading_image = false;
    4090         foreach ( $map as $slot_key => $attachment_id ) {
    4091             if ( 0 === strpos( (string) $slot_key, 'h2:' ) || 0 === strpos( (string) $slot_key, 'h3:' ) || 0 === strpos( (string) $slot_key, 'h4:' ) ) {
    4092                 $has_heading_image = true;
    4093                 break;
    4094             }
    4095         }
    4096 
    4097         if ( ! $has_heading_image ) {
    4098             delete_post_meta( $post_id, self::META_APPLY_PENDING );
    4099             if ( isset( $lock_set ) && $lock_set ) {
    4100                 delete_transient( $lock_key );
    4101             }
    4102             return;
    4103         }
    4104 
    41054656        $current = (string) $post->post_content;
    4106         $render = $is_block ? $this->inject_images_into_blocks( $current, $opts, $map ) : $this->render_heading_images_markup( $current, $opts, $map, true );
     4657        $render  = $is_block ? $this->inject_images_into_blocks( $current, $opts, $heading_map ) : $this->render_heading_images_markup( $current, $opts, $heading_map, true );
    41074658
    41084659        // Fallback: if block injection did not change content, try HTML-based injection.
    41094660        if ( $is_block && ( ! $render['modified'] || $render['content'] === $current ) ) {
    4110             $render = $this->render_heading_images_markup( $current, $opts, $map, true );
     4661            $render = $this->render_heading_images_markup( $current, $opts, $heading_map, true );
    41114662        }
    41124663
    41134664        if ( ! $render['modified'] || $render['content'] === $current ) {
    4114             // Keep apply pending and reschedule to try again later.
    4115             update_post_meta( $post_id, self::META_APPLY_PENDING, 1 );
    4116             if ( ! wp_next_scheduled( self::CRON_HOOK_WATCHDOG ) ) {
    4117                 wp_schedule_single_event( time() + self::POLL_INTERVAL_SEC, self::CRON_HOOK_WATCHDOG, [] );
     4665            $apply_attempts++;
     4666            if ( $apply_attempts >= self::MAX_APPLY_ATTEMPTS ) {
     4667                // Stop infinite retries when content cannot be updated automatically.
     4668                delete_post_meta( $post_id, self::META_APPLY_PENDING );
     4669                delete_post_meta( $post_id, self::META_APPLY_ATTEMPTS );
     4670            } else {
     4671                update_post_meta( $post_id, self::META_APPLY_PENDING, 1 );
     4672                update_post_meta( $post_id, self::META_APPLY_ATTEMPTS, $apply_attempts );
     4673                if ( ! wp_next_scheduled( self::CRON_HOOK_WATCHDOG ) ) {
     4674                    wp_schedule_single_event( time() + self::POLL_INTERVAL_SEC, self::CRON_HOOK_WATCHDOG, [] );
     4675                }
    41184676            }
    41194677            if ( isset( $lock_set ) && $lock_set ) {
     
    41304688        );
    41314689        delete_post_meta( $post_id, self::META_APPLY_PENDING );
     4690        delete_post_meta( $post_id, self::META_APPLY_ATTEMPTS );
    41324691        if ( isset( $lock_set ) && $lock_set ) {
    41334692            delete_transient( $lock_key );
    41344693        }
     4694
    41354695    }
    41364696
     
    41514711        $start_time = microtime( true );
    41524712        $should_reschedule = false;
    4153         $remaining_slots   = 10;
    41544713
    41554714        $posts = get_posts(
     
    41924751        if ( ! empty( $posts ) ) {
    41934752            foreach ( $posts as $post_id ) {
    4194                 if ( $remaining_slots <= 0 ) {
    4195                     $should_reschedule = true;
    4196                     break;
    4197                 }
    41984753                $post_id = (int) $post_id;
    41994754                if ( $post_id <= 0 ) {
     
    42194774                if ( 'queued' === $status ) {
    42204775                    $force = (bool) get_post_meta( $post_id, self::META_FORCE, true );
    4221                     $started = $this->start_generation_for_post( $post_id, $force, $remaining_slots );
    4222                     $remaining_slots = max( 0, $remaining_slots - $started );
     4776                    $this->start_generation_for_post( $post_id, $force, 0, $start_time );
    42234777                } elseif ( 'generating' === $status ) {
    4224                     $processed = $this->cron_check_result( $post_id, $remaining_slots );
    4225                     $remaining_slots = max( 0, $remaining_slots - $processed );
     4778                    $this->cron_check_result( $post_id, 0, $start_time );
    42264779                }
    42274780
    42284781                $status_after = get_post_meta( $post_id, self::META_STATUS, true );
    4229                 if ( $apply_pending ) {
     4782                $apply_pending_after = (bool) get_post_meta( $post_id, self::META_APPLY_PENDING, true );
     4783                if ( $apply_pending_after ) {
    42304784                    $should_reschedule = true;
    42314785                } elseif ( in_array( $status_after, [ 'queued', 'generating' ], true ) ) {
     
    42334787                }
    42344788
    4235                 if ( microtime( true ) - $start_time > 15 ) {
    4236                     $should_reschedule = true;
    4237                     break;
    4238                 }
    42394789            }
    42404790        }
  • clipcloud-image-generation/trunk/languages/readme-da_DK.po

    r3455057 r3490444  
    2929#: readme.txt:15
    3030msgid "* Don’t download images from other sites because you want to avoid copyright claims?"
    31 msgstr "* Downloader du ikke billeder fra andre sider for at undgå ophavsretskrav?**"
     31msgstr "* Downloader du ikke billeder fra andre sider for at undgå ophavsretskrav?"
    3232
    3333#: readme.txt:16
    3434msgid "* Tired of generating images manually via ChatGPT or similar services: coming up with prompts, waiting, then uploading images by hand?"
    35 msgstr "* Træt af at generere billeder manuelt via ChatGPT eller lignende: finde på prompts, vente, og så uploade billederne i hånden?*"
     35msgstr "* Træt af at generere billeder manuelt via ChatGPT eller lignende: finde på prompts, vente, og så uploade billederne i hånden?"
    3636
    3737#: readme.txt:17
    3838msgid "* Other auto-generation plugins are too expensive or have confusing pricing?"
    39 msgstr "* Er andre auto-genereringsplugins for dyre eller har uklare priser?*"
     39msgstr "* Er andre auto-genereringsplugins for dyre eller har uklare priser?"
    4040
    4141#: readme.txt:19
  • clipcloud-image-generation/trunk/languages/readme-fr_FR.po

    r3455057 r3490444  
    44msgstr ""
    55"PO-Revision-Date: 2025-12-15 00:00+0100\n"
     6"Last-Translator: \n"
     7"Language-Team: \n"
    68"MIME-Version: 1.0\n"
    79"Content-Type: text/plain; charset=UTF-8\n"
  • clipcloud-image-generation/trunk/readme.txt

    r3460288 r3490444  
    33Tags: AI, midjourney, featured image, image generation, thumbnail
    44Requires at least: 6.0
    5 Tested up to: 6.9
     5Tested up to: 7.0
    66Requires PHP: 7.0
    7 Stable tag: 1.1.2
     7Stable tag: 1.2.0
    88License: GPL-2.0-or-later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    9090
    9191== Changelog ==
     92= 1.2.0 =
     93* The image title and alt tag now contain much more useful information. This is useful for website SEO.
     94* Image generation has been significantly accelerated.
     95* Fixed a very rare issue where one image might not be generated.
     96* Fixed API key saving in some WordPress installations.
     97* Other minor fixes and improvements.
     98
    9299= 1.1.2 =
    93100* Another improvement to automatic prompt enhancement.
Note: See TracChangeset for help on using the changeset viewer.