feat: deterministic image composer — OG + square variants, branding, baked caption (v0.17.0)
Commit 2 (final scope) of the in-plugin editorial image pipeline (ARCHITECTURE.md #21). A deterministic local composer now runs between provider bytes and sideload: Image A yields a corner-marked featured (1200x632) + branded OG (1200x630, teal band, logo, baked caption in Poppins SemiBold) + square card (1080x1080, cream panel, Poppins Bold, teal footer with the reverse lockup); Image B gets the corner mark only. - Capability ladder: imagick (full compositing) -> wp_get_image_editor resize-only (unbranded, WARNING once) -> pass-through (WARNING once); probe cached in prautoblogger_image_compose_capability, fingerprinted by PHP version + imagick/gd presence so host flips auto-invalidate. compose() never throws; composition can never fail a publish. - Variants sideloaded with a role filename suffix; attachment meta _prautoblogger_image_role + _prautoblogger_base_attachment_id; post meta _prautoblogger_og_image_id (frozen for the rebuilt SEO stage) + _prautoblogger_square_image_id. No cost-tracker rows ($0 local stage; duration logged on the image_composer channel). - New Images settings: compose_enabled ('1'), compose_variants ('og,square'), featured_mark_enabled ('1') — read at point of use. Geometry ships as Layout defaults behind the prautoblogger_image_compose_layout filter. - Vendored brand rasters (assets/brand) + bundled OFL Poppins faces (assets/fonts) for per-environment byte-stable output (stripImage + PNG date/time chunks excluded). Validated on Hostinger PHP 8.3.30 / ImageMagick 7.1.1-43: php -l clean on all touched files, double render byte-identical (sha256), exact variant geometry. - Fix: featured/Image B alt text is the editorial caption (was the model name); model still persists in _prautoblogger_image_model. - Image_Attacher extracted from Image_Pipeline (300-line compliance); sideloader gains an optional filename suffix param (back-compat ''). - Version 0.16.0 -> 0.17.0 + CHANGELOG + ARCHITECTURE.md #21 + CONVENTIONS.md composer guide in this commit. Unit tests: layout clamp boundaries, capability ladder, cache invalidation, attacher meta-linking, pipeline integration, imagick determinism (gated). Agent-Session: engineer-prautoblogger-image-composer-20260611
This commit is contained in:
@@ -8,13 +8,17 @@ declare(strict_types=1);
|
||||
*
|
||||
* 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.
|
||||
* Since v0.17.0 every result passes through the deterministic image composer
|
||||
* (branded variants for A, corner mark for B) before sideloading; composition
|
||||
* degrades gracefully and never blocks a publish.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Pipeline_Runner after publisher creates the post.
|
||||
* Dependencies: Image provider, Prompt builder, Sideloader, Cost tracker, Logger.
|
||||
* Dependencies: Image provider, Prompt builder, Composer, Attacher, 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.
|
||||
* @see core/class-image-composer.php — Deterministic variant composition.
|
||||
* @see core/class-image-attacher.php — Sideloading + variant meta-linking.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Pipeline {
|
||||
|
||||
@@ -34,26 +38,35 @@ class PRAutoBlogger_Image_Pipeline {
|
||||
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
|
||||
/** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */
|
||||
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
||||
/** @var PRAutoBlogger_Image_Composer_Interface Composes branded variants pre-sideload. */
|
||||
private PRAutoBlogger_Image_Composer_Interface $composer;
|
||||
/** @var PRAutoBlogger_Image_Attacher Sideloads + meta-links results and variants. */
|
||||
private PRAutoBlogger_Image_Attacher $attacher;
|
||||
/** @var PRAutoBlogger_Opik_Trace_Context|null Optional tracing context. */
|
||||
private ?PRAutoBlogger_Opik_Trace_Context $trace_context;
|
||||
|
||||
/**
|
||||
* Construct with dependencies.
|
||||
*
|
||||
* When no provider is injected, the constructor reads the
|
||||
* `prautoblogger_image_provider` setting and instantiates the
|
||||
* matching concrete class ('runware' or 'openrouter').
|
||||
* Construct with dependencies. When no provider is injected, the
|
||||
* `prautoblogger_image_provider` setting picks the concrete class
|
||||
* ('runware' or 'openrouter').
|
||||
*
|
||||
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider override.
|
||||
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
|
||||
* @param PRAutoBlogger_Opik_Trace_Context|null $trace_context Optional tracing context.
|
||||
* @param PRAutoBlogger_Image_Composer_Interface|null $composer Optional composer override.
|
||||
*/
|
||||
public function __construct(
|
||||
?PRAutoBlogger_Image_Provider_Interface $provider = null,
|
||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null,
|
||||
?PRAutoBlogger_Opik_Trace_Context $trace_context = null
|
||||
?PRAutoBlogger_Opik_Trace_Context $trace_context = null,
|
||||
?PRAutoBlogger_Image_Composer_Interface $composer = null
|
||||
) {
|
||||
$this->provider = $provider ?? self::create_default_provider();
|
||||
$this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder( $trace_context );
|
||||
$this->sideloader = new PRAutoBlogger_Image_Media_Sideloader();
|
||||
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
|
||||
$this->composer = $composer ?? new PRAutoBlogger_Image_Composer();
|
||||
$this->attacher = new PRAutoBlogger_Image_Attacher( $this->sideloader, $this->cost_tracker );
|
||||
$this->trace_context = $trace_context;
|
||||
}
|
||||
|
||||
@@ -149,12 +162,14 @@ class PRAutoBlogger_Image_Pipeline {
|
||||
->retry_blocked_slots( $post_id, $batch_requests, $batch_results, $captions, $article_data, $source_data );
|
||||
}
|
||||
|
||||
$title = (string) ( $article_data['post_title'] ?? $article_data['suggested_title'] ?? '' );
|
||||
|
||||
// Process Image A result.
|
||||
$this->process_image_a( $post_id, $batch_results, $captions, $result );
|
||||
$this->process_slot( 'image_a', $post_id, $batch_results, $captions, $title, $result );
|
||||
|
||||
// Process Image B result (if requested).
|
||||
if ( isset( $batch_results['image_b'] ) ) {
|
||||
$this->process_image_b( $post_id, $batch_results, $captions, $result );
|
||||
$this->process_slot( 'image_b', $post_id, $batch_results, $captions, $title, $result );
|
||||
} elseif ( null === $source_data || empty( $source_data ) ) {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Image B skipped for post %d: no source data.', $post_id ),
|
||||
@@ -166,142 +181,98 @@ class PRAutoBlogger_Image_Pipeline {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Image A batch result: sideload, set featured image, insert caption.
|
||||
* Process one slot's batch result: compose, sideload, link to the post.
|
||||
*
|
||||
* @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).
|
||||
* Image A becomes the featured image (role meta, caption prepend, and
|
||||
* composed OG/square variants meta-linked); Image B is stored in post
|
||||
* meta with a corner mark only. Alt text is the editorial caption
|
||||
* (v0.17.0 fix — the model name used to leak here).
|
||||
*
|
||||
* @param string $slot 'image_a' or 'image_b'.
|
||||
* @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 string $title Post title (composer context).
|
||||
* @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;
|
||||
private function process_slot( string $slot, int $post_id, array $batch_results, array $captions, string $title, array &$result ): void {
|
||||
$label = 'image_a' === $slot ? 'Image A' : 'Image B';
|
||||
$image_data = $batch_results[ $slot ] ?? null;
|
||||
|
||||
if ( ! $image_data || isset( $image_data['error'] ) ) {
|
||||
$err_msg = $image_data['error'] ?? 'Image A missing from batch results.';
|
||||
$err_msg = $image_data['error'] ?? sprintf( '%s missing from batch results.', $label );
|
||||
$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 ),
|
||||
sprintf( '%s generation failed for post %d: %s', $label, $post_id, $err_msg ),
|
||||
'image_pipeline'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$attachment_id = $this->sideload_and_log( $image_data, $post_id, 'image_a', 'Image A' );
|
||||
$caption = (string) ( $captions[ $slot ] ?? '' );
|
||||
$variants = $this->compose_variants( $image_data, $post_id, $caption, $title, $slot );
|
||||
$featured = array_merge( $image_data, array_shift( $variants ) );
|
||||
|
||||
$attachment_id = $this->attacher->sideload_and_log( $featured, $post_id, $slot, $label, $caption );
|
||||
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 );
|
||||
$result[ $slot . '_id' ] = $attachment_id;
|
||||
$result['cost_usd'] += $image_data['cost_usd'];
|
||||
|
||||
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'] );
|
||||
if ( '' !== $caption ) {
|
||||
update_post_meta( $attachment_id, '_prautoblogger_image_caption', $caption );
|
||||
}
|
||||
|
||||
if ( 'image_a' === $slot ) {
|
||||
set_post_thumbnail( $post_id, $attachment_id );
|
||||
update_post_meta( $attachment_id, '_prautoblogger_image_role', 'featured' );
|
||||
if ( '' !== $caption ) {
|
||||
$this->attacher->prepend_caption_to_post( $post_id, $attachment_id, $caption );
|
||||
}
|
||||
$this->attacher->attach_variants( $variants, $post_id, $attachment_id, $caption, (string) ( $image_data['model'] ?? 'unknown' ) );
|
||||
} else {
|
||||
update_post_meta( $post_id, '_prautoblogger_image_b_id', $attachment_id );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Image B batch result: sideload, store meta.
|
||||
* Run the composer with a hard safety net: any failure (composer bugs
|
||||
* included — its own internals already degrade) falls back to passing
|
||||
* the base image through, so composition can never fail a publish.
|
||||
*
|
||||
* @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).
|
||||
* @param array $image_data Provider result for one slot.
|
||||
* @param int $post_id Post ID (composer context).
|
||||
* @param string $caption Editorial caption (composer context).
|
||||
* @param string $title Post title (composer context).
|
||||
* @param string $slot 'image_a' or 'image_b'.
|
||||
* @return array<int, array<string, mixed>> Featured-first variant list (never empty).
|
||||
*/
|
||||
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'
|
||||
private function compose_variants( array $image_data, int $post_id, string $caption, string $title, string $slot ): array {
|
||||
try {
|
||||
$variants = $this->composer->compose(
|
||||
$image_data,
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'caption' => $caption,
|
||||
'title' => $title,
|
||||
'slot' => $slot,
|
||||
)
|
||||
);
|
||||
if ( ! empty( $variants ) ) {
|
||||
return $variants;
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
'Image composition failed, publishing original image: ' . $e->getMessage(),
|
||||
'image_composer'
|
||||
);
|
||||
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(
|
||||
array(
|
||||
'ID' => $post_id,
|
||||
'post_content' => $caption_html . "\n\n" . $post->post_content,
|
||||
)
|
||||
);
|
||||
return array( array_merge( $image_data, array( 'role' => 'featured' ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user