Files
prautoblogger/includes/core/class-image-pipeline.php
peptiderepo fb1451c4dc feat(images): retry Cloudflare NSFW-blocked prompts with a sanitized fallback
Cloudflare's Workers AI FLUX.1 schnell content filter sometimes rejects
peptide-comic prompts as NSFW (HTTP 400, errors[].code === 3030). On
2026-04-20 this produced a silent publish-without-featured-image for
post #650 ("Semaglutide vs Tirzepatide") — the LLM-rewritten scene
tripped the filter and we had no recovery path.

New behaviour: on a 3030 response, the Cloudflare provider throws a
typed PRAutoBlogger_Image_NSFW_Blocked exception. The batch wrapper
catches it and tags the per-slot result with
`error_type: nsfw_blocked`. Image_Pipeline hands the results to a new
PRAutoBlogger_Image_NSFW_Retry helper, which reruns the blocked slot
once with a sanitized fallback prompt (title + style suffix, no LLM
rewrite). On success the retry result replaces the error entry; on a
second block, a WARNING is logged and the pipeline publishes without
that image — same terminal behaviour we had before, just explicit.

Files:

- New providers/class-image-nsfw-blocked.php — typed exception.
- providers/class-cloudflare-image-support.php — is_nsfw_error() +
  throw_if_nsfw() helpers.
- providers/class-cloudflare-image-provider.php — calls throw_if_nsfw
  inside the 4xx branch; batch catches the typed exception and marks
  the result with error_type.
- core/class-image-prompt-builder.php — new public build_fallback_prompt
  shim so the retry layer can reach the rule-based fallback without
  going through the LLM path.
- New core/class-image-nsfw-retry.php — single-shot retry helper.
- core/class-image-pipeline.php — invokes the retry between batch
  completion and per-slot processing.
- admin/class-settings-fields-extended.php — new toggle
  `prautoblogger_image_nsfw_retry` (default on) in the Images section.

Tests:

- CloudflareImageProviderTest — 3030 response throws NSFW exception;
  batch tags the per-slot result with `error_type: nsfw_blocked`.
- ImagePipelineTest — retry succeeds when provider returns a good
  result on the second call; retry is skipped when the setting is off.

Cost impact: worst-case an extra Cloudflare call per NSFW-blocked slot
(~$0.000834). Current incidence ~2% of generations; ~+$0.00002 per
generation amortized. Immaterial.

Uninstall: new option covered by the existing prautoblogger_% LIKE
sweep in uninstall.php.

