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:
peptiderepo
2026-06-11 12:31:03 +07:00
parent d559fc58ab
commit 28947a0caa
26 changed files with 2416 additions and 128 deletions

View File

@@ -0,0 +1,169 @@
<?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,
)
);
}
}