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:
169
includes/core/class-image-attacher.php
Normal file
169
includes/core/class-image-attacher.php
Normal 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,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
186
includes/core/class-image-composer-canvas.php
Normal file
186
includes/core/class-image-composer-canvas.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Low-level Imagick canvas primitives for the deterministic image composer.
|
||||
*
|
||||
* Capability smoke test, base-image loading, cover-cropping, logo placement,
|
||||
* and deterministic PNG encoding. No WordPress functions anywhere in this
|
||||
* class — it runs unchanged inside the standalone render harness used to
|
||||
* produce the PR sample images.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Composer_Imagick (renderer) per variant.
|
||||
* Dependencies: ext-imagick (callers must gate on is_imagick_usable()).
|
||||
*
|
||||
* @see core/class-image-composer-imagick.php — Sole consumer.
|
||||
* @see core/class-image-composer.php — Caches the capability probe result.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Composer_Canvas {
|
||||
|
||||
/**
|
||||
* Probe whether Imagick can do everything the renderer needs: PNG
|
||||
* encode plus TTF text annotation (the smoke test draws one glyph on a
|
||||
* tiny in-memory canvas). Cheap to run but callers should cache it.
|
||||
*
|
||||
* Side effects: none beyond a transient in-memory Imagick object.
|
||||
*
|
||||
* @param string $font_path Absolute path to a bundled TTF used for the smoke test.
|
||||
*
|
||||
* @return bool True when full compositing is available.
|
||||
*/
|
||||
public static function is_imagick_usable( string $font_path ): bool {
|
||||
if ( ! extension_loaded( 'imagick' ) || ! class_exists( '\Imagick' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$formats = \Imagick::queryFormats( 'PNG' );
|
||||
if ( empty( $formats ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! is_readable( $font_path ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$probe = new \Imagick();
|
||||
$probe->newImage( 8, 8, new \ImagickPixel( '#ffffff' ), 'png' );
|
||||
$draw = new \ImagickDraw();
|
||||
$draw->setFont( $font_path );
|
||||
$draw->setFontSize( 6 );
|
||||
$probe->annotateImage( $draw, 1, 6, 0.0, 'P' );
|
||||
$probe->clear();
|
||||
|
||||
return true;
|
||||
} catch ( \Throwable $e ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load base image bytes into an Imagick object (first frame only).
|
||||
*
|
||||
* @param string $bytes Raw image bytes (PNG/JPEG/WebP — any readable format).
|
||||
*
|
||||
* @return \Imagick Loaded image.
|
||||
* @throws \RuntimeException When the bytes cannot be decoded.
|
||||
*/
|
||||
public static function load_base( string $bytes ): \Imagick {
|
||||
$image = new \Imagick();
|
||||
if ( ! $image->readImageBlob( $bytes ) ) {
|
||||
throw new \RuntimeException( 'Composer could not decode base image bytes.' );
|
||||
}
|
||||
$image->setIteratorIndex( 0 );
|
||||
$image = $image->coalesceImages()->current();
|
||||
$image->setImagePage( 0, 0, 0, 0 );
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Center cover-crop an image to exact target dimensions without upscaling.
|
||||
*
|
||||
* Crops the largest centered region matching the target aspect ratio,
|
||||
* then scales DOWN to the target. Refuses (returns false) when the source
|
||||
* is too small to fill the target without upscaling.
|
||||
*
|
||||
* Side effects: mutates $image in place on success.
|
||||
*
|
||||
* @param \Imagick $image Source image (mutated).
|
||||
* @param int $target_w Target width in px.
|
||||
* @param int $target_h Target height in px.
|
||||
*
|
||||
* @return bool False when the crop would require upscaling.
|
||||
*/
|
||||
public static function cover_crop( \Imagick $image, int $target_w, int $target_h ): bool {
|
||||
$width = $image->getImageWidth();
|
||||
$height = $image->getImageHeight();
|
||||
if ( $width < 1 || $height < 1 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ratio = max( $target_w / $width, $target_h / $height );
|
||||
if ( $ratio > 1.0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$src_w = min( $width, (int) round( $target_w / $ratio ) );
|
||||
$src_h = min( $height, (int) round( $target_h / $ratio ) );
|
||||
$x = (int) floor( ( $width - $src_w ) / 2 );
|
||||
$y = (int) floor( ( $height - $src_h ) / 2 );
|
||||
|
||||
$image->cropImage( $src_w, $src_h, $x, $y );
|
||||
$image->setImagePage( 0, 0, 0, 0 );
|
||||
$image->resizeImage( $target_w, $target_h, \Imagick::FILTER_LANCZOS, 1 );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite a brand PNG onto the canvas at a target height and opacity.
|
||||
*
|
||||
* @param \Imagick $canvas Destination canvas (mutated).
|
||||
* @param string $asset_path Absolute path to the brand PNG.
|
||||
* @param int $target_h Logo height in px (width keeps aspect).
|
||||
* @param int $x Left position on the canvas.
|
||||
* @param int $y Top position on the canvas.
|
||||
* @param float $opacity 1.0 = opaque, 0.55 = subtle corner mark.
|
||||
*
|
||||
* @return int Rendered logo width in px (useful for caption x-offsets).
|
||||
* @throws \RuntimeException When the asset is missing or unreadable.
|
||||
*/
|
||||
public static function place_logo( \Imagick $canvas, string $asset_path, int $target_h, int $x, int $y, float $opacity = 1.0 ): int {
|
||||
if ( ! is_readable( $asset_path ) ) {
|
||||
throw new \RuntimeException( 'Composer brand asset missing: ' . basename( $asset_path ) );
|
||||
}
|
||||
|
||||
$logo = new \Imagick( $asset_path );
|
||||
$logo->setImagePage( 0, 0, 0, 0 );
|
||||
$scale = $target_h / max( 1, $logo->getImageHeight() );
|
||||
$target_w = max( 1, (int) round( $logo->getImageWidth() * $scale ) );
|
||||
$logo->resizeImage( $target_w, $target_h, \Imagick::FILTER_LANCZOS, 1 );
|
||||
|
||||
if ( $opacity < 1.0 ) {
|
||||
$logo->evaluateImage( \Imagick::EVALUATE_MULTIPLY, max( 0.0, $opacity ), \Imagick::CHANNEL_ALPHA );
|
||||
}
|
||||
|
||||
$canvas->compositeImage( $logo, \Imagick::COMPOSITE_OVER, $x, $y );
|
||||
$logo->clear();
|
||||
|
||||
return $target_w;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a canvas as a deterministic PNG variant payload.
|
||||
*
|
||||
* Strips all metadata and excludes PNG date/time chunks so identical
|
||||
* inputs produce byte-identical output in the same environment.
|
||||
*
|
||||
* Side effects: destroys the Imagick object after encoding.
|
||||
*
|
||||
* @param \Imagick $image Finished canvas.
|
||||
* @param string $role Variant role: 'featured', 'og', or 'square'.
|
||||
*
|
||||
* @return array{bytes: string, mime_type: string, width: int, height: int, role: string}
|
||||
*/
|
||||
public static function finalize_png( \Imagick $image, string $role ): array {
|
||||
$image->setImageFormat( 'png' );
|
||||
$image->setImageDepth( 8 );
|
||||
$image->stripImage();
|
||||
$image->setOption( 'png:exclude-chunks', 'date,time' );
|
||||
$image->setOption( 'png:compression-level', '9' );
|
||||
|
||||
$variant = array(
|
||||
'bytes' => $image->getImageBlob(),
|
||||
'mime_type' => 'image/png',
|
||||
'width' => $image->getImageWidth(),
|
||||
'height' => $image->getImageHeight(),
|
||||
'role' => $role,
|
||||
);
|
||||
$image->clear();
|
||||
|
||||
return $variant;
|
||||
}
|
||||
}
|
||||
114
includes/core/class-image-composer-editor.php
Normal file
114
includes/core/class-image-composer-editor.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Resize-only composer rung backed by wp_get_image_editor() (GD path).
|
||||
*
|
||||
* Used when Imagick is unusable: emits unbranded, caption-free center-crops
|
||||
* at each variant's exact dimensions. Variants that would require upscaling
|
||||
* (e.g. a 1080×1080 square from a 1200×632 base — WP's editor never upscales
|
||||
* on crop-resize) are skipped; the OG crop is always achievable from the
|
||||
* standard base. Output is GD PNG, which embeds no timestamps, so this rung
|
||||
* is deterministic too.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Composer when the capability probe says 'gd'.
|
||||
* Dependencies: wp_get_image_editor(), get_temp_dir(), PRAutoBlogger_Logger.
|
||||
*
|
||||
* @see core/class-image-composer.php — Orchestrator + degradation ladder.
|
||||
* @see core/class-image-composer-imagick.php — The full-fidelity rung this replaces.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Composer_Editor {
|
||||
|
||||
/**
|
||||
* Render resize-only variants for the requested roles.
|
||||
*
|
||||
* Side effects: temp file write/delete per variant; debug log on skips.
|
||||
*
|
||||
* @param string $bytes Base image bytes.
|
||||
* @param string[] $roles Variant roles to attempt ('og', 'square').
|
||||
* @param array<string, array<string, mixed>> $layout Layout geometry (for target dimensions).
|
||||
*
|
||||
* @return array<int, array{bytes: string, mime_type: string, width: int, height: int, role: string}>
|
||||
* Successfully resized variants only (possibly empty).
|
||||
*/
|
||||
public function compose_variants( string $bytes, array $roles, array $layout ): array {
|
||||
$variants = array();
|
||||
|
||||
foreach ( $roles as $role ) {
|
||||
$target_w = (int) ( $layout[ $role ]['width'] ?? 0 );
|
||||
$target_h = (int) ( $layout[ $role ]['height'] ?? 0 );
|
||||
if ( $target_w < 1 || $target_h < 1 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$resized = $this->cover_crop( $bytes, $target_w, $target_h );
|
||||
if ( null === $resized ) {
|
||||
PRAutoBlogger_Logger::instance()->debug(
|
||||
sprintf( 'Resize-only %s variant skipped (editor failure or would upscale).', $role ),
|
||||
'image_composer'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$resized['role'] = $role;
|
||||
$variants[] = $resized;
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Center-crop bytes to exact dimensions with the portable WP editor.
|
||||
*
|
||||
* @param string $bytes Source image bytes.
|
||||
* @param int $target_w Target width in px.
|
||||
* @param int $target_h Target height in px.
|
||||
*
|
||||
* @return array{bytes: string, mime_type: string, width: int, height: int}|null
|
||||
* Null when the editor fails or cannot hit the exact size
|
||||
* without upscaling.
|
||||
*/
|
||||
private function cover_crop( string $bytes, int $target_w, int $target_h ): ?array {
|
||||
$temp = get_temp_dir() . uniqid( 'prab_compose_', true ) . '.png';
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- temp file, same pattern as the sideloader.
|
||||
if ( false === file_put_contents( $temp, $bytes ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$editor = wp_get_image_editor( $temp );
|
||||
if ( is_wp_error( $editor ) ) {
|
||||
return null;
|
||||
}
|
||||
if ( is_wp_error( $editor->resize( $target_w, $target_h, true ) ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$size = $editor->get_size();
|
||||
if ( (int) $size['width'] !== $target_w || (int) $size['height'] !== $target_h ) {
|
||||
return null; // Exact size not reachable without upscaling.
|
||||
}
|
||||
|
||||
if ( is_wp_error( $editor->save( $temp, 'image/png' ) ) ) {
|
||||
return null;
|
||||
}
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- temp file we just wrote.
|
||||
$out = file_get_contents( $temp );
|
||||
|
||||
return false === $out ? null : array(
|
||||
'bytes' => $out,
|
||||
'mime_type' => 'image/png',
|
||||
'width' => $target_w,
|
||||
'height' => $target_h,
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->debug( 'WP image editor crop threw: ' . $e->getMessage(), 'image_composer' );
|
||||
return null;
|
||||
} finally {
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort temp cleanup.
|
||||
@unlink( $temp );
|
||||
}
|
||||
}
|
||||
}
|
||||
250
includes/core/class-image-composer-imagick.php
Normal file
250
includes/core/class-image-composer-imagick.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Imagick renderer for the deterministic image composer (full-fidelity rung).
|
||||
*
|
||||
* Renders the three branded variants from one base image: corner-marked
|
||||
* featured (1200×632), teal-band OG (1200×630), and card-layout square
|
||||
* (1080×1080). No WordPress functions anywhere — layout geometry and asset
|
||||
* directories arrive via the constructor, so the class runs unchanged inside
|
||||
* the standalone render harness that produced the PR samples. Methods throw
|
||||
* on failure; the orchestrator catches and degrades, so a renderer error can
|
||||
* never fail a publish.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Composer when the capability probe says 'imagick'.
|
||||
* Dependencies: ext-imagick, bundled brand PNGs + OFL fonts,
|
||||
* PRAutoBlogger_Image_Composer_Canvas (primitives),
|
||||
* PRAutoBlogger_Image_Composer_Layout (caption clamp).
|
||||
*
|
||||
* @see core/class-image-composer.php — Orchestrator + degradation ladder.
|
||||
* @see core/class-image-composer-canvas.php — Crop/logo/PNG primitives.
|
||||
* @see core/class-image-composer-layout.php — Geometry defaults + clamp.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Composer_Imagick {
|
||||
|
||||
/** @var array<string, array<string, mixed>> Layout geometry keyed by role. */
|
||||
private array $layout;
|
||||
|
||||
/** @var string Absolute path to the vendored brand PNG directory (trailing slash). */
|
||||
private string $brand_dir;
|
||||
|
||||
/** @var string Absolute path to the bundled font directory (trailing slash). */
|
||||
private string $fonts_dir;
|
||||
|
||||
/**
|
||||
* Construct with resolved layout + asset locations.
|
||||
*
|
||||
* @param array<string, array<string, mixed>> $layout Filtered layout (see Layout::defaults()).
|
||||
* @param string $brand_dir Brand PNG directory, trailing slash.
|
||||
* @param string $fonts_dir Font directory, trailing slash.
|
||||
*/
|
||||
public function __construct( array $layout, string $brand_dir, string $fonts_dir ) {
|
||||
$this->layout = $layout;
|
||||
$this->brand_dir = $brand_dir;
|
||||
$this->fonts_dir = $fonts_dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured variant: base image untouched except a subtle corner mark.
|
||||
*
|
||||
* @param string $bytes Base image bytes.
|
||||
*
|
||||
* @return array{bytes: string, mime_type: string, width: int, height: int, role: string}
|
||||
* @throws \RuntimeException|\ImagickException On decode/asset failure.
|
||||
*/
|
||||
public function compose_featured( string $bytes ): array {
|
||||
$conf = $this->layout['featured'];
|
||||
$canvas = PRAutoBlogger_Image_Composer_Canvas::load_base( $bytes );
|
||||
|
||||
$mark_h = (int) $conf['mark_height'];
|
||||
$inset = (int) $conf['mark_inset'];
|
||||
// The mark asset is square, so rendered width equals the height.
|
||||
PRAutoBlogger_Image_Composer_Canvas::place_logo(
|
||||
$canvas,
|
||||
$this->brand_dir . $conf['mark_asset'],
|
||||
$mark_h,
|
||||
$canvas->getImageWidth() - $mark_h - $inset,
|
||||
$canvas->getImageHeight() - $mark_h - $inset,
|
||||
(float) $conf['mark_opacity']
|
||||
);
|
||||
|
||||
return PRAutoBlogger_Image_Composer_Canvas::finalize_png( $canvas, 'featured' );
|
||||
}
|
||||
|
||||
/**
|
||||
* OG variant (1200×630): center-cropped base, bottom teal band with the
|
||||
* brand mark and the clamped caption in white.
|
||||
*
|
||||
* @param string $bytes Base image bytes.
|
||||
* @param string $caption Editorial caption (clamped to 2 lines).
|
||||
*
|
||||
* @return array{bytes: string, mime_type: string, width: int, height: int, role: string}
|
||||
* @throws \RuntimeException|\ImagickException On decode/crop/asset failure.
|
||||
*/
|
||||
public function compose_og( string $bytes, string $caption ): array {
|
||||
$conf = $this->layout['og'];
|
||||
$width = (int) $conf['width'];
|
||||
$height = (int) $conf['height'];
|
||||
$band_h = (int) $conf['band_height'];
|
||||
$band_y = $height - $band_h;
|
||||
|
||||
$canvas = PRAutoBlogger_Image_Composer_Canvas::load_base( $bytes );
|
||||
if ( ! PRAutoBlogger_Image_Composer_Canvas::cover_crop( $canvas, $width, $height ) ) {
|
||||
$canvas->clear();
|
||||
throw new \RuntimeException( 'OG variant would require upscaling the base image.' );
|
||||
}
|
||||
|
||||
$this->draw_rect( $canvas, 0, $band_y, $width, $height, (string) $conf['band_color'] );
|
||||
|
||||
$logo_h = (int) $conf['logo_height'];
|
||||
$logo_w = PRAutoBlogger_Image_Composer_Canvas::place_logo(
|
||||
$canvas,
|
||||
$this->brand_dir . $conf['logo_asset'],
|
||||
$logo_h,
|
||||
(int) $conf['logo_x'],
|
||||
$band_y + (int) floor( ( $band_h - $logo_h ) / 2 )
|
||||
);
|
||||
|
||||
$lines = PRAutoBlogger_Image_Composer_Layout::clamp_caption(
|
||||
$caption,
|
||||
(int) $conf['caption_chars_per_line'],
|
||||
(int) $conf['caption_max_lines']
|
||||
);
|
||||
$this->draw_lines(
|
||||
$canvas,
|
||||
$lines,
|
||||
array(
|
||||
'font' => $this->fonts_dir . $conf['caption_font'],
|
||||
'size' => (float) $conf['caption_size'],
|
||||
'color' => (string) $conf['caption_color'],
|
||||
'line_height' => (float) $conf['caption_line_height'],
|
||||
'x' => (int) $conf['logo_x'] + $logo_w + (int) $conf['caption_gap'],
|
||||
'center_y' => $band_y + (int) floor( $band_h / 2 ),
|
||||
'align' => \Imagick::ALIGN_LEFT,
|
||||
)
|
||||
);
|
||||
|
||||
return PRAutoBlogger_Image_Composer_Canvas::finalize_png( $canvas, 'og' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Square variant (1080×1080, card layout, no upscaling): full base
|
||||
* downscaled into the top slice, cream caption panel in Poppins Bold,
|
||||
* teal footer band with the horizontal reverse lockup.
|
||||
*
|
||||
* @param string $bytes Base image bytes.
|
||||
* @param string $caption Editorial caption (clamped to 3 lines).
|
||||
*
|
||||
* @return array{bytes: string, mime_type: string, width: int, height: int, role: string}
|
||||
* @throws \RuntimeException|\ImagickException On decode/crop/asset failure.
|
||||
*/
|
||||
public function compose_square( string $bytes, string $caption ): array {
|
||||
$conf = $this->layout['square'];
|
||||
$width = (int) $conf['width'];
|
||||
$height = (int) $conf['height'];
|
||||
$slice_h = (int) $conf['slice_height'];
|
||||
$footer_h = (int) $conf['footer_height'];
|
||||
$footer_y = $height - $footer_h;
|
||||
|
||||
$slice = PRAutoBlogger_Image_Composer_Canvas::load_base( $bytes );
|
||||
if ( ! PRAutoBlogger_Image_Composer_Canvas::cover_crop( $slice, $width, $slice_h ) ) {
|
||||
$slice->clear();
|
||||
throw new \RuntimeException( 'Square variant would require upscaling the base image.' );
|
||||
}
|
||||
|
||||
$canvas = new \Imagick();
|
||||
$canvas->newImage( $width, $height, new \ImagickPixel( (string) $conf['panel_color'] ), 'png' );
|
||||
$canvas->compositeImage( $slice, \Imagick::COMPOSITE_OVER, 0, 0 );
|
||||
$slice->clear();
|
||||
|
||||
$this->draw_rect( $canvas, 0, $footer_y, $width, $height, (string) $conf['footer_color'] );
|
||||
|
||||
$logo_h = (int) $conf['footer_logo_height'];
|
||||
$logo = new \Imagick( $this->brand_dir . $conf['footer_logo_asset'] );
|
||||
$logo_w = (int) round( $logo->getImageWidth() * ( $logo_h / max( 1, $logo->getImageHeight() ) ) );
|
||||
$logo->clear();
|
||||
PRAutoBlogger_Image_Composer_Canvas::place_logo(
|
||||
$canvas,
|
||||
$this->brand_dir . $conf['footer_logo_asset'],
|
||||
$logo_h,
|
||||
(int) floor( ( $width - $logo_w ) / 2 ),
|
||||
$footer_y + (int) floor( ( $footer_h - $logo_h ) / 2 )
|
||||
);
|
||||
|
||||
$lines = PRAutoBlogger_Image_Composer_Layout::clamp_caption(
|
||||
$caption,
|
||||
(int) $conf['caption_chars_per_line'],
|
||||
(int) $conf['caption_max_lines']
|
||||
);
|
||||
$this->draw_lines(
|
||||
$canvas,
|
||||
$lines,
|
||||
array(
|
||||
'font' => $this->fonts_dir . $conf['caption_font'],
|
||||
'size' => (float) $conf['caption_size'],
|
||||
'color' => (string) $conf['caption_color'],
|
||||
'line_height' => (float) $conf['caption_line_height'],
|
||||
'x' => (int) floor( $width / 2 ),
|
||||
'center_y' => $slice_h + (int) floor( ( $footer_y - $slice_h ) / 2 ),
|
||||
'align' => \Imagick::ALIGN_CENTER,
|
||||
)
|
||||
);
|
||||
|
||||
return PRAutoBlogger_Image_Composer_Canvas::finalize_png( $canvas, 'square' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill an axis-aligned rectangle on the canvas.
|
||||
*
|
||||
* @param \Imagick $canvas Destination canvas (mutated).
|
||||
* @param int $x1 Left.
|
||||
* @param int $y1 Top.
|
||||
* @param int $x2 Right.
|
||||
* @param int $y2 Bottom.
|
||||
* @param string $color Hex fill color.
|
||||
*/
|
||||
private function draw_rect( \Imagick $canvas, int $x1, int $y1, int $x2, int $y2, string $color ): void {
|
||||
$draw = new \ImagickDraw();
|
||||
$draw->setFillColor( new \ImagickPixel( $color ) );
|
||||
$draw->rectangle( (float) $x1, (float) $y1, (float) $x2, (float) $y2 );
|
||||
$canvas->drawImage( $draw );
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw pre-clamped caption lines vertically centered around center_y.
|
||||
*
|
||||
* @param \Imagick $canvas Destination canvas (mutated).
|
||||
* @param string[] $lines Already-clamped lines (may be empty — no-op).
|
||||
* @param array{font: string, size: float, color: string, line_height: float, x: int, center_y: int, align: int} $opts Text options.
|
||||
*
|
||||
* @throws \RuntimeException When the bundled font file is missing.
|
||||
*/
|
||||
private function draw_lines( \Imagick $canvas, array $lines, array $opts ): void {
|
||||
if ( empty( $lines ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! is_readable( $opts['font'] ) ) {
|
||||
throw new \RuntimeException( 'Composer font missing: ' . basename( $opts['font'] ) );
|
||||
}
|
||||
|
||||
$draw = new \ImagickDraw();
|
||||
$draw->setFont( $opts['font'] );
|
||||
$draw->setFontSize( $opts['size'] );
|
||||
$draw->setFillColor( new \ImagickPixel( $opts['color'] ) );
|
||||
$draw->setTextAlignment( $opts['align'] );
|
||||
$draw->setTextAntialias( true );
|
||||
|
||||
$metrics = $canvas->queryFontMetrics( $draw, 'Hg' );
|
||||
$line_step = (int) round( $opts['size'] * $opts['line_height'] );
|
||||
$block_h = ( count( $lines ) - 1 ) * $line_step + (int) round( $metrics['ascender'] - $metrics['descender'] );
|
||||
$baseline = $opts['center_y'] - (int) floor( $block_h / 2 ) + (int) round( $metrics['ascender'] );
|
||||
|
||||
foreach ( $lines as $line ) {
|
||||
$canvas->annotateImage( $draw, (float) $opts['x'], (float) $baseline, 0.0, $line );
|
||||
$baseline += $line_step;
|
||||
}
|
||||
}
|
||||
}
|
||||
173
includes/core/class-image-composer-layout.php
Normal file
173
includes/core/class-image-composer-layout.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Geometry defaults and caption clamping for the deterministic image composer.
|
||||
*
|
||||
* Pure functions and constants only — no WordPress, no Imagick. This keeps
|
||||
* the class unit-testable and usable from a standalone render harness. The
|
||||
* orchestrator exposes defaults() through the `prautoblogger_image_compose_layout`
|
||||
* filter; geometry is intentionally NOT a settings-page surface (promote to
|
||||
* settings only if the CEO asks to tune it — see ARCHITECTURE.md #21).
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Composer (orchestrator) at compose time.
|
||||
* Dependencies: mbstring (bundled with WordPress via compat polyfills).
|
||||
*
|
||||
* @see core/class-image-composer.php — Applies the layout filter.
|
||||
* @see core/class-image-composer-imagick.php — Consumes the geometry values.
|
||||
* @see tests/unit/Core/ImageComposerLayoutTest.php — Clamp boundary cases.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Composer_Layout {
|
||||
|
||||
/** Brand tokens (locked — see peptide-repo-brand README). */
|
||||
public const COLOR_TEAL = '#1B8A92';
|
||||
public const COLOR_LIME = '#7FD600';
|
||||
public const COLOR_ORANGE = '#FF8A3D';
|
||||
public const COLOR_INK = '#0D0D0D';
|
||||
public const COLOR_CREAM = '#FAFAF7';
|
||||
|
||||
/** Ellipsis appended when a caption overflows its line budget. */
|
||||
public const ELLIPSIS = '…';
|
||||
|
||||
/**
|
||||
* Default geometry for every composed variant.
|
||||
*
|
||||
* Asset filenames resolve against assets/brand/ and assets/fonts/ in the
|
||||
* plugin; sizes are px. Values were tuned against rendered samples (see
|
||||
* PR #thread 2026-06-image-composer assets).
|
||||
*
|
||||
* @return array<string, array<string, mixed>> Keyed by role: featured, og, square.
|
||||
*/
|
||||
public static function defaults(): array {
|
||||
return array(
|
||||
'featured' => array(
|
||||
'mark_asset' => 'logo-mark-small-56.png',
|
||||
'mark_height' => 28,
|
||||
'mark_inset' => 16,
|
||||
'mark_opacity' => 0.55,
|
||||
),
|
||||
'og' => array(
|
||||
'width' => 1200,
|
||||
'height' => 630,
|
||||
'band_height' => 120,
|
||||
'band_color' => self::COLOR_TEAL,
|
||||
'logo_asset' => 'logo-mark-small-112.png',
|
||||
'logo_height' => 64,
|
||||
'logo_x' => 40,
|
||||
'caption_font' => 'Poppins-SemiBold.ttf',
|
||||
'caption_size' => 32,
|
||||
'caption_color' => '#FFFFFF',
|
||||
'caption_gap' => 24,
|
||||
'caption_margin_right' => 40,
|
||||
'caption_max_lines' => 2,
|
||||
'caption_chars_per_line' => 52,
|
||||
'caption_line_height' => 1.3,
|
||||
),
|
||||
'square' => array(
|
||||
'width' => 1080,
|
||||
'height' => 1080,
|
||||
'slice_height' => 569,
|
||||
'panel_color' => self::COLOR_CREAM,
|
||||
'footer_height' => 96,
|
||||
'footer_color' => self::COLOR_TEAL,
|
||||
'footer_logo_asset' => 'logo-horizontal-reverse-128.png',
|
||||
'footer_logo_height' => 48,
|
||||
'caption_font' => 'Poppins-Bold.ttf',
|
||||
'caption_size' => 48,
|
||||
'caption_color' => self::COLOR_INK,
|
||||
'caption_max_lines' => 3,
|
||||
'caption_chars_per_line' => 36,
|
||||
'caption_side_padding' => 64,
|
||||
'caption_line_height' => 1.3,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp and word-wrap a caption to a hard per-variant budget.
|
||||
*
|
||||
* Whitespace is collapsed, words wrap at word boundaries, words longer
|
||||
* than a full line are hard-split, and overflow beyond max_lines is cut
|
||||
* with a trailing ellipsis. Multibyte-safe (mb_* functions).
|
||||
*
|
||||
* Side effects: none (pure).
|
||||
*
|
||||
* @param string $caption Raw caption text (any length, any script).
|
||||
* @param int $chars_per_line Hard character budget per line (min 1).
|
||||
* @param int $max_lines Maximum number of lines (min 1).
|
||||
*
|
||||
* @return string[] Wrapped lines; empty array when the caption is blank.
|
||||
*/
|
||||
public static function clamp_caption( string $caption, int $chars_per_line, int $max_lines ): array {
|
||||
$chars_per_line = max( 1, $chars_per_line );
|
||||
$max_lines = max( 1, $max_lines );
|
||||
$normalized = trim( (string) preg_replace( '/\s+/u', ' ', $caption ) );
|
||||
|
||||
if ( '' === $normalized ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$lines = array();
|
||||
$line = '';
|
||||
|
||||
foreach ( explode( ' ', $normalized ) as $word ) {
|
||||
// Hard-split words longer than a full line so they cannot overflow.
|
||||
while ( mb_strlen( $word ) > $chars_per_line ) {
|
||||
if ( '' !== $line ) {
|
||||
$lines[] = $line;
|
||||
$line = '';
|
||||
if ( count( $lines ) >= $max_lines ) {
|
||||
return self::ellipsize( $lines, $chars_per_line );
|
||||
}
|
||||
}
|
||||
$lines[] = mb_substr( $word, 0, $chars_per_line );
|
||||
$word = mb_substr( $word, $chars_per_line );
|
||||
if ( count( $lines ) >= $max_lines ) {
|
||||
return self::ellipsize( $lines, $chars_per_line );
|
||||
}
|
||||
}
|
||||
|
||||
$candidate = ( '' === $line ) ? $word : $line . ' ' . $word;
|
||||
if ( mb_strlen( $candidate ) <= $chars_per_line ) {
|
||||
$line = $candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines[] = $line;
|
||||
$line = $word;
|
||||
if ( count( $lines ) >= $max_lines ) {
|
||||
return self::ellipsize( $lines, $chars_per_line );
|
||||
}
|
||||
}
|
||||
|
||||
if ( '' !== $line ) {
|
||||
$lines[] = $line;
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an ellipsis to the final line, trimming first if the line is
|
||||
* already at the character budget so the result never exceeds it.
|
||||
*
|
||||
* @param string[] $lines Lines already at the max-line count.
|
||||
* @param int $chars_per_line Hard character budget per line.
|
||||
*
|
||||
* @return string[] Lines with a trailing ellipsis on the last one.
|
||||
*/
|
||||
private static function ellipsize( array $lines, int $chars_per_line ): array {
|
||||
$last_index = count( $lines ) - 1;
|
||||
$last = $lines[ $last_index ];
|
||||
|
||||
if ( mb_strlen( $last ) + 1 > $chars_per_line ) {
|
||||
$last = rtrim( mb_substr( $last, 0, max( 0, $chars_per_line - 1 ) ) );
|
||||
}
|
||||
|
||||
$lines[ $last_index ] = $last . self::ELLIPSIS;
|
||||
|
||||
return $lines;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,13 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
||||
* latency_ms: int,
|
||||
* } $image_data Raw image bytes + metadata from PRAutoBlogger_Image_Provider_Interface.
|
||||
* @param int $post_id Target post ID to attach the image to.
|
||||
* @param string $alt_text Alt text for the image (from the generation prompt).
|
||||
* @param string $alt_text Alt text for the image (the editorial caption).
|
||||
* @param string $filename_suffix Optional role hint appended to the filename
|
||||
* (e.g. 'og', 'square' for composed variants).
|
||||
*
|
||||
* @return int|\WP_Error Attachment ID on success, WP_Error on failure.
|
||||
*/
|
||||
public function sideload_image( array $image_data, int $post_id, string $alt_text = '' ) {
|
||||
public function sideload_image( array $image_data, int $post_id, string $alt_text = '', string $filename_suffix = '' ) {
|
||||
// WordPress media functions live in wp-admin/includes/ and are NOT
|
||||
// loaded automatically in cron or REST contexts. Load them if missing.
|
||||
// Why: WP-Cron runs as a frontend request, so admin-only files like
|
||||
@@ -64,7 +66,7 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
||||
|
||||
// Prepare file array for media_handle_sideload().
|
||||
$file_array = array(
|
||||
'name' => $this->generate_filename( $image_data['model'], $image_data['width'], $image_data['height'] ),
|
||||
'name' => $this->generate_filename( $image_data['model'], $image_data['width'], $image_data['height'], $filename_suffix ),
|
||||
'tmp_name' => $temp_file,
|
||||
);
|
||||
|
||||
@@ -135,16 +137,18 @@ class PRAutoBlogger_Image_Media_Sideloader {
|
||||
* @param string $model Model name (e.g., 'flux-1-schnell').
|
||||
* @param int $width Image width.
|
||||
* @param int $height Image height.
|
||||
* @param string $suffix Optional role hint (e.g. 'og', 'square'); back-compat default ''.
|
||||
*
|
||||
* @return string Sanitized filename.
|
||||
*/
|
||||
private function generate_filename( string $model, int $width, int $height ): string {
|
||||
private function generate_filename( string $model, int $width, int $height, string $suffix = '' ): string {
|
||||
$base = sprintf(
|
||||
'prautoblogger_%s_%dx%d_%s',
|
||||
'prautoblogger_%s_%dx%d_%s%s',
|
||||
sanitize_title( $model ),
|
||||
$width,
|
||||
$height,
|
||||
gmdate( 'Y-m-d_H-i-s' )
|
||||
gmdate( 'Y-m-d_H-i-s' ),
|
||||
'' !== $suffix ? '_' . sanitize_title( $suffix ) : ''
|
||||
);
|
||||
|
||||
return $base . '.png'; // Default to PNG — most image models output PNG.
|
||||
|
||||
@@ -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' ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
50
includes/core/interface-image-composer.php
Normal file
50
includes/core/interface-image-composer.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Contract for image composers that turn raw provider bytes into final variants.
|
||||
*
|
||||
* A composer sits between the image provider (raw generated bytes) and the
|
||||
* media sideloader. It receives one base image plus per-post context and
|
||||
* returns one or more finished variants — the first is always the `featured`
|
||||
* role (the bytes that become the post thumbnail), optionally followed by
|
||||
* social variants (`og`, `square`). Implementations must NEVER throw out of
|
||||
* compose(): on any internal failure they degrade to passing the base image
|
||||
* through unmodified so publishing is never blocked.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Pipeline between batch results and sideload.
|
||||
* Dependencies: none — this is the contract only.
|
||||
*
|
||||
* @see core/class-image-composer.php — Default implementation (capability ladder).
|
||||
* @see core/class-image-pipeline.php — Caller.
|
||||
* @see core/class-image-attacher.php — Persists the returned variants.
|
||||
*/
|
||||
interface PRAutoBlogger_Image_Composer_Interface {
|
||||
|
||||
/**
|
||||
* Compose final image variant(s) from raw provider bytes.
|
||||
*
|
||||
* Side effects: implementations may read plugin options and write to the
|
||||
* activity log; they must not make network calls or fail the publish.
|
||||
*
|
||||
* @param array{
|
||||
* bytes: string,
|
||||
* mime_type: string,
|
||||
* width: int,
|
||||
* height: int,
|
||||
* } $image_data Base image bytes + metadata from the provider result.
|
||||
* @param array{
|
||||
* post_id: int,
|
||||
* caption: string,
|
||||
* title: string,
|
||||
* slot: string,
|
||||
* } $context Post ID, editorial caption, post title, and slot
|
||||
* ('image_a' or 'image_b'); only image_a yields variants.
|
||||
*
|
||||
* @return array<int, array{bytes: string, mime_type: string, width: int, height: int, role: string}>
|
||||
* Non-empty list; index 0 always carries role 'featured'.
|
||||
*/
|
||||
public function compose( array $image_data, array $context ): array;
|
||||
}
|
||||
Reference in New Issue
Block a user