feat: parallel image generation via curl_multi

Both Image A and Image B now fire concurrently through a single
generate_image_batch() call instead of sequentially. Total wall-clock
time equals the slowest image (~20s), not the sum (~40s), which
eliminates the Image B timeout problem on Hostinger where
max_execution_time killed the cron before the second request finished.

New files:
- class-open-router-image-batch.php — curl_multi orchestrator
- generate_image_batch() added to image provider interface

Pipeline refactored: prompts built upfront, single batch call, results
processed per-key. Cloudflare provider gets a sequential fallback.
Tests updated for batch API. All files under 300-line cap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
peptiderepo
2026-04-18 21:10:54 +07:00
parent c8595e05de
commit d17c488ef3
7 changed files with 604 additions and 225 deletions

View File

@@ -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;
}
/**

View File

@@ -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.
*

View File

@@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
/**
* Parallel image generation via curl_multi for OpenRouter.
*
* Fires multiple chat/completions requests concurrently using PHP's
* curl_multi interface, cutting wall-clock time from N×20s to ~20s
* regardless of how many images are requested. This solves the Image B
* timeout problem on Hostinger where max_execution_time kills the
* cron process before a sequential second request can finish.
*
* Triggered by: PRAutoBlogger_OpenRouter_Image_Provider::generate_image_batch().
* Dependencies: PRAutoBlogger_OpenRouter_Image_Support (key, parsing),
* PRAutoBlogger_OpenRouter_Image_Pricing (model, cost),
* PRAutoBlogger_OpenRouter_Config (base URL, gateway),
* PRAutoBlogger_OpenRouter_Request_Builder (headers).
*
* @see class-open-router-image-provider.php — Sole caller.
* @see class-open-router-image-support.php — Response parsing.
* @see ARCHITECTURE.md — Image pipeline data flow.
*/
class PRAutoBlogger_OpenRouter_Image_Batch {
/** @var PRAutoBlogger_OpenRouter_Image_Pricing */
private PRAutoBlogger_OpenRouter_Image_Pricing $pricing;
/** @var PRAutoBlogger_OpenRouter_Image_Support */
private PRAutoBlogger_OpenRouter_Image_Support $support;
/** @var PRAutoBlogger_OpenRouter_Config */
private PRAutoBlogger_OpenRouter_Config $config;
/** @var PRAutoBlogger_OpenRouter_Request_Builder */
private PRAutoBlogger_OpenRouter_Request_Builder $request_builder;
/**
* @param PRAutoBlogger_OpenRouter_Image_Pricing $pricing Cost + model resolver.
* @param PRAutoBlogger_OpenRouter_Image_Support $support Key + response parsing.
* @param PRAutoBlogger_OpenRouter_Config $config API URL resolution.
* @param PRAutoBlogger_OpenRouter_Request_Builder $request_builder Header builder.
*/
public function __construct(
PRAutoBlogger_OpenRouter_Image_Pricing $pricing,
PRAutoBlogger_OpenRouter_Image_Support $support,
PRAutoBlogger_OpenRouter_Config $config,
PRAutoBlogger_OpenRouter_Request_Builder $request_builder
) {
$this->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<string, array{prompt: string, width: int, height: int, options?: array}> $requests Keyed requests.
*
* @return array<string, array{
* bytes: string, mime_type: string, width: int, height: int,
* model: string, seed: ?int, cost_usd: float, latency_ms: int,
* }|array{error: string}> 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<string, mixed>
*/
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 ],
];
}
}

View File

@@ -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<string, mixed>
*/
/**
* 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<int, array{w: int, h: int, ratio: float}>
*/
@@ -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.

View File

@@ -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<string, array{
* prompt: string,
* width: int,
* height: int,
* options?: array{seed?: int, steps?: int, model?: string},
* }> $requests Keyed image generation requests.
*
* @return array<string, array{
* bytes: string,
* mime_type: string,
* width: int,
* height: int,
* model: string,
* seed: ?int,
* cost_usd: float,
* latency_ms: int,
* }|array{error: string}> 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.
*