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:
@@ -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/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
272
includes/providers/class-open-router-image-batch.php
Normal file
272
includes/providers/class-open-router-image-batch.php
Normal 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 ],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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'] ) );
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user