Files
prautoblogger/includes/core/class-image-composer-editor.php
peptiderepo 6de8cf2031 feat: deterministic image composer - OG + square variants, branding, baked caption (v0.17.0) (#152)
Composer slots between provider bytes and sideload: corner-marked featured,
branded 1200x630 OG + 1080x1080 square with baked clamped caption, capability
ladder (Imagick -> resize-only -> pass-through, never blocks publish).
Alt-text bug fix (caption, not model name). Vendored brand PNGs + OFL Poppins.

QA: APPROVE-WITH-P2-NOTES @ 6f76df1 (qa-reviews/prautoblogger/2026-06-11-6f76df1.md)
CEO greenlight: 2026-06-11 in chat.

Agent-Session: cto-image-composer-merge-20260611
2026-06-11 15:27:48 +07:00

115 lines
4.1 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 );
}
}
}