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
115 lines
4.1 KiB
PHP
115 lines
4.1 KiB
PHP
<?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 );
|
||
}
|
||
}
|
||
}
|