diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e4e4b0e..374c0fb 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -75,6 +75,11 @@ PRAutoBlogger is a WordPress plugin that monitors social media (starting with Re │ │ retried once with a rule-based fallback │ │ prompt via Image_NSFW_Retry; a second block │ │ logs WARNING and publishes without that image. +│ │ v0.17.0: results then pass through the local +│ │ deterministic Image_Composer (corner mark + +│ │ branded OG/square variants for Image A; mark +│ │ only for Image B) with a capability ladder +│ │ (imagick → GD resize-only → pass-through). │ │ Sideloads to media library, logs costs. └────────────┬────────────┘ │ @@ -103,6 +108,15 @@ prautoblogger/ ├── CHANGELOG.md # Semantic versioning changelog │ ├── assets/ +│ ├── brand/ # Vendored brand PNGs rasterized from peptide-repo-brand SVGs (composer overlays) +│ │ ├── logo-mark-small-56.png # Corner mark, 1x +│ │ ├── logo-mark-small-112.png # Corner mark / OG band logo, 2x +│ │ ├── logo-horizontal-reverse-128.png # Square footer lockup, 1x +│ │ └── logo-horizontal-reverse-256.png # Square footer lockup, 2x +│ ├── fonts/ # Bundled OFL faces for deterministic text rendering +│ │ ├── Poppins-Bold.ttf # Square-card caption face +│ │ ├── Poppins-SemiBold.ttf # OG band caption face +│ │ └── OFL.txt # SIL Open Font License │ ├── css/ │ │ ├── admin.css # Admin page styles (wp-admin conventions) │ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars) @@ -148,6 +162,13 @@ prautoblogger/ │ │ ├── class-image-pipeline.php # Orchestrates A/B image generation (parallel via batch) │ │ ├── class-image-prompt-builder.php # Generates visual prompts from article/source data │ │ ├── class-image-template-filler.php # Fills the editorial style template's {{ topic_summary }} slot (v0.16.0) +│ │ ├── interface-image-composer.php # Contract: provider bytes → featured-first variant list (v0.17.0) +│ │ ├── class-image-composer.php # Composer orchestrator: cached capability probe + degradation ladder +│ │ ├── class-image-composer-imagick.php # Imagick renderer: corner mark, OG band, square card (WP-free) +│ │ ├── class-image-composer-canvas.php # Imagick primitives: smoke test, cover-crop, logo, deterministic PNG (WP-free) +│ │ ├── class-image-composer-editor.php # Resize-only rung via wp_get_image_editor (GD path) +│ │ ├── class-image-composer-layout.php # Geometry defaults + caption clamp (pure, WP-free) +│ │ ├── class-image-attacher.php # Sideload + cost log + variant meta-linking + caption prepend │ │ ├── class-image-media-sideloader.php # Imports images into WordPress media library │ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits │ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug) @@ -448,6 +469,10 @@ All prefixed with `prautoblogger_`: | `prautoblogger_image_style_suffix` | DEPRECATED (v0.16.0). Former comic style suffix appended to every image prompt; no longer read by the builder. Mirrored to `prautoblogger_image_style_suffix_deprecated` for one cycle and not deleted yet | | `prautoblogger_openrouter_model_registry` | Normalized OpenRouter model list (JSON array, daily refresh, serves as durable cache) | | `prautoblogger_openrouter_model_registry_fetched_at` | Unix timestamp of last successful model registry refresh | +| `prautoblogger_image_compose_enabled` | Toggle: run the deterministic image composer (default '1'; auto-degrades, never blocks publishing) | +| `prautoblogger_image_compose_variants` | Comma list of composed variants for Image A (default 'og,square'; whitelisted at point of use) | +| `prautoblogger_image_featured_mark_enabled` | Toggle: subtle corner mark on featured images (default '1') | +| `prautoblogger_image_compose_capability` | Cached capability probe `{fingerprint, capability}`; fingerprint = PHP version + imagick/gd presence, so it auto-invalidates on host changes | ### Post Meta @@ -466,6 +491,14 @@ Stored on every PRAutoBlogger-generated post: | `_prautoblogger_editor_notes` | Chief editor's review notes | | `_prautoblogger_generated_at` | ISO 8601 timestamp of generation | | `_prautoblogger_research_sources` | JSON array of source URLs used | +| `_prautoblogger_og_image_id` | Attachment ID of the composed 1200×630 OG variant (v0.17.0; the rebuilt SEO stage emits `og:image` from this — key name frozen) | +| `_prautoblogger_square_image_id` | Attachment ID of the composed 1080×1080 square variant (v0.17.0; stored now, no consumer yet) | + +Composed-variant **attachment** meta (v0.17.0): every pipeline attachment gets +`_prautoblogger_image_role` (`featured`/`og`/`square`); OG/square variants also +get `_prautoblogger_base_attachment_id` pointing at the featured attachment +they derive from. All keys keep the `_prautoblogger_` prefix, so the uninstall +prefix-sweep purges them. --- @@ -587,6 +620,42 @@ Commit 1 of the in-plugin editorial image pipeline (brief `docs/proposals/2026-0 Provider/model are unchanged — only the prompt text changes, so production keeps emitting via Runware FLUX.1 schnell but now produces text-free editorial base images. +### #21: Deterministic PHP image composer with capability ladder (v0.17.0, Jun 2026) + +Commit 2 (and final scope) of the in-plugin editorial image pipeline (#20). Small diffusion +models cannot render clean text (2026-04-21 A/B finding), so base images are text-free — +but the one image that travels *without* its HTML caption is the social/OG share image. +A **deterministic local composer** (`PRAutoBlogger_Image_Composer` behind +`PRAutoBlogger_Image_Composer_Interface`) now runs between provider bytes and sideload: + +1. **Variants.** Image A → corner-marked featured (1200×632) + branded OG (1200×630, teal + band, brand mark, baked caption in Poppins SemiBold) + square card (1080×1080: full base + downscaled into a 1080×569 top slice — no upscaling — cream caption panel in Poppins + Bold, teal footer with the horizontal reverse lockup). Image B → corner mark only. The + variant set is config-driven (`prautoblogger_image_compose_variants`, default `og,square`; + square has no consumer yet — CEO wants it rendered + stored now). Variants are + meta-linked via `_prautoblogger_image_role`, `_prautoblogger_base_attachment_id`, + `_prautoblogger_og_image_id`, `_prautoblogger_square_image_id`. The og key is **frozen**: + the rebuilt SEO stage (pipeline-redesign §4.8) emits `og:image` from it. +2. **Capability ladder, never fatal.** Imagick is probed once (PNG format + TTF annotate + smoke test) and cached in `prautoblogger_image_compose_capability`, fingerprinted by + PHP version + imagick/gd presence so host flips (hPanel/cagefs — it has happened) + auto-invalidate the cache. Rungs: imagick → `wp_get_image_editor()` resize-only + (unbranded, square skipped because it would upscale) → pass-through. Every render is + try/caught; degradation logs one WARNING per run; the publish always proceeds with the + editable HTML caption intact. Test/ops override: `prautoblogger_image_compose_capability` + filter. +3. **Determinism.** Bundled OFL fonts (no system-font queries), vendored brand PNGs (no + runtime SVG delegate variance), `stripImage()` + PNG date/time chunks excluded + fixed + compression → byte-stable output **per environment** (not across ImageMagick versions). + Composition is local CPU, $0, logged as a duration-only stage on the `image_composer` + channel — no cost-tracker rows. +4. **Layout tuning.** Geometry/opacity are constants in + `PRAutoBlogger_Image_Composer_Layout::defaults()` behind the + `prautoblogger_image_compose_layout` filter — deliberately not settings; promote only if + the CEO asks to tune them. The caption is hard-clamped (word-boundary wrap + ellipsis, + multibyte-safe) even though the rewriter LLM is already constrained to <15 words. + ### #17: Runware as default image backend (v0.9.0, Apr 2026) After a comic-style A/B round in Apr 2026, FLUX.1 schnell via Runware was chosen as the default image backend. Cost is ~$0.0006/image vs ~$0.039/image for Gemini 2.5 Flash Image via OpenRouter (≈65× cheaper), and the looser schnell fidelity reads as editorial-cartoon style — a feature, not a bug, for our single-panel comic aesthetic. FLUX.1 dev (~$0.02/image, 28 steps) remains available as an opt-in for higher-fidelity runs. The Runware layer mirrors the OpenRouter split (interface + provider + support + pricing + batch), all under the 300-line cap. The batch class dispatches `imageInference` POSTs via `curl_multi_init/exec/select` — wall-clock time for the A/B pair drops to ≈ the slowest single image. A v0.9.0 one-time migration (`prautoblogger_migrated_default_image_v090`) flips sites still on the legacy default; explicit user selections are preserved. Cloudflare Workers AI was removed as an image provider in v0.10.0 (see ADR #16 note below). diff --git a/CHANGELOG.md b/CHANGELOG.md index c714ac4..95ac26d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,54 @@ All notable changes to PRAutoBlogger will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project uses [Semantic Versioning](https://semver.org/). +## [0.17.0] - 2026-06-11 + +### Added +- **Deterministic PHP image composer (Commit 2 of the in-plugin editorial image + pipeline).** Every generated image now passes through a local, $0, + deterministic composer between the provider's bytes and the media sideloader. + Image A yields a corner-marked featured image (1200×632) plus a branded + **OG/social variant** (1200×630, teal band, brand mark, baked editorial + caption in Poppins SemiBold) and a **square card variant** (1080×1080, image + slice + cream caption panel in Poppins Bold + teal footer with the horizontal + lockup). Image B gets the corner mark only. Variants are sideloaded with a + role filename suffix and meta-linked: attachment meta + `_prautoblogger_image_role` + `_prautoblogger_base_attachment_id`, post meta + `_prautoblogger_og_image_id` + `_prautoblogger_square_image_id` (the rebuilt + SEO stage will emit `og:image` from the frozen og key). +- **Capability ladder with cached probe.** Imagick (full compositing) → + `wp_get_image_editor()` GD path (resize-only, unbranded, single WARNING) → + pass-through (single WARNING). The probe result is cached in + `prautoblogger_image_compose_capability`, fingerprinted by PHP version + + imagick/gd presence so it auto-invalidates when the host changes. Override + filter: `prautoblogger_image_compose_capability`. Composition failure can + never block a publish (per-render try/catch + pipeline safety net). +- New Images settings: `prautoblogger_image_compose_enabled` (default on), + `prautoblogger_image_compose_variants` (default `og,square`), + `prautoblogger_image_featured_mark_enabled` (default on). Geometry ships as + layout-class constants behind the `prautoblogger_image_compose_layout` + filter. All keys keep the `prautoblogger_`/`_prautoblogger_` prefixes, so + the existing uninstall prefix-sweep purges them (no uninstall.php changes). +- Vendored brand rasters (`assets/brand/`, from peptide-repo-brand SVG masters) + and bundled OFL fonts (`assets/fonts/`: Poppins Bold + Poppins SemiBold + + OFL.txt) — no system-font or runtime-SVG dependency, byte-stable output per + environment (metadata stripped, PNG date/time chunks excluded). + +### Fixed +- Featured/Image B **alt text is now the editorial caption** instead of the + generation model name (`sideload_and_log()` passed the model as `$alt_text`, + contradicting the sideloader docblock). The model keeps persisting in + `_prautoblogger_image_model`. + +### Changed +- `PRAutoBlogger_Image_Media_Sideloader::sideload_image()` / + `generate_filename()` accept an optional filename role suffix + (back-compat default `''`). +- `PRAutoBlogger_Image_Pipeline` delegates sideload/cost-log, caption + prepending, and variant persistence to the new + `PRAutoBlogger_Image_Attacher` (300-line compliance); also declares the + previously-dynamic `$trace_context` property (PHP 8.2 deprecation). + ## [0.16.0] - 2026-05-29 ### Changed diff --git a/CONVENTIONS.md b/CONVENTIONS.md index e260229..495ab6f 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -150,6 +150,38 @@ interface PRAutoBlogger_Image_Provider_Interface { --- +## How To: Tune the Image Composer (v0.17.0+) + +The deterministic composer (ARCHITECTURE.md #21) renders branded variants between +provider bytes and sideload. Three rules when touching it: + +1. **Geometry lives in code, not settings.** All sizes/colors/opacities come from + `PRAutoBlogger_Image_Composer_Layout::defaults()` and can be overridden via the + `prautoblogger_image_compose_layout` filter: + ```php + add_filter( 'prautoblogger_image_compose_layout', function ( array $layout ): array { + $layout['featured']['mark_opacity'] = 0.40; + return $layout; + } ); + ``` + Promote a value to a settings field only on explicit CEO request. +2. **Keep renderer classes WP-free.** `Image_Composer_Imagick`, `Image_Composer_Canvas`, + and `Image_Composer_Layout` must not call WordPress functions — they are exercised by + a standalone render harness (and the determinism test) without a WP bootstrap. Only + the orchestrator (`Image_Composer`) and the GD rung (`Image_Composer_Editor`) may + touch WP APIs. +3. **Never fatal a publish.** Any new render path needs a try/catch that degrades to + pass-through and logs at most one WARNING per run (`warn_once()`). Capability checks + go through the cached probe (`prautoblogger_image_compose_capability` option + + same-named override filter) — never re-probe per image. + +Adding a new variant role: extend `Layout::defaults()` + the renderer, whitelist the +role in `Image_Composer::configured_variants()` and in +`Image_Attacher::ROLE_POST_META` (new `_prautoblogger_{role}_image_id` post meta keeps +the uninstall prefix-sweep effective), then cover it in the unit ladder tests. + +--- + ## How To: Add a New Admin Setting 1. Define the option key in the settings array in `admin/class-admin-page.php` → `get_settings_fields()` method diff --git a/assets/brand/logo-horizontal-reverse-128.png b/assets/brand/logo-horizontal-reverse-128.png new file mode 100644 index 0000000..379362a Binary files /dev/null and b/assets/brand/logo-horizontal-reverse-128.png differ diff --git a/assets/brand/logo-horizontal-reverse-256.png b/assets/brand/logo-horizontal-reverse-256.png new file mode 100644 index 0000000..c838aff Binary files /dev/null and b/assets/brand/logo-horizontal-reverse-256.png differ diff --git a/assets/brand/logo-mark-small-112.png b/assets/brand/logo-mark-small-112.png new file mode 100644 index 0000000..fa250f4 Binary files /dev/null and b/assets/brand/logo-mark-small-112.png differ diff --git a/assets/brand/logo-mark-small-56.png b/assets/brand/logo-mark-small-56.png new file mode 100644 index 0000000..eea676d Binary files /dev/null and b/assets/brand/logo-mark-small-56.png differ diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt new file mode 100644 index 0000000..76df3b5 --- /dev/null +++ b/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Poppins-Bold.ttf b/assets/fonts/Poppins-Bold.ttf new file mode 100644 index 0000000..1982f38 Binary files /dev/null and b/assets/fonts/Poppins-Bold.ttf differ diff --git a/assets/fonts/Poppins-SemiBold.ttf b/assets/fonts/Poppins-SemiBold.ttf new file mode 100644 index 0000000..c30ad10 Binary files /dev/null and b/assets/fonts/Poppins-SemiBold.ttf differ diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index 3b903a8..805687e 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -258,6 +258,30 @@ class PRAutoBlogger_Settings_Fields_Extended { 'default' => '1', 'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ), ), + array( + 'id' => 'prautoblogger_image_compose_enabled', + 'label' => __( 'Compose Branded Variants', 'prautoblogger' ), + 'type' => 'toggle', + 'section' => 'prautoblogger_images', + 'default' => '1', + 'description' => __( 'Render branded social variants (teal band, logo, baked caption) from each generated image via the local deterministic composer. Auto-degrades to plain resizing or pass-through if the server lacks an image library — publishing is never blocked.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_image_compose_variants', + 'label' => __( 'Composed Variant Set', 'prautoblogger' ), + 'type' => 'text', + 'section' => 'prautoblogger_images', + 'default' => 'og,square', + 'description' => __( 'Comma-separated variants to render for Image A. Supported: og (1200×630 social share), square (1080×1080 card). Unknown values are ignored.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_image_featured_mark_enabled', + 'label' => __( 'Featured Image Corner Mark', 'prautoblogger' ), + 'type' => 'toggle', + 'section' => 'prautoblogger_images', + 'default' => '1', + 'description' => __( 'Overlay a small semi-transparent Peptide Repo mark in the bottom-right corner of featured images, so scraped or re-shared images carry attribution.', 'prautoblogger' ), + ), array( 'id' => 'prautoblogger_runware_model_catalog', 'label' => __( 'Runware Model Catalog', 'prautoblogger' ), diff --git a/includes/core/class-image-attacher.php b/includes/core/class-image-attacher.php new file mode 100644 index 0000000..fca1323 --- /dev/null +++ b/includes/core/class-image-attacher.php @@ -0,0 +1,169 @@ + '_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( + '
— "%s"
', + esc_html( $caption ) + ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => $caption_html . "\n\n" . $post->post_content, + ) + ); + } +} diff --git a/includes/core/class-image-composer-canvas.php b/includes/core/class-image-composer-canvas.php new file mode 100644 index 0000000..6b893d1 --- /dev/null +++ b/includes/core/class-image-composer-canvas.php @@ -0,0 +1,186 @@ +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; + } +} diff --git a/includes/core/class-image-composer-editor.php b/includes/core/class-image-composer-editor.php new file mode 100644 index 0000000..e63ebae --- /dev/null +++ b/includes/core/class-image-composer-editor.php @@ -0,0 +1,114 @@ +> $layout Layout geometry (for target dimensions). + * + * @return array— "%s"
', - 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' ) ) ); } } diff --git a/includes/core/interface-image-composer.php b/includes/core/interface-image-composer.php new file mode 100644 index 0000000..bee9b58 --- /dev/null +++ b/includes/core/interface-image-composer.php @@ -0,0 +1,50 @@ + + * Non-empty list; index 0 always carries role 'featured'. + */ + public function compose( array $image_data, array $context ): array; +} diff --git a/prautoblogger.php b/prautoblogger.php index 1db73bb..3b8c519 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -9,7 +9,7 @@ * Plugin Name: PRAutoBlogger * Plugin URI: https://peptiderepo.com/prautoblogger * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. - * Version: 0.16.0 + * Version: 0.17.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | Defined here so every file in the plugin can reference paths, versions, | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.16.0' ); +define( 'PRAUTOBLOGGER_VERSION', '0.17.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); diff --git a/tests/unit/Core/ImageAttacherTest.php b/tests/unit/Core/ImageAttacherTest.php new file mode 100644 index 0000000..0824d53 --- /dev/null +++ b/tests/unit/Core/ImageAttacherTest.php @@ -0,0 +1,176 @@ +alias( + static function ( $thing ) { + return $thing instanceof \WP_Error; + } + ); + $this->meta_calls = []; + Functions\when( 'update_post_meta' )->alias( + function ( $object_id, $key, $value ) { + $this->meta_calls[] = [ $object_id, $key, $value ]; + return true; + } + ); + $this->stub_get_option( [] ); + } + + /** Helper: find a captured meta value by object id + key. */ + private function meta_value( int $object_id, string $key ) { + foreach ( $this->meta_calls as $call ) { + if ( $call[0] === $object_id && $call[1] === $key ) { + return $call[2]; + } + } + return null; + } + + /** Helper: primary image fixture. */ + private function image_data(): array { + return [ + 'bytes' => 'featured_bytes', + 'mime_type' => 'image/png', + 'width' => 1200, + 'height' => 632, + 'model' => 'flux-1-schnell', + 'cost_usd' => 0.0006, + 'latency_ms' => 1500, + ]; + } + + /** + * sideload_and_log() forwards the caption as alt text (bug fix: the model + * name used to leak into alt text) and logs the generation cost once. + */ + public function test_sideload_and_log_forwards_caption_as_alt_and_logs_cost(): void { + $sideloader = $this->createMock( \PRAutoBlogger_Image_Media_Sideloader::class ); + $sideloader->expects( $this->once() ) + ->method( 'sideload_image' ) + ->with( $this->image_data(), 7, 'Collagen fragments may aid joint repair' ) + ->willReturn( 42 ); + + $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); + $cost_tracker->expects( $this->once() ) + ->method( 'log_image_generation' ) + ->with( 0.0006, 'flux-1-schnell', 7, 'image_a' ); + + $attacher = new \PRAutoBlogger_Image_Attacher( $sideloader, $cost_tracker ); + $result = $attacher->sideload_and_log( $this->image_data(), 7, 'image_a', 'Image A', 'Collagen fragments may aid joint repair' ); + + $this->assertSame( 42, $result ); + } + + /** + * A failed primary sideload returns the WP_Error untouched and logs no cost. + */ + public function test_sideload_and_log_propagates_error_without_cost_row(): void { + $error = new \WP_Error( 'sideload_failed', 'disk full' ); + $sideloader = $this->createMock( \PRAutoBlogger_Image_Media_Sideloader::class ); + $sideloader->method( 'sideload_image' )->willReturn( $error ); + + $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); + $cost_tracker->expects( $this->never() )->method( 'log_image_generation' ); + + $attacher = new \PRAutoBlogger_Image_Attacher( $sideloader, $cost_tracker ); + + $this->assertSame( $error, $attacher->sideload_and_log( $this->image_data(), 7, 'image_a', 'Image A', 'cap' ) ); + } + + /** + * attach_variants() sideloads og + square with the role as filename + * suffix and the caption as alt text, writes role/base attachment meta + * and the role-specific post meta, and never logs composition cost. + */ + public function test_attach_variants_meta_links_og_and_square(): void { + $variants = [ + [ + 'bytes' => 'og_bytes', + 'mime_type' => 'image/png', + 'width' => 1200, + 'height' => 630, + 'role' => 'og', + ], + [ + 'bytes' => 'square_bytes', + 'mime_type' => 'image/png', + 'width' => 1080, + 'height' => 1080, + 'role' => 'square', + ], + ]; + + $returned_ids = [ 'og' => 43, 'square' => 44 ]; + $sideloader = $this->createMock( \PRAutoBlogger_Image_Media_Sideloader::class ); + $sideloader->expects( $this->exactly( 2 ) ) + ->method( 'sideload_image' ) + ->willReturnCallback( + function ( array $image_data, int $post_id, string $alt, string $suffix ) use ( $returned_ids ) { + $this->assertSame( 7, $post_id ); + $this->assertSame( 'A short caption', $alt ); + $this->assertSame( 0.0, $image_data['cost_usd'] ); + $this->assertSame( 'flux-1-schnell', $image_data['model'] ); + $this->assertArrayHasKey( $suffix, $returned_ids, 'filename suffix must be the role' ); + return $returned_ids[ $suffix ]; + } + ); + + $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); + $cost_tracker->expects( $this->never() )->method( 'log_image_generation' ); + + $attacher = new \PRAutoBlogger_Image_Attacher( $sideloader, $cost_tracker ); + $attacher->attach_variants( $variants, 7, 42, 'A short caption', 'flux-1-schnell' ); + + $this->assertSame( 'og', $this->meta_value( 43, '_prautoblogger_image_role' ) ); + $this->assertSame( 42, $this->meta_value( 43, '_prautoblogger_base_attachment_id' ) ); + $this->assertSame( 43, $this->meta_value( 7, '_prautoblogger_og_image_id' ) ); + + $this->assertSame( 'square', $this->meta_value( 44, '_prautoblogger_image_role' ) ); + $this->assertSame( 42, $this->meta_value( 44, '_prautoblogger_base_attachment_id' ) ); + $this->assertSame( 44, $this->meta_value( 7, '_prautoblogger_square_image_id' ) ); + } + + /** + * Unknown roles (e.g. a stray featured entry) are ignored, and a failed + * variant sideload is skipped without meta writes or exceptions. + */ + public function test_attach_variants_skips_unknown_roles_and_failed_sideloads(): void { + $variants = [ + [ 'bytes' => 'f', 'mime_type' => 'image/png', 'width' => 1, 'height' => 1, 'role' => 'featured' ], + [ 'bytes' => 'o', 'mime_type' => 'image/png', 'width' => 1200, 'height' => 630, 'role' => 'og' ], + ]; + + $sideloader = $this->createMock( \PRAutoBlogger_Image_Media_Sideloader::class ); + $sideloader->expects( $this->once() ) // Only og — featured is not a variant role here. + ->method( 'sideload_image' ) + ->willReturn( new \WP_Error( 'sideload_failed', 'nope' ) ); + + $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); + + $attacher = new \PRAutoBlogger_Image_Attacher( $sideloader, $cost_tracker ); + $attacher->attach_variants( $variants, 7, 42, 'cap', 'model' ); + + $this->assertSame( [], $this->meta_calls ); + } +} diff --git a/tests/unit/Core/ImageComposerImagickTest.php b/tests/unit/Core/ImageComposerImagickTest.php new file mode 100644 index 0000000..cf26007 --- /dev/null +++ b/tests/unit/Core/ImageComposerImagickTest.php @@ -0,0 +1,104 @@ +markTestSkipped( 'ext-imagick not available — renderer covered by the server-side harness.' ); + } + } + + /** Helper: renderer wired to the real bundled assets. */ + private function renderer(): \PRAutoBlogger_Image_Composer_Imagick { + $root = dirname( __DIR__, 3 ); + + return new \PRAutoBlogger_Image_Composer_Imagick( + \PRAutoBlogger_Image_Composer_Layout::defaults(), + $root . '/assets/brand/', + $root . '/assets/fonts/' + ); + } + + /** Helper: deterministic 1200×632 fixture base image (gradient + disc). */ + private function fixture_bytes(): string { + $image = new \Imagick(); + $image->newPseudoImage( 1200, 632, 'gradient:#1B8A92-#FAFAF7' ); + $draw = new \ImagickDraw(); + $draw->setFillColor( new \ImagickPixel( '#FF8A3D' ) ); + $draw->circle( 600, 316, 600, 470 ); + $image->drawImage( $draw ); + $image->setImageFormat( 'png' ); + + return $image->getImageBlob(); + } + + /** + * Determinism: rendering the same input twice yields byte-identical + * variants (metadata stripped, PNG date/time chunks excluded). + */ + public function test_same_input_renders_byte_identical_output(): void { + $bytes = $this->fixture_bytes(); + $caption = 'Collagen fragments may aid joint repair'; + + $first = $this->renderer()->compose_og( $bytes, $caption ); + $second = $this->renderer()->compose_og( $bytes, $caption ); + $this->assertSame( hash( 'sha256', $first['bytes'] ), hash( 'sha256', $second['bytes'] ), 'OG render must be byte-stable' ); + + $first = $this->renderer()->compose_square( $bytes, $caption ); + $second = $this->renderer()->compose_square( $bytes, $caption ); + $this->assertSame( hash( 'sha256', $first['bytes'] ), hash( 'sha256', $second['bytes'] ), 'Square render must be byte-stable' ); + + $first = $this->renderer()->compose_featured( $bytes ); + $second = $this->renderer()->compose_featured( $bytes ); + $this->assertSame( hash( 'sha256', $first['bytes'] ), hash( 'sha256', $second['bytes'] ), 'Featured render must be byte-stable' ); + } + + /** + * Variant geometry matches the spec: featured keeps base dimensions, + * OG is exactly 1200×630, square exactly 1080×1080, all PNG. + */ + public function test_variant_dimensions_and_mime(): void { + $bytes = $this->fixture_bytes(); + $caption = 'Collagen fragments may aid joint repair'; + + $featured = $this->renderer()->compose_featured( $bytes ); + $this->assertSame( [ 1200, 632, 'image/png', 'featured' ], [ $featured['width'], $featured['height'], $featured['mime_type'], $featured['role'] ] ); + + $og = $this->renderer()->compose_og( $bytes, $caption ); + $this->assertSame( [ 1200, 630, 'image/png', 'og' ], [ $og['width'], $og['height'], $og['mime_type'], $og['role'] ] ); + + $square = $this->renderer()->compose_square( $bytes, $caption ); + $this->assertSame( [ 1080, 1080, 'image/png', 'square' ], [ $square['width'], $square['height'], $square['mime_type'], $square['role'] ] ); + } + + /** + * Output PNG bytes embed no date text chunks or tIME chunk, so a render + * is stable across wall-clock time, not just within one process. (We + * scan the raw bytes: Imagick synthesizes `date:*` image properties at + * read time even for chunk-free PNGs, so getImageProperties() can't + * distinguish embedded dates from synthesized ones.) + */ + public function test_output_contains_no_date_chunks(): void { + $og = $this->renderer()->compose_og( $this->fixture_bytes(), 'Short caption' ); + + $this->assertFalse( strpos( $og['bytes'], 'date:create' ), 'PNG must not embed a date:create text chunk' ); + $this->assertFalse( strpos( $og['bytes'], 'date:modify' ), 'PNG must not embed a date:modify text chunk' ); + $this->assertFalse( strpos( $og['bytes'], 'tIME' ), 'PNG must not embed a tIME chunk' ); + } +} diff --git a/tests/unit/Core/ImageComposerLayoutTest.php b/tests/unit/Core/ImageComposerLayoutTest.php new file mode 100644 index 0000000..1d92ef7 --- /dev/null +++ b/tests/unit/Core/ImageComposerLayoutTest.php @@ -0,0 +1,144 @@ +assertSame( [], \PRAutoBlogger_Image_Composer_Layout::clamp_caption( '', 52, 2 ) ); + $this->assertSame( [], \PRAutoBlogger_Image_Composer_Layout::clamp_caption( " \n\t ", 52, 2 ) ); + } + + /** + * A single short word stays on one line, untouched. + */ + public function test_single_word_caption(): void { + $this->assertSame( + [ 'Collagen' ], + \PRAutoBlogger_Image_Composer_Layout::clamp_caption( 'Collagen', 52, 2 ) + ); + } + + /** + * A caption exactly at the per-line budget fills the line with no + * ellipsis and no spill. + */ + public function test_caption_exactly_at_budget_is_not_truncated(): void { + $caption = 'abcde fghij'; // 11 chars, budget 11. + $lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( $caption, 11, 1 ); + + $this->assertSame( [ 'abcde fghij' ], $lines ); + } + + /** + * A caption exactly filling max_lines is kept in full (boundary: the + * ellipsis only appears when content is actually dropped). + */ + public function test_caption_exactly_filling_max_lines_keeps_all_words(): void { + $lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( 'aa bb cc dd', 5, 2 ); + + $this->assertSame( [ 'aa bb', 'cc dd' ], $lines ); + } + + /** + * 3× the budget gets cut at max_lines with a trailing ellipsis, and no + * line ever exceeds the per-line budget. + */ + public function test_overflowing_caption_is_ellipsized_within_budget(): void { + $caption = 'Collagen fragments may aid joint repair according to early trials in athletes'; + $lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( $caption, 20, 2 ); + + $this->assertCount( 2, $lines ); + $this->assertStringEndsWith( \PRAutoBlogger_Image_Composer_Layout::ELLIPSIS, $lines[1] ); + foreach ( $lines as $line ) { + $this->assertLessThanOrEqual( 20, mb_strlen( $line ) ); + } + } + + /** + * Words wrap at word boundaries — a word never straddles two lines when + * it fits on the next one. + */ + public function test_wraps_at_word_boundaries(): void { + $lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( 'peptide bonds form chains', 13, 3 ); + + $this->assertSame( [ 'peptide bonds', 'form chains' ], $lines ); + } + + /** + * A single word longer than a full line is hard-split instead of + * overflowing the canvas. + */ + public function test_overlong_single_word_is_hard_split(): void { + $lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( 'abcdefghij', 4, 3 ); + + $this->assertSame( [ 'abcd', 'efgh', 'ij' ], $lines ); + } + + /** + * Multibyte captions are measured in characters, not bytes, and the + * ellipsis path keeps each line within the budget. + */ + public function test_multibyte_caption_counts_characters_not_bytes(): void { + $lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( 'ペプチドは結合組織を強化する可能性がある', 6, 2 ); + + $this->assertCount( 2, $lines ); + foreach ( $lines as $line ) { + $this->assertLessThanOrEqual( 6, mb_strlen( $line ) ); + } + $this->assertStringEndsWith( \PRAutoBlogger_Image_Composer_Layout::ELLIPSIS, $lines[1] ); + } + + /** + * Interior whitespace runs (newlines, tabs, doubles) collapse to single + * spaces before wrapping. + */ + public function test_whitespace_is_collapsed_before_wrapping(): void { + $lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( "joint \n\t repair", 20, 2 ); + + $this->assertSame( [ 'joint repair' ], $lines ); + } + + /** + * Layout defaults carry every key the renderers consume, with the + * spec'd headline geometry (band 120, square slice 569, footer 96). + */ + public function test_defaults_shape_matches_renderer_contract(): void { + $defaults = \PRAutoBlogger_Image_Composer_Layout::defaults(); + + $this->assertSame( [ 'featured', 'og', 'square' ], array_keys( $defaults ) ); + + $this->assertSame( 28, $defaults['featured']['mark_height'] ); + $this->assertSame( 0.55, $defaults['featured']['mark_opacity'] ); + + $this->assertSame( 1200, $defaults['og']['width'] ); + $this->assertSame( 630, $defaults['og']['height'] ); + $this->assertSame( 120, $defaults['og']['band_height'] ); + $this->assertSame( 2, $defaults['og']['caption_max_lines'] ); + + $this->assertSame( 1080, $defaults['square']['width'] ); + $this->assertSame( 1080, $defaults['square']['height'] ); + $this->assertSame( 569, $defaults['square']['slice_height'] ); + $this->assertSame( 96, $defaults['square']['footer_height'] ); + $this->assertSame( 3, $defaults['square']['caption_max_lines'] ); + + foreach ( [ 'og', 'square' ] as $role ) { + foreach ( [ 'caption_font', 'caption_size', 'caption_color', 'caption_chars_per_line', 'caption_line_height' ] as $key ) { + $this->assertArrayHasKey( $key, $defaults[ $role ], "$role missing $key" ); + } + } + } +} diff --git a/tests/unit/Core/ImageComposerTest.php b/tests/unit/Core/ImageComposerTest.php new file mode 100644 index 0000000..0842126 --- /dev/null +++ b/tests/unit/Core/ImageComposerTest.php @@ -0,0 +1,317 @@ + 'raw_base_bytes', + 'mime_type' => 'image/png', + 'width' => 1200, + 'height' => 632, + 'model' => 'flux-1-schnell', + 'cost_usd' => 0.0006, + 'latency_ms' => 1500, + ]; + + protected function setUp(): void { + parent::setUp(); + \PRAutoBlogger_Image_Composer::reset_run_state(); + Functions\when( 'update_option' )->justReturn( true ); + $this->stub_get_option( [] ); // All composer options fall back to defaults. + } + + protected function tearDown(): void { + \PRAutoBlogger_Image_Composer::reset_run_state(); + parent::tearDown(); + } + + /** Helper: composer context for Image A. */ + private function context( string $slot = 'image_a' ): array { + return [ + 'post_id' => 7, + 'caption' => 'Collagen fragments may aid joint repair', + 'title' => 'Collagen Peptides Explained', + 'slot' => $slot, + ]; + } + + /** Helper: a fake rendered variant payload. */ + private function variant( string $role, int $w, int $h ): array { + return [ + 'bytes' => "rendered_{$role}", + 'mime_type' => 'image/png', + 'width' => $w, + 'height' => $h, + 'role' => $role, + ]; + } + + /** + * Compose disabled in settings → single pass-through featured variant, + * renderer untouched. + */ + public function test_disabled_setting_returns_passthrough_only(): void { + $this->stub_get_option( [ \PRAutoBlogger_Image_Composer::OPTION_ENABLED => '0' ] ); + + $renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class ); + $renderer->expects( $this->never() )->method( 'compose_featured' ); + $renderer->expects( $this->never() )->method( 'compose_og' ); + + $composer = new \PRAutoBlogger_Image_Composer( $renderer ); + $variants = $composer->compose( $this->image_data, $this->context() ); + + $this->assertCount( 1, $variants ); + $this->assertSame( 'featured', $variants[0]['role'] ); + $this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] ); + } + + /** + * Empty provider bytes short-circuit to pass-through (no probe, no render). + */ + public function test_empty_bytes_return_passthrough(): void { + $composer = new \PRAutoBlogger_Image_Composer( + $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class ) + ); + $variants = $composer->compose( [ 'bytes' => '' ], $this->context() ); + + $this->assertCount( 1, $variants ); + $this->assertSame( 'featured', $variants[0]['role'] ); + } + + /** + * Ladder rung 1 (Imagick "present" via filter + mocked renderer): + * Image A yields marked featured + og + square, featured first, with the + * caption forwarded to both text-bearing variants. + */ + public function test_imagick_rung_renders_featured_plus_variants(): void { + Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'imagick' ); + + $caption = $this->context()['caption']; + $renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class ); + $renderer->expects( $this->once() )->method( 'compose_featured' ) + ->with( 'raw_base_bytes' )->willReturn( $this->variant( 'featured', 1200, 632 ) ); + $renderer->expects( $this->once() )->method( 'compose_og' ) + ->with( 'raw_base_bytes', $caption )->willReturn( $this->variant( 'og', 1200, 630 ) ); + $renderer->expects( $this->once() )->method( 'compose_square' ) + ->with( 'raw_base_bytes', $caption )->willReturn( $this->variant( 'square', 1080, 1080 ) ); + + $composer = new \PRAutoBlogger_Image_Composer( $renderer ); + $variants = $composer->compose( $this->image_data, $this->context() ); + + $this->assertSame( [ 'featured', 'og', 'square' ], array_column( $variants, 'role' ) ); + $this->assertSame( 'rendered_featured', $variants[0]['bytes'] ); + } + + /** + * Featured corner mark toggled off → featured stays the untouched base, + * but social variants still render. + */ + public function test_mark_disabled_passes_featured_through_but_keeps_variants(): void { + Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'imagick' ); + $this->stub_get_option( [ \PRAutoBlogger_Image_Composer::OPTION_MARK_ENABLED => '0' ] ); + + $renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class ); + $renderer->expects( $this->never() )->method( 'compose_featured' ); + $renderer->method( 'compose_og' )->willReturn( $this->variant( 'og', 1200, 630 ) ); + $renderer->method( 'compose_square' )->willReturn( $this->variant( 'square', 1080, 1080 ) ); + + $composer = new \PRAutoBlogger_Image_Composer( $renderer ); + $variants = $composer->compose( $this->image_data, $this->context() ); + + $this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] ); + $this->assertSame( [ 'featured', 'og', 'square' ], array_column( $variants, 'role' ) ); + } + + /** + * Image B slot gets the corner mark only — no og/square renders ever. + */ + public function test_image_b_slot_yields_mark_only(): void { + Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'imagick' ); + + $renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class ); + $renderer->expects( $this->once() )->method( 'compose_featured' ) + ->willReturn( $this->variant( 'featured', 1200, 632 ) ); + $renderer->expects( $this->never() )->method( 'compose_og' ); + $renderer->expects( $this->never() )->method( 'compose_square' ); + + $composer = new \PRAutoBlogger_Image_Composer( $renderer ); + $variants = $composer->compose( $this->image_data, $this->context( 'image_b' ) ); + + $this->assertCount( 1, $variants ); + $this->assertSame( 'featured', $variants[0]['role'] ); + } + + /** + * The variant option is whitelisted at point of use: unknown tokens are + * dropped, duplicates deduped, config order preserved. + */ + public function test_variant_config_is_whitelisted_and_ordered(): void { + Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'imagick' ); + $this->stub_get_option( + [ \PRAutoBlogger_Image_Composer::OPTION_VARIANTS => ' square, banana ,og, og' ] + ); + + $renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class ); + $renderer->method( 'compose_featured' )->willReturn( $this->variant( 'featured', 1200, 632 ) ); + $renderer->expects( $this->once() )->method( 'compose_square' ) + ->willReturn( $this->variant( 'square', 1080, 1080 ) ); + $renderer->expects( $this->once() )->method( 'compose_og' ) + ->willReturn( $this->variant( 'og', 1200, 630 ) ); + + $composer = new \PRAutoBlogger_Image_Composer( $renderer ); + $variants = $composer->compose( $this->image_data, $this->context() ); + + $this->assertSame( [ 'featured', 'square', 'og' ], array_column( $variants, 'role' ) ); + } + + /** + * Acceptance: with every render throwing (e.g. probe lied / Imagick + * flipped mid-run), compose() still returns the publishable pass-through + * featured variant and never lets the exception escape. + */ + public function test_renderer_failure_degrades_to_passthrough(): void { + Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'imagick' ); + + $renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class ); + $renderer->method( 'compose_featured' )->willThrowException( new \RuntimeException( 'no imagick after all' ) ); + $renderer->method( 'compose_og' )->willThrowException( new \RuntimeException( 'no imagick after all' ) ); + $renderer->method( 'compose_square' )->willThrowException( new \RuntimeException( 'no imagick after all' ) ); + + $composer = new \PRAutoBlogger_Image_Composer( $renderer ); + $variants = $composer->compose( $this->image_data, $this->context() ); + + $this->assertCount( 1, $variants ); + $this->assertSame( 'featured', $variants[0]['role'] ); + $this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] ); + } + + /** + * Ladder rung 3 forced via the override filter: pass-through, no renderer + * involvement, publish data intact. + */ + public function test_capability_none_passes_through(): void { + Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'none' ); + + $renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class ); + $renderer->expects( $this->never() )->method( 'compose_featured' ); + + $composer = new \PRAutoBlogger_Image_Composer( $renderer ); + $variants = $composer->compose( $this->image_data, $this->context() ); + + $this->assertCount( 1, $variants ); + $this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] ); + $this->assertSame( 1200, $variants[0]['width'] ); + $this->assertSame( 632, $variants[0]['height'] ); + } + + /** + * A garbage filter return is coerced to 'none' (pass-through), never an + * undefined rung. + */ + public function test_unknown_capability_value_is_treated_as_none(): void { + Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'webgpu' ); + + $composer = new \PRAutoBlogger_Image_Composer( + $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class ) + ); + $variants = $composer->compose( $this->image_data, $this->context() ); + + $this->assertCount( 1, $variants ); + $this->assertSame( 'featured', $variants[0]['role'] ); + } + + /** + * A cached probe with a matching environment fingerprint is reused — + * no re-probe, no option write. + */ + public function test_matching_capability_cache_skips_probe_write(): void { + $fingerprint = md5( + PHP_VERSION . '|' . ( extension_loaded( 'imagick' ) ? 'im1' : 'im0' ) . '|' . ( extension_loaded( 'gd' ) ? 'gd1' : 'gd0' ) + ); + $this->stub_get_option( + [ + \PRAutoBlogger_Image_Composer::OPTION_CAPABILITY => [ + 'fingerprint' => $fingerprint, + 'capability' => 'none', + ], + ] + ); + Functions\expect( 'update_option' )->never(); + + $composer = new \PRAutoBlogger_Image_Composer( + $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class ) + ); + $variants = $composer->compose( $this->image_data, $this->context() ); + + $this->assertSame( 'featured', $variants[0]['role'] ); + } + + /** + * A stale fingerprint (host changed) triggers a fresh probe and a cache + * rewrite — the auto-invalidation the spec requires. + */ + public function test_stale_capability_cache_reprobes_and_rewrites(): void { + $this->stub_get_option( + [ + \PRAutoBlogger_Image_Composer::OPTION_CAPABILITY => [ + 'fingerprint' => 'old-host-fingerprint', + 'capability' => 'imagick', + ], + ] + ); + Functions\expect( 'update_option' ) + ->once() + ->with( + \PRAutoBlogger_Image_Composer::OPTION_CAPABILITY, + \Mockery::on( + static function ( $value ): bool { + return is_array( $value ) + && isset( $value['fingerprint'], $value['capability'] ) + && 'old-host-fingerprint' !== $value['fingerprint']; + } + ), + false + ) + ->andReturn( true ); + + $composer = new \PRAutoBlogger_Image_Composer( + $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class ) + ); + $variants = $composer->compose( $this->image_data, $this->context() ); + + $this->assertSame( 'featured', $variants[0]['role'] ); + } + + /** + * QA note for the uninstall contract: every new option key keeps the + * `prautoblogger_` prefix so uninstall.php's prefix-sweep purges it. + * (Per spec, uninstall.php itself is intentionally untouched.) + */ + public function test_new_option_keys_keep_uninstall_sweep_prefix(): void { + $keys = [ + \PRAutoBlogger_Image_Composer::OPTION_ENABLED, + \PRAutoBlogger_Image_Composer::OPTION_VARIANTS, + \PRAutoBlogger_Image_Composer::OPTION_MARK_ENABLED, + \PRAutoBlogger_Image_Composer::OPTION_CAPABILITY, + ]; + + foreach ( $keys as $key ) { + $this->assertStringStartsWith( 'prautoblogger_', $key ); + } + } +} diff --git a/tests/unit/Core/ImagePipelineTest.php b/tests/unit/Core/ImagePipelineTest.php index 043fd19..3bbd49b 100644 --- a/tests/unit/Core/ImagePipelineTest.php +++ b/tests/unit/Core/ImagePipelineTest.php @@ -18,6 +18,11 @@ class ImagePipelineTest extends BaseTestCase { protected function setUp(): void { parent::setUp(); + // v0.17.0: the composer caches its capability probe via update_option + // and keeps a once-per-run warning flag that must not leak between tests. + \PRAutoBlogger_Image_Composer::reset_run_state(); + Functions\when( 'update_option' )->justReturn( true ); + // Mock WordPress functions used by the pipeline and its dependencies. Functions\when( 'is_wp_error' )->alias( function ( $thing ) { return $thing instanceof \WP_Error; @@ -346,6 +351,67 @@ class ImagePipelineTest extends BaseTestCase { $this->assertNotEmpty( $result['errors'] ); } + /** + * v0.17.0: composed OG/square variants are sideloaded and meta-linked — + * role + base-attachment meta on the variant attachments, role-specific + * image ID meta on the post, and the featured attachment tagged with its + * role. Composition adds no cost-tracker rows beyond the primary image. + */ + public function test_composed_variants_are_sideloaded_and_meta_linked(): void { + $image_result = [ + 'bytes' => 'fake_image_data', + 'mime_type' => 'image/png', + 'width' => 1200, + 'height' => 632, + 'model' => 'test-model', + 'cost_usd' => 0.05, + 'latency_ms' => 2000, + ]; + + $provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class ); + $provider->method( 'estimate_cost' )->willReturn( 0.05 ); + $provider->method( 'generate_image_batch' )->willReturn( [ 'image_a' => $image_result ] ); + + $cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class ); + $cost_tracker->method( 'would_exceed_budget' )->willReturn( false ); + // Exactly one cost row: the primary image. Variants are $0 local renders. + $cost_tracker->expects( $this->exactly( 1 ) )->method( 'log_image_generation' ); + + $composer = new class() implements \PRAutoBlogger_Image_Composer_Interface { + public function compose( array $image_data, array $context ): array { + return [ + [ 'bytes' => 'marked', 'mime_type' => 'image/png', 'width' => 1200, 'height' => 632, 'role' => 'featured' ], + [ 'bytes' => 'og_png', 'mime_type' => 'image/png', 'width' => 1200, 'height' => 630, 'role' => 'og' ], + [ 'bytes' => 'sq_png', 'mime_type' => 'image/png', 'width' => 1080, 'height' => 1080, 'role' => 'square' ], + ]; + } + }; + + Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' ); + $next_id = 41; + Functions\when( 'media_handle_sideload' )->alias( function () use ( &$next_id ) { + return ++$next_id; // 42 featured, 43 og, 44 square. + } ); + + $meta_calls = []; + Functions\when( 'update_post_meta' )->alias( function ( $object_id, $key, $value ) use ( &$meta_calls ) { + $meta_calls[] = [ $object_id, $key, $value ]; + return true; + } ); + + $pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker, null, $composer ); + $result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ], null ); + + $this->assertSame( 42, $result['image_a_id'] ); + $this->assertContains( [ 42, '_prautoblogger_image_role', 'featured' ], $meta_calls ); + $this->assertContains( [ 43, '_prautoblogger_image_role', 'og' ], $meta_calls ); + $this->assertContains( [ 43, '_prautoblogger_base_attachment_id', 42 ], $meta_calls ); + $this->assertContains( [ 1, '_prautoblogger_og_image_id', 43 ], $meta_calls ); + $this->assertContains( [ 44, '_prautoblogger_image_role', 'square' ], $meta_calls ); + $this->assertContains( [ 44, '_prautoblogger_base_attachment_id', 42 ], $meta_calls ); + $this->assertContains( [ 1, '_prautoblogger_square_image_id', 44 ], $meta_calls ); + } + /** * Test graceful handling when one image in the batch returns an error. */