Part of the v0.8.0 image bundle (issues from thread image-mime-bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:06:59 +07:00

301 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Orchestrates image generation for published articles.
*
* Generates two images per article (A/B): Image A (article-driven) as featured
* image, Image B (source-driven) in post meta. Each is independently fallible.
*
* Triggered by: PRAutoBlogger_Pipeline_Runner after publisher creates the post.
* Dependencies: Image provider, Prompt builder, Sideloader, Cost tracker, Logger.
*
* @see core/class-pipeline-runner.php — Calls this after publisher.
* @see core/class-image-prompt-builder.php — Prompt generation (scene + caption).
* @see core/class-image-media-sideloader.php — Media library integration.
*/
class PRAutoBlogger_Image_Pipeline {
/**
* Default image dimensions (landscape).
*/
// Dimensions must be divisible by 8 (required by some providers).
// 1200×632 is the closest 8-aligned pair to the standard OG image size (1200×630).
private const DEFAULT_WIDTH = 1200;
private const DEFAULT_HEIGHT = 632;
/** @var PRAutoBlogger_Image_Provider_Interface Image gen provider. */
private PRAutoBlogger_Image_Provider_Interface $provider;
/** @var PRAutoBlogger_Image_Prompt_Builder Builds scene + caption from article data. */
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
/** @var PRAutoBlogger_Image_Media_Sideloader Downloads images into WP media library. */
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
/** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */
private PRAutoBlogger_Cost_Tracker $cost_tracker;
/**
* Construct with dependencies.
*
* When no provider is injected, the constructor reads the
* `prautoblogger_image_provider` setting and instantiates the
* matching concrete class ('openrouter' or 'cloudflare').
*
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider override.
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
*/
public function __construct(
?PRAutoBlogger_Image_Provider_Interface $provider = null,
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
) {
$this->provider = $provider ?? self::create_default_provider();
$this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder();
$this->sideloader = new PRAutoBlogger_Image_Media_Sideloader();
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
}
/**
* Instantiate the image provider based on the admin setting.
*
* @return PRAutoBlogger_Image_Provider_Interface
*/
private static function create_default_provider(): PRAutoBlogger_Image_Provider_Interface {
$provider_id = (string) get_option( 'prautoblogger_image_provider', PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER );
if ( 'openrouter' === $provider_id ) {
return new PRAutoBlogger_OpenRouter_Image_Provider();
}
return new PRAutoBlogger_Cloudflare_Image_Provider();
}
/**
* Generate and attach images to a published post.
*
* 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 $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[]}
*/
public function generate_and_attach_images(
int $post_id,
array $article_data,
?array $source_data = null
): array {
$result = [ 'cost_usd' => 0.0, 'errors' => [] ];
if ( ! get_option( 'prautoblogger_image_enabled' ) ) {
PRAutoBlogger_Logger::instance()->info( 'Image generation disabled in settings.', 'image_pipeline' );
return $result;
}
// Check whether Image B is enabled in settings.
$image_b_enabled = get_option( 'prautoblogger_image_b_enabled', '1' );
$has_source_data = null !== $source_data && ! empty( $source_data );
$generate_b = $has_source_data && '1' === $image_b_enabled;
// Budget pre-check: estimate cost for all images before any HTTP calls.
$image_count = $generate_b ? 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'] ];
$source_prompt = null;
if ( $generate_b ) {
$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'];
}
// 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;
}
// Single-shot retry for any slot the provider flagged as NSFW-blocked.
if ( PRAutoBlogger_Image_NSFW_Retry::is_enabled() ) {
( new PRAutoBlogger_Image_NSFW_Retry( $this->provider, $this->prompt_builder ) )
->retry_blocked_slots( $post_id, $batch_requests, $batch_results, $captions, $article_data, $source_data );
}
// Process Image A result.
$this->process_image_a( $post_id, $batch_results, $captions, $result );
// 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.', $post_id ),
'image_pipeline'
);
}
return $result;
}
/**
* 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'] ) ) {
$err_msg = $image_data['error'] ?? 'Image A missing from batch results.';
$result['errors'][] = $err_msg;
// Log at WARNING so the path is visible even if the outer catch in the
// caller doesn't fire. See thread image-mime-bug Issue 2.
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Image A generation failed for post %d: %s', $post_id, $err_msg ),
'image_pipeline'
);
return;
}
$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'] );
}
}
/**
* Process Image B batch result: sideload, store meta.
*
* @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_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'] ) ) {
$err_msg = $image_data['error'] ?? 'Image B missing from batch results.';
$result['errors'][] = $err_msg;
// Log at WARNING so the path is visible even if the outer catch in the
// caller doesn't fire. See thread image-mime-bug Issue 2.
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Image B generation failed for post %d: %s', $post_id, $err_msg ),
'image_pipeline'
);
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;
}
/**
* Prepend the comic caption as styled text at the top of the post content.
*
* Only inserts the caption — NOT the image. The theme already displays the
* featured image, so embedding it again would cause a duplicate.
*
* @param int $post_id Post ID.
* @param int $attachment_id Featured image attachment ID (for meta storage only).
* @param string $caption Caption text (the comic punchline).
*/
private function prepend_caption_to_post( int $post_id, int $attachment_id, string $caption ): void {
$post = get_post( $post_id );
if ( ! $post ) {
return;
}
// Caption-only block — the theme handles the featured image display.
$caption_html = sprintf(
'<p class="prautoblogger-comic-caption" style="text-align:center;font-style:italic;color:#555;font-size:1.1em;margin:0 0 2em 0;">— "%s"</p>',
esc_html( $caption )
);
wp_update_post( [
'ID' => $post_id,
'post_content' => $caption_html . "\n\n" . $post->post_content,
] );
}
}