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
170 lines
6.4 KiB
PHP
170 lines
6.4 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
|
*
|
|
* Persists generated images and composed variants for a post.
|
|
*
|
|
* Owns the media-library side of the image pipeline: sideloading the primary
|
|
* (featured) image with cost logging, sideloading composed OG/square variants
|
|
* with role/base meta-linking (no cost-tracker entries — composition is $0
|
|
* local CPU), and prepending the editable HTML caption to the post. Extracted
|
|
* from PRAutoBlogger_Image_Pipeline in v0.17.0 for 300-line compliance.
|
|
*
|
|
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
|
|
* Dependencies: PRAutoBlogger_Image_Media_Sideloader, PRAutoBlogger_Cost_Tracker,
|
|
* PRAutoBlogger_Logger.
|
|
*
|
|
* @see core/class-image-pipeline.php — Sole caller.
|
|
* @see core/class-image-composer.php — Produces the variants this persists.
|
|
* @see core/class-image-media-sideloader.php — Media library import.
|
|
*/
|
|
class PRAutoBlogger_Image_Attacher {
|
|
|
|
/** Post meta key per composed variant role. */
|
|
private const ROLE_POST_META = array(
|
|
'og' => '_prautoblogger_og_image_id',
|
|
'square' => '_prautoblogger_square_image_id',
|
|
);
|
|
|
|
/** @var PRAutoBlogger_Image_Media_Sideloader Imports bytes into the media library. */
|
|
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
|
|
|
|
/** @var PRAutoBlogger_Cost_Tracker Logs primary image generation spend. */
|
|
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
|
|
|
/**
|
|
* Construct with the pipeline's sideloader + cost tracker.
|
|
*
|
|
* @param PRAutoBlogger_Image_Media_Sideloader $sideloader Media importer.
|
|
* @param PRAutoBlogger_Cost_Tracker $cost_tracker Spend logger.
|
|
*/
|
|
public function __construct( PRAutoBlogger_Image_Media_Sideloader $sideloader, PRAutoBlogger_Cost_Tracker $cost_tracker ) {
|
|
$this->sideloader = $sideloader;
|
|
$this->cost_tracker = $cost_tracker;
|
|
}
|
|
|
|
/**
|
|
* Sideload a primary image into the media library and log its cost.
|
|
*
|
|
* Side effects: media import, cost-tracker row, info log.
|
|
*
|
|
* @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.
|
|
* @param string $alt_text Alt text — the editorial caption (v0.17.0 bug
|
|
* fix: previously the model name leaked here).
|
|
*
|
|
* @return int|\WP_Error Attachment ID on success.
|
|
*/
|
|
public function sideload_and_log( array $image_data, int $post_id, string $slot, string $label, string $alt_text = '' ) {
|
|
$attachment_id = $this->sideloader->sideload_image( $image_data, $post_id, $alt_text );
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Sideload composed variants and meta-link them to post + base image.
|
|
*
|
|
* Each variant attachment gets `_prautoblogger_image_role` and
|
|
* `_prautoblogger_base_attachment_id`; the post gets the role-specific
|
|
* ID meta (see ROLE_POST_META). No cost-tracker entries — composition is
|
|
* a $0 local stage. A failed variant sideload logs a WARNING and is
|
|
* skipped; it never blocks the publish.
|
|
*
|
|
* @param array $variants Composed variants (role 'og'/'square' only are persisted).
|
|
* @param int $post_id Post ID.
|
|
* @param int $base_attachment_id Featured attachment the variants derive from.
|
|
* @param string $caption Editorial caption (used as variant alt text).
|
|
* @param string $model Generation model recorded on the variant attachment.
|
|
*/
|
|
public function attach_variants( array $variants, int $post_id, int $base_attachment_id, string $caption, string $model ): void {
|
|
foreach ( $variants as $variant ) {
|
|
$role = (string) ( $variant['role'] ?? '' );
|
|
if ( ! isset( self::ROLE_POST_META[ $role ] ) ) {
|
|
continue;
|
|
}
|
|
|
|
$attachment_id = $this->sideloader->sideload_image(
|
|
array(
|
|
'bytes' => (string) ( $variant['bytes'] ?? '' ),
|
|
'mime_type' => (string) ( $variant['mime_type'] ?? 'image/png' ),
|
|
'width' => (int) ( $variant['width'] ?? 0 ),
|
|
'height' => (int) ( $variant['height'] ?? 0 ),
|
|
'model' => $model,
|
|
'cost_usd' => 0.0,
|
|
'latency_ms' => 0,
|
|
),
|
|
$post_id,
|
|
$caption,
|
|
$role
|
|
);
|
|
|
|
if ( is_wp_error( $attachment_id ) ) {
|
|
PRAutoBlogger_Logger::instance()->warning(
|
|
sprintf( 'Failed to sideload %s variant for post %d: %s', $role, $post_id, $attachment_id->get_error_message() ),
|
|
'image_composer'
|
|
);
|
|
continue;
|
|
}
|
|
|
|
update_post_meta( $attachment_id, '_prautoblogger_image_role', $role );
|
|
update_post_meta( $attachment_id, '_prautoblogger_base_attachment_id', $base_attachment_id );
|
|
update_post_meta( $post_id, self::ROLE_POST_META[ $role ], $attachment_id );
|
|
|
|
PRAutoBlogger_Logger::instance()->info(
|
|
sprintf( '%s variant for post %d (att %d, %dx%d)', $role, $post_id, $attachment_id, (int) $variant['width'], (int) $variant['height'] ),
|
|
'image_composer'
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prepend the editorial caption as styled text at the top of the post.
|
|
*
|
|
* 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 (unused; kept for parity with meta storage).
|
|
* @param string $caption Caption text shown below the image.
|
|
*/
|
|
public 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,
|
|
)
|
|
);
|
|
}
|
|
}
|