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:
298
includes/core/class-image-composer.php
Normal file
298
includes/core/class-image-composer.php
Normal file
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Orchestrates deterministic image composition with a capability ladder.
|
||||
*
|
||||
* Sits between the provider's raw bytes and the media sideloader. Probes the
|
||||
* environment once (cached in an option keyed by a PHP/extension fingerprint,
|
||||
* so it auto-invalidates when the host changes — Hostinger extension state
|
||||
* has flipped before), then renders per the ladder: (1) Imagick usable →
|
||||
* full branded compositing; (2) GD via WP editor → resize-only variants, no
|
||||
* branding/text, WARNING once; (3) no image library → pass-through, WARNING
|
||||
* once. compose() never throws and always returns a featured-first non-empty
|
||||
* list, so a composition failure can never block a publish. Local CPU only:
|
||||
* $0, no provider call; duration is logged on the image_composer channel.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Pipeline after batch results + NSFW retry,
|
||||
* before sideloading.
|
||||
* Dependencies: Image_Composer_Imagick (rung 1), Image_Composer_Editor (rung 2),
|
||||
* Image_Composer_Canvas (probe), Image_Composer_Layout, Logger.
|
||||
*
|
||||
* @see core/interface-image-composer.php — The contract.
|
||||
* @see core/class-image-pipeline.php — Caller.
|
||||
* @see core/class-image-attacher.php — Persists returned variants.
|
||||
* @see ARCHITECTURE.md — Decision #21.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Composer implements PRAutoBlogger_Image_Composer_Interface {
|
||||
|
||||
/** Option keys (prautoblogger_ prefix keeps the uninstall sweep effective). */
|
||||
public const OPTION_ENABLED = 'prautoblogger_image_compose_enabled';
|
||||
public const OPTION_VARIANTS = 'prautoblogger_image_compose_variants';
|
||||
public const OPTION_MARK_ENABLED = 'prautoblogger_image_featured_mark_enabled';
|
||||
public const OPTION_CAPABILITY = 'prautoblogger_image_compose_capability';
|
||||
|
||||
/** Capability rungs. */
|
||||
public const CAP_IMAGICK = 'imagick';
|
||||
public const CAP_GD = 'gd';
|
||||
public const CAP_NONE = 'none';
|
||||
|
||||
/** @var PRAutoBlogger_Image_Composer_Imagick|null Lazily built; injectable for tests. */
|
||||
private ?PRAutoBlogger_Image_Composer_Imagick $renderer;
|
||||
|
||||
/** @var bool Degradation WARNING fired this run (once per PHP process). */
|
||||
private static bool $warned_this_run = false;
|
||||
|
||||
/**
|
||||
* Construct, optionally injecting a renderer (tests).
|
||||
* @param PRAutoBlogger_Image_Composer_Imagick|null $renderer Optional renderer override.
|
||||
*/
|
||||
public function __construct( ?PRAutoBlogger_Image_Composer_Imagick $renderer = null ) {
|
||||
$this->renderer = $renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose final variants for one generated image. See the interface for
|
||||
* the full contract; this implementation never throws.
|
||||
* Side effects: reads options, may cache the capability probe, logs.
|
||||
*
|
||||
* @param array $image_data Provider result (bytes, mime_type, width, height, ...).
|
||||
* @param array $context {post_id, caption, title, slot}.
|
||||
* @return array<int, array{bytes: string, mime_type: string, width: int, height: int, role: string}>
|
||||
*/
|
||||
public function compose( array $image_data, array $context ): array {
|
||||
$passthrough = array( $this->passthrough_variant( $image_data ) );
|
||||
|
||||
if ( '' === $passthrough[0]['bytes'] || '1' !== (string) get_option( self::OPTION_ENABLED, '1' ) ) {
|
||||
return $passthrough;
|
||||
}
|
||||
|
||||
$started = microtime( true );
|
||||
$bytes = $passthrough[0]['bytes'];
|
||||
$capability = $this->resolve_capability();
|
||||
$caption = (string) ( $context['caption'] ?? '' );
|
||||
$roles = ( 'image_a' === (string) ( $context['slot'] ?? 'image_a' ) )
|
||||
? $this->configured_variants()
|
||||
: array();
|
||||
|
||||
if ( self::CAP_IMAGICK === $capability ) {
|
||||
$variants = $this->compose_via_imagick( $bytes, $caption, $roles, $passthrough[0] );
|
||||
} elseif ( self::CAP_GD === $capability ) {
|
||||
$this->warn_once( 'Imagick unavailable — emitting resize-only variants without branding or caption.' );
|
||||
$editor = new PRAutoBlogger_Image_Composer_Editor();
|
||||
$variants = array_merge( $passthrough, $editor->compose_variants( $bytes, $roles, $this->layout() ) );
|
||||
} else {
|
||||
$this->warn_once( 'No usable image library — base image passed through unbranded.' );
|
||||
$variants = $passthrough;
|
||||
}
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf(
|
||||
'Composed %d variant(s) for post %d in %dms ($0, local, capability: %s).',
|
||||
count( $variants ),
|
||||
(int) ( $context['post_id'] ?? 0 ),
|
||||
(int) round( ( microtime( true ) - $started ) * 1000 ),
|
||||
$capability
|
||||
),
|
||||
'image_composer'
|
||||
);
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full compositing rung. Each render is individually guarded: a featured
|
||||
* failure degrades to pass-through, a variant failure skips that variant.
|
||||
*
|
||||
* @param string $bytes Base image bytes.
|
||||
* @param string $caption Editorial caption for OG/square.
|
||||
* @param array $roles Variant roles to render ('og', 'square').
|
||||
* @param array $passthrough Pass-through featured variant (fallback).
|
||||
* @return array<int, array<string, mixed>> Featured-first variant list.
|
||||
*/
|
||||
private function compose_via_imagick( string $bytes, string $caption, array $roles, array $passthrough ): array {
|
||||
$variants = array();
|
||||
|
||||
if ( '1' === (string) get_option( self::OPTION_MARK_ENABLED, '1' ) ) {
|
||||
$featured = $this->try_render( 'featured', $bytes, $caption );
|
||||
$variants[] = null === $featured ? $passthrough : $featured;
|
||||
} else {
|
||||
$variants[] = $passthrough;
|
||||
}
|
||||
|
||||
foreach ( $roles as $role ) {
|
||||
$variant = $this->try_render( $role, $bytes, $caption );
|
||||
if ( null !== $variant ) {
|
||||
$variants[] = $variant;
|
||||
}
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one role via the Imagick renderer. Any throwable or malformed
|
||||
* result (empty bytes) maps to one per-run WARNING + null (caller degrades).
|
||||
* @param string $role 'featured', 'og', or 'square'.
|
||||
* @param string $bytes Base image bytes.
|
||||
* @param string $caption Editorial caption.
|
||||
* @return array<string, mixed>|null Variant payload or null on failure.
|
||||
*/
|
||||
private function try_render( string $role, string $bytes, string $caption ): ?array {
|
||||
try {
|
||||
$renderer = $this->renderer();
|
||||
if ( 'og' === $role ) {
|
||||
$variant = $renderer->compose_og( $bytes, $caption );
|
||||
} elseif ( 'square' === $role ) {
|
||||
$variant = $renderer->compose_square( $bytes, $caption );
|
||||
} else {
|
||||
$variant = $renderer->compose_featured( $bytes );
|
||||
}
|
||||
|
||||
if ( '' === (string) ( $variant['bytes'] ?? '' ) ) {
|
||||
$this->warn_once( sprintf( 'Imagick render for %s variant returned no bytes — degrading.', $role ) );
|
||||
return null;
|
||||
}
|
||||
$variant['role'] = $role;
|
||||
|
||||
return $variant;
|
||||
} catch ( \Throwable $e ) {
|
||||
$this->warn_once( sprintf( 'Imagick render failed for %s variant: %s', $role, $e->getMessage() ) );
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the capability rung: cached probe + override filter. The
|
||||
* same-named filter lets tests/ops force a rung (e.g. 'none').
|
||||
*
|
||||
* @return string One of the CAP_* constants.
|
||||
*/
|
||||
private function resolve_capability(): string {
|
||||
$fingerprint = md5(
|
||||
PHP_VERSION . '|' . ( extension_loaded( 'imagick' ) ? 'im1' : 'im0' ) . '|' . ( extension_loaded( 'gd' ) ? 'gd1' : 'gd0' )
|
||||
);
|
||||
|
||||
$cached = get_option( self::OPTION_CAPABILITY, array() );
|
||||
if ( is_array( $cached ) && $fingerprint === ( $cached['fingerprint'] ?? '' ) && isset( $cached['capability'] ) ) {
|
||||
$capability = (string) $cached['capability'];
|
||||
} else {
|
||||
$capability = $this->probe_capability();
|
||||
update_option(
|
||||
self::OPTION_CAPABILITY,
|
||||
array(
|
||||
'fingerprint' => $fingerprint,
|
||||
'capability' => $capability,
|
||||
),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
$capability = (string) apply_filters( 'prautoblogger_image_compose_capability', $capability );
|
||||
|
||||
return in_array( $capability, array( self::CAP_IMAGICK, self::CAP_GD, self::CAP_NONE ), true )
|
||||
? $capability
|
||||
: self::CAP_NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the host for the best available rung (uncached).
|
||||
* @return string One of the CAP_* constants.
|
||||
*/
|
||||
private function probe_capability(): string {
|
||||
if ( PRAutoBlogger_Image_Composer_Canvas::is_imagick_usable( $this->fonts_dir() . 'Poppins-Bold.ttf' ) ) {
|
||||
return self::CAP_IMAGICK;
|
||||
}
|
||||
|
||||
$png_crop = array(
|
||||
'mime_type' => 'image/png',
|
||||
'methods' => array( 'resize', 'crop' ),
|
||||
);
|
||||
if ( function_exists( 'wp_image_editor_supports' ) && wp_image_editor_supports( $png_crop ) ) {
|
||||
return self::CAP_GD;
|
||||
}
|
||||
|
||||
return self::CAP_NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily build the Imagick renderer with filtered layout + asset paths.
|
||||
* @return PRAutoBlogger_Image_Composer_Imagick
|
||||
*/
|
||||
private function renderer(): PRAutoBlogger_Image_Composer_Imagick {
|
||||
if ( null === $this->renderer ) {
|
||||
$this->renderer = new PRAutoBlogger_Image_Composer_Imagick(
|
||||
$this->layout(),
|
||||
dirname( __DIR__, 2 ) . '/assets/brand/',
|
||||
$this->fonts_dir()
|
||||
);
|
||||
}
|
||||
return $this->renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout geometry: pure defaults behind the documented override filter.
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function layout(): array {
|
||||
return (array) apply_filters( 'prautoblogger_image_compose_layout', PRAutoBlogger_Image_Composer_Layout::defaults() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundled font directory (path-derived, no constants — unit-test safe).
|
||||
* @return string Absolute path with trailing slash.
|
||||
*/
|
||||
private function fonts_dir(): string {
|
||||
return dirname( __DIR__, 2 ) . '/assets/fonts/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant roles from settings, whitelisted at point of use.
|
||||
* @return string[] Subset of ['og', 'square'], deduplicated, in config order.
|
||||
*/
|
||||
private function configured_variants(): array {
|
||||
$raw = (string) get_option( self::OPTION_VARIANTS, 'og,square' );
|
||||
$roles = array();
|
||||
foreach ( explode( ',', $raw ) as $piece ) {
|
||||
$piece = strtolower( trim( $piece ) );
|
||||
if ( in_array( $piece, array( 'og', 'square' ), true ) && ! in_array( $piece, $roles, true ) ) {
|
||||
$roles[] = $piece;
|
||||
}
|
||||
}
|
||||
return $roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the pass-through featured variant from the provider result.
|
||||
* @param array $image_data Provider result.
|
||||
* @return array{bytes: string, mime_type: string, width: int, height: int, role: string}
|
||||
*/
|
||||
private function passthrough_variant( array $image_data ): array {
|
||||
return array(
|
||||
'bytes' => (string) ( $image_data['bytes'] ?? '' ),
|
||||
'mime_type' => (string) ( $image_data['mime_type'] ?? 'image/png' ),
|
||||
'width' => (int) ( $image_data['width'] ?? 0 ),
|
||||
'height' => (int) ( $image_data['height'] ?? 0 ),
|
||||
'role' => 'featured',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a degradation WARNING once per run (one row is enough for ops).
|
||||
* @param string $message Warning text.
|
||||
*/
|
||||
private function warn_once( string $message ): void {
|
||||
if ( self::$warned_this_run ) {
|
||||
return;
|
||||
}
|
||||
self::$warned_this_run = true;
|
||||
PRAutoBlogger_Logger::instance()->warning( $message, 'image_composer' );
|
||||
}
|
||||
|
||||
/** Reset per-run state (unit tests only). */
|
||||
public static function reset_run_state(): void {
|
||||
self::$warned_this_run = false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user