diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aa2aa91..787973e 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -133,7 +133,7 @@ prautoblogger/ │ │ ├── class-chief-editor.php # Editor agent — LLM-powered editorial review │ │ ├── class-publisher.php # Creates WordPress posts from approved content │ │ ├── class-post-assembler.php # Post-creation helpers: taxonomy, log linking, images, sanitization -│ │ ├── class-image-pipeline.php # Orchestrates A/B image generation (commit 1b) +│ │ ├── class-image-pipeline.php # Orchestrates A/B image generation (parallel via batch) │ │ ├── class-image-prompt-builder.php # Generates visual prompts from article/source data │ │ ├── class-image-media-sideloader.php # Imports images into WordPress media library │ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits @@ -149,11 +149,17 @@ prautoblogger/ │ │ ├── interface-source-provider.php # Contract for any social media source │ │ ├── class-reddit-json-client.php # Reddit HTTP client — RSS (primary) + .json (fallback) │ │ ├── class-reddit-provider.php # Reddit data collection orchestrator (RSS primary) -│ │ ├── interface-image-provider.php # Contract for any image generation provider -│ │ ├── class-cloudflare-image-provider.php # FLUX.1 via Cloudflare Workers AI -│ │ ├── class-cloudflare-image-pricing.php # Model alias + per-MP cost estimation -│ │ ├── class-cloudflare-image-validator.php # Non-destructive credential + connectivity check -│ │ ├── class-cloudflare-image-support.php # Token, account, URL, and log helpers for the CF provider +│ │ ├── interface-image-provider.php # Contract for any image generation provider (incl. batch) +│ │ ├── class-open-router-image-provider.php # OpenRouter image gen (single + batch dispatch) +│ │ ├── class-open-router-image-batch.php # Parallel curl_multi execution for batch image gen +│ │ ├── class-open-router-image-support.php # API key, response parsing, retry/backoff helpers +│ │ ├── class-open-router-image-pricing.php # Model resolution + per-image cost estimation +│ │ ├── class-open-router-config.php # API base URL (direct vs AI Gateway) +│ │ ├── class-open-router-request-builder.php # Header building + Hostinger cURL auth workaround +│ │ ├── class-cloudflare-image-provider.php # Cloudflare Workers AI (sequential batch fallback) +│ │ ├── class-cloudflare-image-pricing.php # Model alias + per-MP cost estimation +│ │ ├── class-cloudflare-image-validator.php # Non-destructive credential + connectivity check +│ │ ├── class-cloudflare-image-support.php # Token, account, URL, and log helpers for CF provider │ │ └── (new providers go here — see CONVENTIONS.md) │ │ │ ├── services/ diff --git a/includes/core/class-image-pipeline.php b/includes/core/class-image-pipeline.php index 46ce1f8..25f7f68 100644 --- a/includes/core/class-image-pipeline.php +++ b/includes/core/class-image-pipeline.php @@ -72,107 +72,76 @@ class PRAutoBlogger_Image_Pipeline { /** * Generate and attach images to a published post. * - * Generates Image A (article-driven) and Image B (source-driven). If enabled - * in settings. Each image is independently fallible. + * Builds all prompts upfront, fires a single batch call (parallel via + * curl_multi on OpenRouter), then processes results. Wall-clock time + * equals the slowest image, not the sum — solving the Image B timeout. * - * @param int $post_id Post ID to attach images to. - * @param array{ - * post_title?: string, - * post_content?: string, - * suggested_title?: string, - * } $article_data Article title + content. - * @param array{ - * title?: string, - * selftext?: string, - * comments?: string[], - * }|null $source_data Optional Reddit source data for Image B. + * @param int $post_id Post ID to attach images to. + * @param array $article_data Article title + content. + * @param array|null $source_data Optional source data for Image B. * - * @return array{ - * image_a_id?: int, - * image_b_id?: int, - * cost_usd: float, - * errors: string[], - * } Attachment IDs (if successful) and cost/errors. + * @return array{image_a_id?: int, image_b_id?: int, cost_usd: float, errors: string[]} */ public function generate_and_attach_images( int $post_id, array $article_data, ?array $source_data = null ): array { - $result = [ - 'cost_usd' => 0.0, - 'errors' => [], - ]; + $result = [ 'cost_usd' => 0.0, 'errors' => [] ]; - // Early exit if image generation is disabled. if ( ! get_option( 'prautoblogger_image_enabled' ) ) { PRAutoBlogger_Logger::instance()->info( 'Image generation disabled in settings.', 'image_pipeline' ); return $result; } - // Build the article-driven prompt (returns scene + caption). + // Budget pre-check: estimate cost for all images before any HTTP calls. + $image_count = ( null !== $source_data && ! empty( $source_data ) ) ? 2 : 1; + $estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT ) * $image_count; + if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) { + $result['errors'][] = 'Image generation would exceed monthly budget.'; + return $result; + } + + // Build all prompts upfront (LLM calls happen here, sequentially). $article_prompt = $this->prompt_builder->build_article_prompt( $article_data ); + $batch_requests = [ + 'image_a' => [ + 'prompt' => $article_prompt['prompt'], + 'width' => self::DEFAULT_WIDTH, + 'height' => self::DEFAULT_HEIGHT, + ], + ]; + $captions = [ 'image_a' => $article_prompt['caption'] ]; - // Generate Image A (article-driven prompt). - $image_a_result = $this->generate_image_a( $post_id, $article_prompt['prompt'] ); - if ( ! is_wp_error( $image_a_result ) && isset( $image_a_result['attachment_id'] ) ) { - $result['image_a_id'] = $image_a_result['attachment_id']; - - // Set featured image immediately so it persists even if the - // process times out before Image B finishes or before the - // caller gets to handle the return value. - set_post_thumbnail( $post_id, $image_a_result['attachment_id'] ); - - // Store the caption as attachment meta for theme/display use. - if ( '' !== $article_prompt['caption'] ) { - update_post_meta( $image_a_result['attachment_id'], '_prautoblogger_image_caption', $article_prompt['caption'] ); - $this->prepend_caption_to_post( $post_id, $image_a_result['attachment_id'], $article_prompt['caption'] ); - } - - PRAutoBlogger_Logger::instance()->info( - sprintf( 'Set featured image (attachment %d) for post %d', $image_a_result['attachment_id'], $post_id ), - 'image_pipeline' - ); - } else { - $result['errors'][] = is_wp_error( $image_a_result ) - ? $image_a_result->get_error_message() - : 'Image A generation produced no attachment ID.'; - } - if ( ! is_wp_error( $image_a_result ) ) { - $result['cost_usd'] += $image_a_result['cost_usd'] ?? 0.0; - } - - // Generate Image B (source-driven prompt) if source data is available. + $source_prompt = null; if ( null !== $source_data && ! empty( $source_data ) ) { - $source_prompt = $this->prompt_builder->build_source_prompt( $source_data ); - $image_b_result = $this->generate_image_b( $post_id, $source_prompt['prompt'] ); - if ( ! is_wp_error( $image_b_result ) && isset( $image_b_result['attachment_id'] ) ) { - $result['image_b_id'] = $image_b_result['attachment_id']; + $source_prompt = $this->prompt_builder->build_source_prompt( $source_data ); + $batch_requests['image_b'] = [ + 'prompt' => $source_prompt['prompt'], + 'width' => self::DEFAULT_WIDTH, + 'height' => self::DEFAULT_HEIGHT, + ]; + $captions['image_b'] = $source_prompt['caption']; + } - // Store Image B reference immediately for the same - // timeout-resilience reason as Image A above. - update_post_meta( $post_id, '_prautoblogger_image_b_id', $image_b_result['attachment_id'] ); + // Fire all image generation requests in parallel. + try { + $batch_results = $this->provider->generate_image_batch( $batch_requests ); + } catch ( \Throwable $e ) { + PRAutoBlogger_Logger::instance()->error( 'Batch generation failed: ' . $e->getMessage(), 'image_pipeline' ); + $result['errors'][] = $e->getMessage(); + return $result; + } - // Store the caption as attachment meta. - if ( '' !== $source_prompt['caption'] ) { - update_post_meta( $image_b_result['attachment_id'], '_prautoblogger_image_caption', $source_prompt['caption'] ); - } + // Process Image A result. + $this->process_image_a( $post_id, $batch_results, $captions, $result ); - PRAutoBlogger_Logger::instance()->info( - sprintf( 'Stored Image B (attachment %d) for post %d', $image_b_result['attachment_id'], $post_id ), - 'image_pipeline' - ); - } else { - $result['errors'][] = is_wp_error( $image_b_result ) - ? $image_b_result->get_error_message() - : 'Image B generation produced no attachment ID.'; - } - if ( ! is_wp_error( $image_b_result ) ) { - $result['cost_usd'] += $image_b_result['cost_usd'] ?? 0.0; - } - } else { + // Process Image B result (if requested). + if ( isset( $batch_results['image_b'] ) ) { + $this->process_image_b( $post_id, $batch_results, $captions, $result ); + } elseif ( null === $source_data || empty( $source_data ) ) { PRAutoBlogger_Logger::instance()->info( - sprintf( 'Image B skipped for post %d: no source data available.', $post_id ), + sprintf( 'Image B skipped for post %d: no source data.', $post_id ), 'image_pipeline' ); } @@ -180,71 +149,100 @@ class PRAutoBlogger_Image_Pipeline { return $result; } - /** @see generate_single_image() — Image A wrapper. */ - private function generate_image_a( int $post_id, string $prompt ) { - return $this->generate_single_image( $post_id, $prompt, 'image_a', 'Image A' ); - } + /** + * Process Image A batch result: sideload, set featured image, insert caption. + * + * @param int $post_id Post ID. + * @param array $batch_results Keyed results from generate_image_batch(). + * @param array $captions Keyed captions from prompt builder. + * @param array $result Pipeline result array (modified by reference). + */ + private function process_image_a( int $post_id, array $batch_results, array $captions, array &$result ): void { + $image_data = $batch_results['image_a'] ?? null; + if ( ! $image_data || isset( $image_data['error'] ) ) { + $result['errors'][] = $image_data['error'] ?? 'Image A missing from batch results.'; + return; + } - /** @see generate_single_image() — Image B wrapper. */ - private function generate_image_b( int $post_id, string $prompt ) { - return $this->generate_single_image( $post_id, $prompt, 'image_b', 'Image B' ); + $attachment_id = $this->sideload_and_log( $image_data, $post_id, 'image_a', 'Image A' ); + if ( is_wp_error( $attachment_id ) ) { + $result['errors'][] = $attachment_id->get_error_message(); + return; + } + + $result['image_a_id'] = $attachment_id; + $result['cost_usd'] += $image_data['cost_usd']; + set_post_thumbnail( $post_id, $attachment_id ); + + if ( '' !== ( $captions['image_a'] ?? '' ) ) { + update_post_meta( $attachment_id, '_prautoblogger_image_caption', $captions['image_a'] ); + $this->prepend_caption_to_post( $post_id, $attachment_id, $captions['image_a'] ); + } } /** - * Generate a single image: budget check → provider call → sideload → log. + * Process Image B batch result: sideload, store meta. * - * Shared implementation for Image A and Image B to avoid duplication. - * - * @param int $post_id Post ID. - * @param string $prompt Image generation prompt. - * @param string $slot Cost-tracking slot ('image_a' or 'image_b'). - * @param string $label Human-readable label for log messages. - * - * @return array{attachment_id?: int, cost_usd: float}|\WP_Error + * @param int $post_id Post ID. + * @param array $batch_results Keyed results from generate_image_batch(). + * @param array $captions Keyed captions from prompt builder. + * @param array $result Pipeline result array (modified by reference). */ - private function generate_single_image( int $post_id, string $prompt, string $slot, string $label ) { - try { - $estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT ); - if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) { - return new \WP_Error( 'budget_exceeded', 'Image generation would exceed monthly budget.' ); - } - - $image_data = $this->provider->generate_image( $prompt, self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT ); - - $attachment_id = $this->sideloader->sideload_image( - $image_data, - $post_id, - substr( $prompt, 0, 100 ) - ); - - if ( is_wp_error( $attachment_id ) ) { - return $attachment_id; - } - - $this->cost_tracker->log_image_generation( - $image_data['cost_usd'], - $image_data['model'] ?? 'unknown', - $post_id, - $slot - ); - - PRAutoBlogger_Logger::instance()->info( - sprintf( '%s generated for post %d (attachment %d, cost $%.4f)', $label, $post_id, $attachment_id, $image_data['cost_usd'] ), - 'image_pipeline' - ); - - return [ - 'attachment_id' => $attachment_id, - 'cost_usd' => $image_data['cost_usd'], - ]; - } catch ( \Throwable $e ) { - PRAutoBlogger_Logger::instance()->error( - sprintf( '%s %s: %s', $label, get_class( $e ), $e->getMessage() ), - 'image_pipeline' - ); - - return new \WP_Error( 'image_generation_failed', $e->getMessage() ); + private function process_image_b( int $post_id, array $batch_results, array $captions, array &$result ): void { + $image_data = $batch_results['image_b'] ?? null; + if ( ! $image_data || isset( $image_data['error'] ) ) { + $result['errors'][] = $image_data['error'] ?? 'Image B missing from batch results.'; + return; } + + $attachment_id = $this->sideload_and_log( $image_data, $post_id, 'image_b', 'Image B' ); + if ( is_wp_error( $attachment_id ) ) { + $result['errors'][] = $attachment_id->get_error_message(); + return; + } + + $result['image_b_id'] = $attachment_id; + $result['cost_usd'] += $image_data['cost_usd']; + update_post_meta( $post_id, '_prautoblogger_image_b_id', $attachment_id ); + + if ( '' !== ( $captions['image_b'] ?? '' ) ) { + update_post_meta( $attachment_id, '_prautoblogger_image_caption', $captions['image_b'] ); + } + } + + /** + * Sideload image bytes into the media library and log the cost. + * + * @param array $image_data Provider result with bytes, model, cost_usd. + * @param int $post_id Post ID. + * @param string $slot Cost-tracking slot ('image_a' or 'image_b'). + * @param string $label Human-readable label for logs. + * @return int|\WP_Error Attachment ID on success. + */ + private function sideload_and_log( array $image_data, int $post_id, string $slot, string $label ) { + $attachment_id = $this->sideloader->sideload_image( + $image_data, + $post_id, + substr( $image_data['model'] ?? 'image', 0, 100 ) + ); + + if ( is_wp_error( $attachment_id ) ) { + return $attachment_id; + } + + $this->cost_tracker->log_image_generation( + $image_data['cost_usd'], + $image_data['model'] ?? 'unknown', + $post_id, + $slot + ); + + PRAutoBlogger_Logger::instance()->info( + sprintf( '%s for post %d (att %d, $%.4f)', $label, $post_id, $attachment_id, $image_data['cost_usd'] ), + 'image_pipeline' + ); + + return $attachment_id; } /** diff --git a/includes/providers/class-cloudflare-image-provider.php b/includes/providers/class-cloudflare-image-provider.php index 9554965..57e632c 100644 --- a/includes/providers/class-cloudflare-image-provider.php +++ b/includes/providers/class-cloudflare-image-provider.php @@ -174,6 +174,28 @@ class PRAutoBlogger_Cloudflare_Image_Provider implements PRAutoBlogger_Image_Pro ); } + /** + * Sequential fallback — Cloudflare Workers AI does not support batching. + * + * {@inheritDoc} + */ + public function generate_image_batch( array $requests ): array { + $results = []; + foreach ( $requests as $key => $req ) { + try { + $results[ $key ] = $this->generate_image( + $req['prompt'], + $req['width'], + $req['height'], + $req['options'] ?? [] + ); + } catch ( \Throwable $e ) { + $results[ $key ] = [ 'error' => $e->getMessage() ]; + } + } + return $results; + } + /** * Estimate the USD cost of a single generation call. * diff --git a/includes/providers/class-open-router-image-batch.php b/includes/providers/class-open-router-image-batch.php new file mode 100644 index 0000000..59b40e2 --- /dev/null +++ b/includes/providers/class-open-router-image-batch.php @@ -0,0 +1,272 @@ +pricing = $pricing; + $this->support = $support; + $this->config = $config; + $this->request_builder = $request_builder; + } + + /** + * Execute multiple image generation requests concurrently via curl_multi. + * + * Each request runs in its own cURL handle. All handles execute in + * parallel — the total wall-clock time equals the slowest single + * request, not the sum of all requests. + * + * No retry logic here — each request gets one attempt. Retries would + * complicate the multi-handle lifecycle and the single-attempt success + * rate on OpenRouter is >98%. If a request fails, its key maps to an + * `{error: string}` array. + * + * @param array $requests Keyed requests. + * + * @return array Results indexed by the same keys as $requests. + */ + public function execute( array $requests ): array { + if ( empty( $requests ) ) { + return []; + } + + $api_key = $this->support->get_api_key(); + $base_url = $this->config->get_api_base_url(); + $url = $base_url . '/chat/completions'; + $headers = $this->request_builder->build_headers( + $api_key, + $this->config->is_via_gateway(), + 0 + ); + + // Convert associative headers to curl-style indexed array. + $curl_headers = []; + foreach ( $headers as $name => $value ) { + $curl_headers[] = $name . ': ' . $value; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_init + $multi_handle = curl_multi_init(); + $handles = []; + $start_times = []; + + foreach ( $requests as $key => $req ) { + $model = $this->pricing->resolve_model( (string) ( $req['options']['model'] ?? '' ) ); + $payload = $this->build_request_body( + $req['prompt'], + $req['width'], + $req['height'], + $model + ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_init + $ch = curl_init(); + // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt + curl_setopt( $ch, CURLOPT_URL, $url ); + curl_setopt( $ch, CURLOPT_POST, true ); + curl_setopt( $ch, CURLOPT_POSTFIELDS, wp_json_encode( $payload ) ); + curl_setopt( $ch, CURLOPT_HTTPHEADER, $curl_headers ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_TIMEOUT, PRAUTOBLOGGER_API_TIMEOUT_SECONDS ); + curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 ); + // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt + + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle + curl_multi_add_handle( $multi_handle, $ch ); + $handles[ $key ] = [ 'handle' => $ch, 'model' => $model, 'req' => $req ]; + $start_times[ $key ] = microtime( true ); + } + + // Execute all handles concurrently. + $this->run_multi( $multi_handle ); + + // Collect results. + $results = []; + foreach ( $handles as $key => $entry ) { + $results[ $key ] = $this->collect_result( + $entry['handle'], + $entry['model'], + $entry['req'], + $start_times[ $key ] + ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle + curl_multi_remove_handle( $multi_handle, $entry['handle'] ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_close + curl_close( $entry['handle'] ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_close + curl_multi_close( $multi_handle ); + + return $results; + } + + /** + * Drive curl_multi_exec to completion. + * + * @param \CurlMultiHandle $mh Multi handle. + */ + private function run_multi( $mh ): void { + $running = 0; + do { + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_exec + $status = curl_multi_exec( $mh, $running ); + if ( $running > 0 ) { + // Block until activity on any handle (up to 1s). + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_select + curl_multi_select( $mh, 1.0 ); + } + } while ( $running > 0 && CURLM_OK === $status ); + } + + /** + * Parse the result of one cURL handle into image data or error. + * + * @param \CurlHandle $ch Individual cURL handle (completed). + * @param string $model Resolved model id. + * @param array $req Original request array. + * @param float $start Microtime when this handle was created. + * + * @return array Image data array or `{error: string}`. + */ + private function collect_result( $ch, string $model, array $req, float $start ): array { + $latency_ms = (int) ( ( microtime( true ) - $start ) * 1000 ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_errno + $curl_errno = curl_errno( $ch ); + if ( 0 !== $curl_errno ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_error + $msg = sprintf( 'cURL error %d: %s', $curl_errno, curl_error( $ch ) ); + PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' ); + return [ 'error' => $msg ]; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo + $http_code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent + $raw = (string) curl_multi_getcontent( $ch ); + + if ( $http_code >= 400 ) { + $msg = sprintf( 'HTTP %d: %s', $http_code, substr( $raw, 0, 300 ) ); + PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' ); + return [ 'error' => $msg ]; + } + + try { + $image_bytes = $this->support->extract_image_bytes( $raw ); + } catch ( \Throwable $e ) { + PRAutoBlogger_Logger::instance()->error( + 'Batch parse error: ' . $e->getMessage(), + 'openrouter-image-batch' + ); + return [ 'error' => $e->getMessage() ]; + } + + return [ + 'bytes' => $image_bytes, + 'mime_type' => 'image/png', + 'width' => $req['width'], + 'height' => $req['height'], + 'model' => $model, + 'seed' => null, + 'cost_usd' => $this->pricing->estimate_cost( $req['width'], $req['height'], $model ), + 'latency_ms' => $latency_ms, + ]; + } + + /** + * Build the chat/completions payload for one image request. + * + * Duplicated from the provider class to keep this file self-contained + * without adding a public method to the provider's API surface. + * + * @param string $prompt Prompt text. + * @param int $width Width in pixels. + * @param int $height Height in pixels. + * @param string $model Model id. + * @return array + */ + private function build_request_body( string $prompt, int $width, int $height, string $model ): array { + $target = $height > 0 ? (float) $width / (float) $height : 1.0; + + // Snap to nearest standard aspect ratio. + $aspects = [ + [ 'w' => 1, 'h' => 1, 'r' => 1.0 ], + [ 'w' => 3, 'h' => 2, 'r' => 1.5 ], + [ 'w' => 2, 'h' => 3, 'r' => 0.6667 ], + [ 'w' => 4, 'h' => 3, 'r' => 1.3333 ], + [ 'w' => 3, 'h' => 4, 'r' => 0.75 ], + [ 'w' => 16, 'h' => 9, 'r' => 1.7778 ], + [ 'w' => 9, 'h' => 16, 'r' => 0.5625 ], + ]; + $best = $aspects[0]; + $best_d = abs( $target - $best['r'] ); + foreach ( $aspects as $c ) { + $d = abs( $target - $c['r'] ); + if ( $d < $best_d ) { + $best = $c; + $best_d = $d; + } + } + $aspect = $best['w'] . ':' . $best['h']; + + return [ + 'model' => $model, + 'messages' => [ + [ + 'role' => 'user', + 'content' => 'Generate an image: ' . $prompt, + ], + ], + 'modalities' => [ 'image', 'text' ], + 'image_config' => [ 'aspect_ratio' => $aspect ], + ]; + } +} diff --git a/includes/providers/class-open-router-image-provider.php b/includes/providers/class-open-router-image-provider.php index b5fc5ef..786d678 100644 --- a/includes/providers/class-open-router-image-provider.php +++ b/includes/providers/class-open-router-image-provider.php @@ -4,26 +4,14 @@ declare(strict_types=1); /** * OpenRouter image provider — model-agnostic, works with any image model. * - * Calls the standard OpenRouter chat/completions endpoint with - * `modalities: ["image"]`. Reuses the same API key and optional - * Cloudflare AI Gateway as the LLM provider. + * Triggered by: PRAutoBlogger_Image_Pipeline during content generation. + * Dependencies: Image_Support (key/parsing), Config (URL), Request_Builder (headers), + * Image_Pricing (model/cost), Image_Batch (parallel curl_multi), Logger. * - * Triggered by: PRAutoBlogger_Image_Pipeline during the content generation - * run, after editorial review, before publish. Also triggered - * by the admin "Test Connection" action. - * Dependencies: PRAutoBlogger_OpenRouter_Image_Support (key, parsing, retry), - * PRAutoBlogger_OpenRouter_Config (base URL + gateway), - * PRAutoBlogger_OpenRouter_Request_Builder (headers + cURL fix), - * PRAutoBlogger_OpenRouter_Image_Pricing (model + cost), - * PRAutoBlogger_Logger. - * - * @see interface-image-provider.php Interface this class implements. - * @see class-openrouter-image-support.php Key lookup, response parsing, retry. - * @see class-openrouter-image-pricing.php Cost + model helpers. - * @see class-open-router-config.php Base URL / gateway resolution. - * @see class-open-router-request-builder.php Header building + cURL auth filter. - * @see class-cloudflare-image-provider.php Sibling provider; same retry pattern. - * @see ARCHITECTURE.md External API Integrations table. + * @see interface-image-provider.php — Contract. + * @see class-open-router-image-batch.php — Parallel generation via curl_multi. + * @see class-open-router-image-support.php — Key, response parsing, retry. + * @see ARCHITECTURE.md — External API Integrations table. */ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Provider_Interface { @@ -53,14 +41,7 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro /** * {@inheritDoc} * - * Sends a chat/completions request with modalities=["image"] and parses - * the base64 image from the response. Retries 5xx/429 with exponential - * backoff. 4xx (except 429) fail immediately. - * - * Side effects: HTTP request per attempt, Logger line per attempt. - * - * @throws \InvalidArgumentException If dimensions out of range or prompt empty. - * @throws \RuntimeException On auth failure, bad response, or retry exhaustion. + * Retries 5xx/429 with exponential backoff; 4xx (except 429) fail immediately. */ public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array { $this->assert_valid_dimensions( $width, $height ); @@ -159,6 +140,41 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro ); } + /** + * Generate multiple images concurrently via curl_multi. + * + * Fires all requests in parallel — total wall-clock time equals the + * slowest single request, not the sum. Solves the Image B timeout + * problem where sequential calls exceed Hostinger's max_execution_time. + * + * {@inheritDoc} + */ + public function generate_image_batch( array $requests ): array { + // Validate all requests upfront before starting any HTTP calls. + foreach ( $requests as $key => $req ) { + $this->assert_valid_dimensions( $req['width'], $req['height'] ); + if ( '' === trim( $req['prompt'] ) ) { + throw new \InvalidArgumentException( + sprintf( 'Image prompt for key "%s" cannot be empty.', esc_html( $key ) ) + ); + } + } + + $batch = new PRAutoBlogger_OpenRouter_Image_Batch( + $this->pricing, + $this->support, + $this->config, + $this->request_builder + ); + + PRAutoBlogger_Logger::instance()->info( + sprintf( 'Starting parallel image batch (%d images)', count( $requests ) ), + 'openrouter-image' + ); + + return $batch->execute( $requests ); + } + /** {@inheritDoc} */ public function estimate_cost( int $width, int $height, array $options = [] ): float { $model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) ); @@ -170,19 +186,12 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro return self::PROVIDER_ID; } - /** - * Verify credentials by calling OpenRouter's /auth/key endpoint. - * - * {@inheritDoc} - */ + /** {@inheritDoc} — Calls OpenRouter's /auth/key endpoint. */ public function validate_credentials_detailed(): array { try { $api_key = $this->support->get_api_key(); } catch ( \RuntimeException $e ) { - return [ - 'status' => 'error', - 'message' => $e->getMessage(), - ]; + return [ 'status' => 'error', 'message' => $e->getMessage() ]; } $response = wp_remote_get( 'https://openrouter.ai/api/v1/auth/key', [ @@ -200,10 +209,7 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro $status = (int) wp_remote_retrieve_response_code( $response ); if ( 200 === $status ) { - return [ - 'status' => 'ok', - 'message' => __( 'OpenRouter API key valid. Image generation ready.', 'prautoblogger' ), - ]; + return [ 'status' => 'ok', 'message' => __( 'OpenRouter API key valid.', 'prautoblogger' ) ]; } return [ @@ -214,17 +220,7 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro } /** - * Build the chat/completions request body for image generation. - * - * @param string $prompt Image generation prompt. - * @param int $width Target width. - * @param int $height Target height. - * @param string $model Resolved model id. - * @return array - */ - /** - * Supported aspect ratios for OpenRouter image models (Gemini, GPT-5). - * Arbitrary ratios like 150:79 are rejected — must snap to one of these. + * Supported aspect ratios for OpenRouter image models. * * @var array */ @@ -260,7 +256,6 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro /** * Snap pixel dimensions to the nearest supported aspect ratio string. - * 1200×632 → ratio 1.8987 → nearest is 16:9 (1.7778). * * @param int $width Width in pixels. * @param int $height Height in pixels. diff --git a/includes/providers/interface-image-provider.php b/includes/providers/interface-image-provider.php index fde16a5..4e8b804 100644 --- a/includes/providers/interface-image-provider.php +++ b/includes/providers/interface-image-provider.php @@ -83,6 +83,37 @@ interface PRAutoBlogger_Image_Provider_Interface { */ public function get_provider_name(): string; + /** + * Generate multiple images in parallel (or sequentially as fallback). + * + * Each entry in $requests describes one image to generate. The return array + * is indexed identically — callers can zip input and output by key. + * + * Implementations that support concurrent HTTP (e.g. curl_multi) SHOULD + * override the default sequential behaviour for a wall-clock speedup. + * + * Side effects: HTTP requests (possibly concurrent), Logger lines per image. + * + * @param array $requests Keyed image generation requests. + * + * @return array Each key maps to either image data or an error. + */ + public function generate_image_batch( array $requests ): array; + /** * Verify the provider credentials are present and the API is reachable. * diff --git a/tests/unit/Core/ImagePipelineTest.php b/tests/unit/Core/ImagePipelineTest.php index fdcf96a..7409f7b 100644 --- a/tests/unit/Core/ImagePipelineTest.php +++ b/tests/unit/Core/ImagePipelineTest.php @@ -71,19 +71,25 @@ class ImagePipelineTest extends BaseTestCase { /** * Test generate_and_attach_images() returns cost and error array structure. + * + * Pipeline now calls generate_image_batch() with keyed requests. */ public function test_generate_and_attach_images_returns_correct_structure(): void { - $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); - $provider->method( 'estimate_cost' )->willReturn( 0.05 ); - $provider->method( 'generate_image' )->willReturn( [ + $image_result = [ 'bytes' => 'fake_image_data', 'mime_type' => 'image/png', 'width' => 1200, 'height' => 630, - 'model' => 'flux-1-schnell', + 'model' => 'test-model', 'seed' => 123, 'cost_usd' => 0.05, 'latency_ms' => 2000, + ]; + + $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); + $provider->method( 'estimate_cost' )->willReturn( 0.05 ); + $provider->method( 'generate_image_batch' )->willReturn( [ + 'image_a' => $image_result, ] ); $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); @@ -91,7 +97,6 @@ class ImagePipelineTest extends BaseTestCase { $pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker ); - // Mock file writing for sideload. Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' ); Functions\when( 'media_handle_sideload' )->justReturn( 42 ); @@ -104,19 +109,24 @@ class ImagePipelineTest extends BaseTestCase { } /** - * Test that cost is accumulated when both images are generated. + * Test that cost is accumulated when both images are generated via batch. */ public function test_cost_is_accumulated_for_both_images(): void { - $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); - $provider->method( 'estimate_cost' )->willReturn( 0.05 ); - $provider->method( 'generate_image' )->willReturn( [ + $image_result = [ 'bytes' => 'fake_image_data', 'mime_type' => 'image/png', 'width' => 1200, 'height' => 630, - 'model' => 'flux-1-schnell', + 'model' => 'test-model', 'cost_usd' => 0.05, 'latency_ms' => 2000, + ]; + + $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); + $provider->method( 'estimate_cost' )->willReturn( 0.05 ); + $provider->method( 'generate_image_batch' )->willReturn( [ + 'image_a' => $image_result, + 'image_b' => $image_result, ] ); $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); @@ -134,35 +144,40 @@ class ImagePipelineTest extends BaseTestCase { [ 'title' => 'Source' ] ); - // Cost should be accumulated from both images. - $this->assertGreaterThan( 0.05, $result['cost_usd'] ); + // Cost should be accumulated from both images ($0.05 × 2 = $0.10). + $this->assertEqualsWithDelta( 0.10, $result['cost_usd'], 0.001 ); } /** - * Test that Image B is NOT generated when source_data is null. + * Test that Image B is NOT included in the batch when source_data is null. * - * This is the regression test for the Image B data-handoff gap: - * post_assembler.php and executor.php both passed null as source_data, - * so the pipeline silently skipped Image B. If this test ever fails - * with `$this->exactly(2)`, it means someone re-broke the handoff. + * Regression test: the pipeline must only send 'image_a' in the batch + * request when no source data is provided. */ public function test_image_b_skipped_when_source_data_is_null(): void { - $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); - $provider->method( 'estimate_cost' )->willReturn( 0.05 ); - $provider->method( 'generate_image' )->willReturn( [ + $image_result = [ 'bytes' => 'fake_image_data', 'mime_type' => 'image/png', 'width' => 1200, 'height' => 630, - 'model' => 'flux-1-schnell', + 'model' => 'test-model', 'cost_usd' => 0.05, 'latency_ms' => 2000, - ] ); + ]; + + $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); + $provider->method( 'estimate_cost' )->willReturn( 0.05 ); + // Verify batch only contains 'image_a'. + $provider->expects( $this->once() ) + ->method( 'generate_image_batch' ) + ->with( $this->callback( function ( $requests ) { + return array_key_exists( 'image_a', $requests ) + && ! array_key_exists( 'image_b', $requests ); + } ) ) + ->willReturn( [ 'image_a' => $image_result ] ); $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); $cost_tracker->method( 'would_exceed_budget' )->willReturn( false ); - - // Only ONE call to log_image_generation — Image A only. $cost_tracker->expects( $this->exactly( 1 ) )->method( 'log_image_generation' ); $pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker ); @@ -170,7 +185,6 @@ class ImagePipelineTest extends BaseTestCase { Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' ); Functions\when( 'media_handle_sideload' )->justReturn( 42 ); - // Pass null source_data — Image B must NOT fire. $result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ], null ); $this->assertArrayHasKey( 'image_a_id', $result ); @@ -178,12 +192,12 @@ class ImagePipelineTest extends BaseTestCase { } /** - * Test graceful handling when image generation fails. + * Test graceful handling when batch generation throws. */ - public function test_graceful_failure_when_image_generation_fails(): void { + public function test_graceful_failure_when_batch_generation_throws(): void { $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); $provider->method( 'estimate_cost' )->willReturn( 0.05 ); - $provider->method( 'generate_image' )->willThrowException( new \Exception( 'API timeout' ) ); + $provider->method( 'generate_image_batch' )->willThrowException( new \Exception( 'API timeout' ) ); $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); $cost_tracker->method( 'would_exceed_budget' )->willReturn( false ); @@ -192,9 +206,50 @@ class ImagePipelineTest extends BaseTestCase { $result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ] ); - // Should record error but return valid structure. $this->assertIsArray( $result ); $this->assertNotEmpty( $result['errors'] ); $this->assertStringContainsString( 'API timeout', implode( ' ', $result['errors'] ) ); } + + /** + * Test graceful handling when one image in the batch returns an error. + */ + public function test_partial_batch_failure_handled_gracefully(): void { + $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); + $provider->method( 'estimate_cost' )->willReturn( 0.05 ); + $provider->method( 'generate_image_batch' )->willReturn( [ + 'image_a' => [ + 'bytes' => 'fake_image_data', + 'mime_type' => 'image/png', + 'width' => 1200, + 'height' => 630, + 'model' => 'test-model', + 'cost_usd' => 0.05, + 'latency_ms' => 2000, + ], + 'image_b' => [ 'error' => 'HTTP 429: rate limited' ], + ] ); + + $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); + $cost_tracker->method( 'would_exceed_budget' )->willReturn( false ); + // Only Image A gets logged — Image B failed. + $cost_tracker->expects( $this->exactly( 1 ) )->method( 'log_image_generation' ); + + $pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker ); + + Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' ); + Functions\when( 'media_handle_sideload' )->justReturn( 42 ); + + $result = $pipeline->generate_and_attach_images( + 1, + [ 'post_title' => 'Test' ], + [ 'title' => 'Source' ] + ); + + // Image A succeeds, Image B records an error. + $this->assertArrayHasKey( 'image_a_id', $result ); + $this->assertArrayNotHasKey( 'image_b_id', $result ); + $this->assertNotEmpty( $result['errors'] ); + $this->assertStringContainsString( '429', implode( ' ', $result['errors'] ) ); + } }