Compare commits
6 Commits
claude/ima
...
claude/ima
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f76df1ba5 | ||
|
|
28947a0caa | ||
|
|
d559fc58ab | ||
|
|
a18aa9f316 | ||
|
|
d00e746727 | ||
|
|
a4f3b1832e |
17
.github/workflows/cto-review.yml
vendored
17
.github/workflows/cto-review.yml
vendored
@@ -123,17 +123,18 @@ jobs:
|
||||
git fetch origin ${{ github.event.pull_request.base.ref }} --depth=50
|
||||
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
||||
DIFF=$(git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000)
|
||||
# Write the diff to a file, not a step output / env var. Passing a
|
||||
# ~400KB diff through the process env (execve argv+envp) exceeds the
|
||||
# runner ARG_MAX and fails the next step with "Argument list too long"
|
||||
# (issue #110). File transport also sidesteps the ~1MB step-output cap.
|
||||
git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000 > /tmp/cto-review-diff.txt
|
||||
FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
|
||||
SIZE=$(echo -n "$DIFF" | wc -c)
|
||||
SIZE=$(wc -c < /tmp/cto-review-diff.txt)
|
||||
{
|
||||
echo "size=$SIZE"
|
||||
echo "files<<EOF_FILES"
|
||||
echo "$FILES"
|
||||
echo "EOF_FILES"
|
||||
echo "diff<<EOF_DIFF"
|
||||
echo "$DIFF"
|
||||
echo "EOF_DIFF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Load repo conventions
|
||||
@@ -161,7 +162,7 @@ jobs:
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
DIFF: ${{ steps.diff.outputs.diff }}
|
||||
DIFF_PATH: /tmp/cto-review-diff.txt
|
||||
DIFF_SIZE: ${{ steps.diff.outputs.size }}
|
||||
CHANGED_FILES: ${{ steps.diff.outputs.files }}
|
||||
CONVENTIONS: ${{ steps.conventions.outputs.content }}
|
||||
@@ -229,7 +230,9 @@ jobs:
|
||||
user_blocks.append(f"**Changed files:**\n```\n{os.environ['CHANGED_FILES']}\n```\n")
|
||||
if os.environ.get("CONVENTIONS", "").strip():
|
||||
user_blocks.append(f"# Repo CONVENTIONS.md\n{os.environ['CONVENTIONS']}\n")
|
||||
user_blocks.append(f"# Diff\n```diff\n{os.environ['DIFF']}\n```\n")
|
||||
with open(os.environ["DIFF_PATH"], encoding="utf-8", errors="replace") as _df:
|
||||
_diff_text = _df.read()
|
||||
user_blocks.append(f"# Diff\n```diff\n{_diff_text}\n```\n")
|
||||
user_message = "\n\n".join(user_blocks)
|
||||
|
||||
tools = [{
|
||||
|
||||
94
.github/workflows/deploy.yml
vendored
94
.github/workflows/deploy.yml
vendored
@@ -28,92 +28,10 @@ jobs:
|
||||
-not -path './.github/*' \
|
||||
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
|
||||
|
||||
deploy-staging:
|
||||
name: Deploy to Staging
|
||||
deploy:
|
||||
name: Deploy (shared pipeline)
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
- name: Deploy via rsync → staging
|
||||
run: |
|
||||
rsync -avz --delete \
|
||||
--exclude '.git' --exclude '.github' \
|
||||
--exclude 'tests' --exclude 'phpunit.xml.dist' \
|
||||
--exclude 'composer.json' --exclude 'composer.lock' \
|
||||
--exclude 'vendor' --exclude '.distignore' \
|
||||
--exclude 'ARCHITECTURE.md' --exclude 'CONVENTIONS.md' \
|
||||
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
|
||||
./ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/staging/wp-content/plugins/prautoblogger/
|
||||
- name: Staging health check
|
||||
run: |
|
||||
sleep 5
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://staging.peptiderepo.com/ || echo "000")
|
||||
echo "Staging responded: HTTP $STATUS"
|
||||
[ "$STATUS" = "000" ] && echo "Warning: staging DNS may still be propagating" || true
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/deploy_key
|
||||
|
||||
smoke:
|
||||
name: Smoke tests (staging)
|
||||
needs: deploy-staging
|
||||
continue-on-error: true
|
||||
uses: peptiderepo/peptide-e2e/.github/workflows/smoke.yml@main
|
||||
secrets:
|
||||
STAGING_WP_ADMIN_USER: ${{ secrets.STAGING_WP_ADMIN_USER }}
|
||||
STAGING_WP_ADMIN_PASSWORD: ${{ secrets.STAGING_WP_ADMIN_PASSWORD }}
|
||||
|
||||
deploy-production:
|
||||
name: Deploy to Production
|
||||
needs: [validate, deploy-staging]
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
- name: Deploy via rsync → production
|
||||
run: |
|
||||
rsync -avz --delete \
|
||||
--exclude '.git' --exclude '.github' \
|
||||
--exclude 'tests' --exclude 'phpunit.xml.dist' \
|
||||
--exclude 'composer.json' --exclude 'composer.lock' \
|
||||
--exclude 'vendor' --exclude '.distignore' \
|
||||
--exclude 'ARCHITECTURE.md' --exclude 'CONVENTIONS.md' \
|
||||
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
|
||||
./ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/wp-content/plugins/prautoblogger/
|
||||
- name: Purge LiteSpeed Cache
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
|
||||
${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }} \
|
||||
"cd ~/domains/peptiderepo.com/public_html && \
|
||||
wp litespeed-purge all 2>/dev/null || \
|
||||
(find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null) || true"
|
||||
- name: Production health check
|
||||
run: |
|
||||
sleep 5
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://peptiderepo.com)
|
||||
if [ "$STATUS" != "200" ] && [ "$STATUS" != "403" ]; then
|
||||
echo "ERROR: Site returned unexpected HTTP $STATUS after deploy"
|
||||
exit 1
|
||||
fi
|
||||
echo "Production healthy (HTTP $STATUS)"
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/deploy_key
|
||||
- name: Deploy summary
|
||||
run: |
|
||||
echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Commit:** \`${GITHUB_SHA::7}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Deployed to:** peptiderepo.com" >> $GITHUB_STEP_SUMMARY
|
||||
uses: peptiderepo/peptide-e2e/.github/workflows/deploy-app.yml@main
|
||||
with:
|
||||
target_path: wp-content/plugins/prautoblogger
|
||||
secrets: inherit
|
||||
|
||||
22
.github/workflows/main-push-audit.yml
vendored
22
.github/workflows/main-push-audit.yml
vendored
@@ -29,12 +29,22 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const sha = context.sha;
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: sha,
|
||||
});
|
||||
const merged = prs.find(p => p.merged_at);
|
||||
// Squash-merge -> PR association is eventually-consistent; poll briefly
|
||||
// before concluding this was a direct push (avoids false positives on merges).
|
||||
async function findMergedPR() {
|
||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: sha,
|
||||
});
|
||||
const m = prs.find(p => p.merged_at);
|
||||
if (m) return m;
|
||||
if (attempt < 5) await new Promise(r => setTimeout(r, 10000));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const merged = await findMergedPR();
|
||||
if (merged) {
|
||||
core.info(`OK: commit ${sha.substring(0,7)} came from PR #${merged.number}`);
|
||||
return;
|
||||
|
||||
@@ -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)
|
||||
@@ -147,6 +161,14 @@ prautoblogger/
|
||||
│ │ ├── class-post-assembler.php # Post-creation helpers: taxonomy, log linking, images, sanitization
|
||||
│ │ ├── 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)
|
||||
@@ -437,15 +459,20 @@ All prefixed with `prautoblogger_`:
|
||||
| `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') |
|
||||
| `prautoblogger_image_model` | Image model slug from `Image_Model_Registry::get_models()`; provider is derived from this on save |
|
||||
| `prautoblogger_image_provider` | Derived from the chosen model on save (v0.8.0+); not editable in admin UI |
|
||||
| `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty |
|
||||
| `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty. v0.16.0: the default now instructs the LLM to emit a 1-2 sentence editorial topic/mechanism summary as the SCENE (was a comic gag) |
|
||||
| `prautoblogger_image_nsfw_retry` | Toggle: retry NSFW-blocked image slots with a rule-based fallback prompt (default: '1') |
|
||||
| `prautoblogger_migrated_image_provider_v080` | One-shot migration flag — auto-heals mismatched provider/model pairs on first admin_init after v0.8.0 upgrade |
|
||||
| `prautoblogger_article_font_family` | Font family key: 'default', 'inter', 'georgia', 'merriweather', 'lora', 'open_sans', 'roboto', 'system' |
|
||||
| `prautoblogger_article_font_size` | Body font size in px (0 = theme default). Recommended: 16–18. |
|
||||
| `prautoblogger_table_borders` | Toggle: add borders/padding/striping to tables (default: '1') |
|
||||
| `prautoblogger_image_style_suffix` | Text appended to every image prompt (default: CEO-locked 90s infomercial prompt) |
|
||||
| `prautoblogger_image_style_template` | Full image prompt template (v0.16.0+); contains exactly one `{{ topic_summary }}` token filled per-article with the rewriter scene. Default = `PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE` (editorial scientific illustration). Replaces the Style Suffix. Validated on save (single-token, brief A5) |
|
||||
| `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
|
||||
|
||||
@@ -464,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -575,6 +610,52 @@ Reddit rejected our OAuth API application (April 2026). We initially switched to
|
||||
|
||||
The admin model picker (v1) needs to list OpenRouter models with pricing and capabilities. We fetch `https://openrouter.ai/api/v1/models` (free, unauthenticated) once daily and store the normalized payload in a WP option fronted by a 24h transient. On stale-and-fetch-fails, we serve last-good + surface a warning. The registry class (`class-open-router-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month.
|
||||
|
||||
### #20: Editorial illustration prompt + style-template setting (v0.16.0, May 2026)
|
||||
|
||||
Commit 1 of the in-plugin editorial image pipeline (brief `docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md`). The image style pivots from a single-panel newspaper comic to a **text-free editorial scientific illustration**. Two changes ship here; the deterministic PHP composer is a later commit.
|
||||
|
||||
1. **Prompt structure.** `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` now asks the rewriter LLM for a concise 1-2 sentence topic/mechanism summary (one concrete centered focal subject, no text/people-as-gag/logos) as the SCENE, keeping the short CAPTION line for the unchanged HTML-caption-below-image path. The scene/caption parsing contract (`Image_Scene_Parser`) is unchanged.
|
||||
2. **Template substitution.** The old `trim($scene . ' ' . $style_suffix)` concat is replaced by `PRAutoBlogger_Image_Template_Filler::fill($scene)` in all three entry points (`build_article_prompt`, `build_source_prompt`, `build_fallback_prompt`). The filler resolves the active template (admin override → default), strips control chars and clamps the summary length (brief A5), then substitutes the single `{{ topic_summary }}` token. Degradation (brief A5/A6): if the template lacks exactly one token it appends the summary and logs a warning; if the summary is empty it emits the style-only prompt — a blank or token-only prompt is never sent to the provider.
|
||||
3. **Setting.** New textarea `prautoblogger_image_style_template` (default `PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE`) replaces the Style Suffix field. On save it is sanitised with `sanitize_textarea_field` and validated to contain exactly one token (`Image_Template_Filler::sanitize_for_save`). The old `prautoblogger_image_style_suffix` is mirrored to `..._deprecated` for one cycle and not deleted (one-time migration `prautoblogger_migrated_style_template_v0160`).
|
||||
|
||||
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).
|
||||
|
||||
85
CHANGELOG.md
85
CHANGELOG.md
@@ -5,6 +5,91 @@ 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
|
||||
- **Editorial image prompt (Commit 1 of the in-plugin editorial image pipeline).**
|
||||
The image style pivots from a single-panel newspaper comic to a text-free
|
||||
editorial scientific illustration. The rewriter LLM now emits a concise
|
||||
1-2 sentence topic/mechanism summary as the SCENE (one centered focal
|
||||
subject, no text/people-as-gag/logos), keeping the short CAPTION line for the
|
||||
unchanged HTML-caption-below-image behavior. The scene/caption parsing
|
||||
contract is unchanged. Provider/model are unchanged — production keeps
|
||||
generating via Runware FLUX.1 schnell but now produces text-free editorial
|
||||
base images. See `docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md`.
|
||||
- The prompt builder now substitutes the rewriter scene into an editable style
|
||||
template's `{{ topic_summary }}` slot via the new
|
||||
`PRAutoBlogger_Image_Template_Filler`, replacing the old
|
||||
`scene + style suffix` concatenation, across all three entry points
|
||||
(`build_article_prompt`, `build_source_prompt`, `build_fallback_prompt`).
|
||||
Before substitution the scene is stripped of control characters and
|
||||
length-clamped (brief A5). If the template lacks exactly one token the
|
||||
summary is appended and a warning logged; if the summary is empty the
|
||||
style-only prompt is emitted — a blank or token-only prompt is never sent to
|
||||
the provider (brief A5/A6).
|
||||
|
||||
### Added
|
||||
- New setting **Style Template** (`prautoblogger_image_style_template`,
|
||||
textarea) replacing the Style Suffix field. Default =
|
||||
`PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE` (the editorial template). On save
|
||||
it is validated to contain exactly one `{{ topic_summary }}` token; an invalid
|
||||
template keeps the previous value and surfaces a settings error.
|
||||
|
||||
### Deprecated
|
||||
- `prautoblogger_image_style_suffix` is deprecated and no longer read by the
|
||||
prompt builder. A one-time migration (`prautoblogger_migrated_style_template_v0160`)
|
||||
mirrors its value to `prautoblogger_image_style_suffix_deprecated` for one
|
||||
version cycle and seeds the new template default. The old option is NOT
|
||||
deleted this cycle.
|
||||
|
||||
## [0.15.1] - 2026-05-14
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
assets/brand/logo-horizontal-reverse-128.png
Normal file
BIN
assets/brand/logo-horizontal-reverse-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
BIN
assets/brand/logo-horizontal-reverse-256.png
Normal file
BIN
assets/brand/logo-horizontal-reverse-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/brand/logo-mark-small-112.png
Normal file
BIN
assets/brand/logo-mark-small-112.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
assets/brand/logo-mark-small-56.png
Normal file
BIN
assets/brand/logo-mark-small-56.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
93
assets/fonts/OFL.txt
Normal file
93
assets/fonts/OFL.txt
Normal file
@@ -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.
|
||||
BIN
assets/fonts/Poppins-Bold.ttf
Normal file
BIN
assets/fonts/Poppins-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Poppins-SemiBold.ttf
Normal file
BIN
assets/fonts/Poppins-SemiBold.ttf
Normal file
Binary file not shown.
@@ -330,6 +330,11 @@ class PRAutoBlogger_Admin_Page {
|
||||
if ( 'prautoblogger_image_model' === $option_name ) {
|
||||
return $this->sanitize_image_model( (string) $value );
|
||||
}
|
||||
if ( 'prautoblogger_image_style_template' === $option_name ) {
|
||||
// Multi-line template: validation + textarea-safe sanitisation
|
||||
// (incl. the single-token check, brief A5) lives in the filler.
|
||||
return PRAutoBlogger_Image_Template_Filler::sanitize_for_save( (string) $value );
|
||||
}
|
||||
return sanitize_text_field( (string) $value );
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ declare(strict_types=1);
|
||||
*
|
||||
* What: Declarative field definitions for operational settings sections.
|
||||
* Who calls it: PRAutoBlogger_Settings_Fields::get_fields() merges these in.
|
||||
* Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX constants,
|
||||
* Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE constants,
|
||||
* PRAutoBlogger_Article_Typography (font choices), PRAutoBlogger_Image_Model_Registry.
|
||||
*
|
||||
* @see admin/class-settings-fields.php — Core fields and sections; calls get_extended_fields().
|
||||
@@ -235,12 +235,12 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
'icon' => '🔑',
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_style_suffix',
|
||||
'label' => __( 'Style Suffix', 'prautoblogger' ),
|
||||
'id' => 'prautoblogger_image_style_template',
|
||||
'label' => __( 'Style Template', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX,
|
||||
'description' => __( 'Appended to every image prompt. Controls visual look. Changing mid-run causes visible style drift.', 'prautoblogger' ),
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
|
||||
'description' => __( 'The full image prompt template. Must contain exactly one {{ topic_summary }} token, which is filled with a 1-2 sentence topic/mechanism summary per article. The rest is brand-locked editorial style. Replaces the old Style Suffix (now deprecated). Changing mid-run causes visible style drift.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_prompt_instructions',
|
||||
@@ -248,7 +248,7 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
|
||||
'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE + CAPTION for the image generator. Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
|
||||
'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE (a 1-2 sentence editorial topic/mechanism summary, substituted into the Style Template) plus a CAPTION (HTML text shown below the image). Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_nsfw_retry',
|
||||
@@ -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' ),
|
||||
|
||||
@@ -267,6 +267,24 @@ class PRAutoBlogger {
|
||||
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.16.0): editorial pivot. The comic Style Suffix
|
||||
// is replaced by the editorial Style Template. Mirror the old suffix
|
||||
// value into a deprecated-keyed option for one version cycle (so a
|
||||
// customised value is recoverable / auditable) and seed the new
|
||||
// template with its editorial default. The old option is intentionally
|
||||
// NOT deleted this cycle. See docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md §8 Commit 1.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_template_v0160' ) ) {
|
||||
$old_suffix = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
if ( '' !== $old_suffix && false === get_option( 'prautoblogger_image_style_suffix_deprecated', false ) ) {
|
||||
update_option( 'prautoblogger_image_style_suffix_deprecated', $old_suffix );
|
||||
}
|
||||
// Only seed the new template if the admin has not already set one.
|
||||
if ( '' === (string) get_option( 'prautoblogger_image_style_template', '' ) ) {
|
||||
update_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_template_v0160', '1' );
|
||||
}
|
||||
|
||||
// v0.9.0 — Runware as default image model. v0.10.0 — remove CF Workers AI.
|
||||
PRAutoBlogger_Activator::migrate_default_image_v090();
|
||||
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||
|
||||
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 ) && ( $cached['fingerprint'] ?? '' ) === $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' ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,16 @@ declare(strict_types=1);
|
||||
*
|
||||
* Builds image prompts from article content or source data.
|
||||
*
|
||||
* Uses a cheap LLM (via OpenRouter) to distill article content into concise
|
||||
* visual scene descriptions optimised for image generation. Falls back to rule-based
|
||||
* Uses a cheap LLM (via OpenRouter) to distill article content into a concise
|
||||
* 1-2 sentence editorial topic/mechanism summary, then substitutes that summary
|
||||
* into the admin-editable editorial style template (the {{ topic_summary }}
|
||||
* slot) to produce a text-free image-gen prompt. Falls back to rule-based
|
||||
* synthesis if the LLM call fails, so image generation never blocks on a
|
||||
* prompt-rewriting outage.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
|
||||
* Dependencies: PRAutoBlogger_OpenRouter_Provider (LLM call),
|
||||
* PRAutoBlogger_Image_Template_Filler (template substitution),
|
||||
* PRAutoBlogger_Cost_Tracker (logs prompt-rewrite cost),
|
||||
* PRAutoBlogger_Logger (diagnostics).
|
||||
*
|
||||
@@ -27,34 +30,39 @@ class PRAutoBlogger_Image_Prompt_Builder {
|
||||
* prompts. Exposed as a `public const` so the admin-settings layer can
|
||||
* use it as the default value for `prautoblogger_image_prompt_instructions`.
|
||||
* The option, when non-empty, wins at call time (see rewrite_via_llm).
|
||||
*
|
||||
* Editorial pivot (v0.16.0): the model emits a concise topic/mechanism
|
||||
* summary as the SCENE — concrete, single centered focal subject, no text,
|
||||
* no people-as-gag, no logos — which the prompt builder substitutes into
|
||||
* the editorial style template's {{ topic_summary }} slot. The CAPTION line
|
||||
* is preserved unchanged for the existing HTML-caption-below-image path.
|
||||
*/
|
||||
public const REWRITER_SYSTEM_PROMPT = <<<'PROMPT'
|
||||
You are a comedy writer and single-panel cartoon creator, like Gary Larson (The Far Side) meets science humor.
|
||||
You write subject descriptions for a text-free editorial science illustration about peptides, supplements, or biohacking.
|
||||
|
||||
Given an article title and summary about peptides, supplements, or biohacking, create a SINGLE-PANEL COMIC concept. Output TWO parts separated by a blank line:
|
||||
Given an article title and summary, output TWO parts separated by a blank line:
|
||||
|
||||
SCENE: One sentence describing the visual gag — a funny, absurd, or ironic situation related to the article's topic. Favor CONCRETE, HUMAN-SCALE scenes: people reacting to things, doctors and patients, scientists in a lab, gym bros with peptide vials, partners at a kitchen table, ordinary objects in ordinary rooms. Keep it simple — one clear visual joke, 1-3 characters max.
|
||||
SCENE: A concise 1-2 sentence description of the article's core topic or mechanism, written as the SUBJECT of a single editorial illustration. Name ONE concrete, centered focal subject and the few supporting visual elements around it (e.g. a labelled-free vial and dropper, a stylised receptor on a cell membrane, a molecular chain, a dosing syringe, an organ cross-section). Favor a clear central focal point with margin around it so the image crops well. This is image-gen direction, not prose.
|
||||
|
||||
CAPTION: A short, punchy caption or speech bubble line (under 15 words) that delivers the punchline. The humor should be dry, nerdy, and accessible — the reader should chuckle even if they only half-understand the science.
|
||||
CAPTION: A short, informative caption line (under 15 words) for display as HTML text below the image. Plain and editorial, not a joke.
|
||||
|
||||
Rules:
|
||||
- The joke MUST relate to the article's actual subject matter, not generic science humor
|
||||
- DO NOT personify molecules, peptides, hormones, proteins, or other chemical structures (no "anthropomorphic molecule", no "cartoon hormone"). Image models render anthropomorphized chemistry as incoherent blobs. If the article is about a substance, show a PERSON reacting to or using the substance; if the article is abstract (a mechanism, a process, a concept), fall back on a human observer reacting to its effect.
|
||||
- Characters can have simple cartoon faces (round heads, dot eyes, expressive eyebrows)
|
||||
- Keep the scene physically simple — few objects, clear staging, easy to read at thumbnail size
|
||||
- The caption should work as a standalone joke even without the image
|
||||
- No logos, watermarks, or branding
|
||||
- The SCENE must depict the article's actual subject/mechanism, not a generic science scene.
|
||||
- NO text, words, captions, speech bubbles, labels, or logos described in the SCENE — the illustration is text-free; text is rendered separately.
|
||||
- NO people used as a visual gag and NO cartoon/comic framing. A human anatomical element (a cell, an organ, a hand holding a vial) is fine if it is the legitimate subject.
|
||||
- Do NOT personify molecules, peptides, hormones, or proteins (no "smiling molecule"); depict them as clean stylised diagrams or structures.
|
||||
- Keep the staging simple with one clear focal subject, readable at small sizes.
|
||||
- Output ONLY the scene and caption. No preamble, no explanation.
|
||||
|
||||
Example output format:
|
||||
A muscular gym bro stares, deflated, at a tiny glass vial in his hand while his friend squats a barbell effortlessly in the background.
|
||||
A single glass peptide vial on a clean surface with a fine dropper above it, a faint stylised molecular chain arcing behind as a backdrop, centered with generous negative space.
|
||||
|
||||
"They told me the peptide did the work, not the squats."
|
||||
A concentrated look at how the compound is prepared before use.
|
||||
PROMPT;
|
||||
|
||||
/**
|
||||
* Max tokens for the rewriter response. Comic concepts need more room
|
||||
* for the scene description plus the caption punchline.
|
||||
* Max tokens for the rewriter response. Enough room for the 1-2 sentence
|
||||
* topic/mechanism summary plus the short editorial caption line.
|
||||
*/
|
||||
private const REWRITER_MAX_TOKENS = 180;
|
||||
|
||||
@@ -85,8 +93,9 @@ PROMPT;
|
||||
* Build a visual prompt from finished article content.
|
||||
*
|
||||
* Tries LLM rewriting first; falls back to rule-based synthesis on
|
||||
* failure. Splits scene (for image gen) from caption (HTML below the
|
||||
* image) and appends the style suffix.
|
||||
* failure. Splits scene (the topic summary, for image gen) from caption
|
||||
* (HTML below the image) and substitutes the scene into the editorial
|
||||
* style template's {{ topic_summary }} slot.
|
||||
*
|
||||
* @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data
|
||||
* @return array{prompt: string, caption: string}
|
||||
@@ -98,10 +107,8 @@ PROMPT;
|
||||
|
||||
$parsed = $this->rewrite_via_llm( $title, $first_para );
|
||||
|
||||
$style_suffix = $this->get_style_suffix();
|
||||
|
||||
return array(
|
||||
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
||||
'caption' => $parsed['caption'],
|
||||
);
|
||||
}
|
||||
@@ -120,10 +127,8 @@ PROMPT;
|
||||
|
||||
$parsed = $this->rewrite_via_llm( $title, $context );
|
||||
|
||||
$style_suffix = $this->get_style_suffix();
|
||||
|
||||
return array(
|
||||
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
||||
'caption' => $parsed['caption'],
|
||||
);
|
||||
}
|
||||
@@ -260,10 +265,9 @@ PROMPT;
|
||||
* @return array{prompt: string, caption: string}
|
||||
*/
|
||||
public function build_fallback_prompt( string $title ): array {
|
||||
$parsed = PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, '' );
|
||||
$style_suffix = $this->get_style_suffix();
|
||||
$parsed = PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, '' );
|
||||
return array(
|
||||
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
||||
'caption' => $parsed['caption'],
|
||||
);
|
||||
}
|
||||
@@ -281,20 +285,4 @@ PROMPT;
|
||||
$override = (string) get_option( 'prautoblogger_image_prompt_instructions', '' );
|
||||
return '' !== trim( $override ) ? $override : self::REWRITER_SYSTEM_PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image style suffix with empty-string safeguard.
|
||||
*
|
||||
* Treats both absent and empty string as "use default" to prevent
|
||||
* silent style loss if the admin settings field is saved empty.
|
||||
*
|
||||
* @return string The style suffix with safe fallback.
|
||||
*/
|
||||
private function get_style_suffix(): string {
|
||||
$style_suffix = (string) get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
if ( '' === trim( $style_suffix ) ) {
|
||||
$style_suffix = PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX;
|
||||
}
|
||||
return $style_suffix;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,22 +70,25 @@ class PRAutoBlogger_Image_Scene_Parser {
|
||||
/**
|
||||
* Rule-based fallback when LLM rewriting is unavailable.
|
||||
*
|
||||
* Kept deliberately simple — this only runs if OpenRouter is down.
|
||||
* Kept deliberately simple — this only runs if OpenRouter is down. Produces
|
||||
* a text-free editorial-illustration subject (not a comic) so the prompt
|
||||
* builder always has a non-empty topic summary to substitute into the
|
||||
* style template, never shipping a blank prompt to the provider (brief A6).
|
||||
*
|
||||
* @param string $title Main heading / topic.
|
||||
* @param string $supporting_text Additional context.
|
||||
* @return array{scene: string, caption: string} Scene for image gen, caption for HTML.
|
||||
*/
|
||||
public static function synthesize_visual_concepts_fallback( string $title, string $supporting_text ): array {
|
||||
// Fallback produces a simple comic concept when the LLM is unavailable.
|
||||
// Fallback produces a simple editorial-illustration subject when the LLM is unavailable.
|
||||
$scene = sprintf(
|
||||
'A cartoon scientist in a lab coat looking bewildered while examining something related to: %s.',
|
||||
'A clean, centered editorial science illustration representing the topic of %s, with a single clear focal subject and generous negative space.',
|
||||
$title
|
||||
);
|
||||
|
||||
return array(
|
||||
'scene' => trim( $scene ),
|
||||
'caption' => 'Science is full of surprises.',
|
||||
'caption' => '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
185
includes/core/class-image-template-filler.php
Normal file
185
includes/core/class-image-template-filler.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Fills the admin-editable editorial image-prompt template with the
|
||||
* rewriter-produced topic summary, replacing the old "scene + style suffix"
|
||||
* concatenation.
|
||||
*
|
||||
* The template (option `prautoblogger_image_style_template`, default
|
||||
* PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE) contains exactly one
|
||||
* `{{ topic_summary }}` token. This class:
|
||||
* - resolves the active template (admin override wins; blank → default),
|
||||
* - sanitises and length-clamps the topic summary (brief A5),
|
||||
* - substitutes the token, and
|
||||
* - degrades safely (brief A5/A6) so a blank or token-only prompt is never
|
||||
* emitted to the image provider.
|
||||
*
|
||||
* Who calls it: PRAutoBlogger_Image_Prompt_Builder (static call from each of
|
||||
* build_article_prompt / build_source_prompt / build_fallback_prompt).
|
||||
* Dependencies: PRAutoBlogger_Logger (diagnostics only).
|
||||
*
|
||||
* @see core/class-image-prompt-builder.php — Sole caller.
|
||||
* @see ARCHITECTURE.md — Image generation data flow.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Template_Filler {
|
||||
|
||||
/** The single substitution token the template must contain. */
|
||||
public const TOKEN = '{{ topic_summary }}';
|
||||
|
||||
/**
|
||||
* Max characters retained from the rewriter topic summary before it is
|
||||
* substituted into the template. Keeps the final provider prompt bounded
|
||||
* even if the rewriter ignores its length guidance.
|
||||
*/
|
||||
public const MAX_SUMMARY_CHARS = 320;
|
||||
|
||||
/**
|
||||
* Build the final image-generation prompt from a rewriter scene.
|
||||
*
|
||||
* @param string $scene Raw topic/mechanism summary from the rewriter (the
|
||||
* "scene" half of the scene/caption contract).
|
||||
* @return string Provider-ready prompt; never empty.
|
||||
*/
|
||||
public static function fill( string $scene ): string {
|
||||
$template = self::resolve_template();
|
||||
$summary = self::sanitize_summary( $scene );
|
||||
|
||||
// Brief A6: never ship a blank or token-only prompt. If the rewriter
|
||||
// produced nothing usable, return the template with the token removed
|
||||
// only as a last resort — but the caller guards this by passing a
|
||||
// rule-based fallback scene, so an empty summary here is unexpected.
|
||||
if ( '' === $summary ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
'Editorial prompt fill received an empty topic summary; emitting style-only prompt.',
|
||||
'image_template_filler'
|
||||
);
|
||||
return self::strip_token( $template );
|
||||
}
|
||||
|
||||
$token_count = substr_count( $template, self::TOKEN );
|
||||
|
||||
if ( 1 === $token_count ) {
|
||||
return trim( str_replace( self::TOKEN, $summary, $template ) );
|
||||
}
|
||||
|
||||
// Brief A5: admin removed or duplicated the token. Fall back to
|
||||
// appending the summary to the style text and log a warning rather
|
||||
// than shipping a malformed prompt.
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf(
|
||||
'Image style template has %d "%s" tokens (expected 1); appending summary instead of substituting.',
|
||||
$token_count,
|
||||
self::TOKEN
|
||||
),
|
||||
'image_template_filler'
|
||||
);
|
||||
|
||||
$base = self::strip_token( $template );
|
||||
return trim( $base . ' ' . $summary );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise and validate the admin-submitted style template on save.
|
||||
*
|
||||
* Multi-line, so sanitised with sanitize_textarea_field() (not
|
||||
* sanitize_text_field(), which would strip newlines). Per brief A5 the
|
||||
* template must contain exactly one TOKEN; otherwise the previous value is
|
||||
* kept and a settings error is surfaced. A blank submission falls back to
|
||||
* the editorial default so image generation can never be bricked.
|
||||
*
|
||||
* @param string $value Submitted template.
|
||||
* @return string Sanitised, validated template.
|
||||
*/
|
||||
public static function sanitize_for_save( string $value ): string {
|
||||
$clean = sanitize_textarea_field( $value );
|
||||
|
||||
if ( '' === trim( $clean ) ) {
|
||||
return PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE;
|
||||
}
|
||||
|
||||
$token_count = substr_count( $clean, self::TOKEN );
|
||||
if ( 1 !== $token_count ) {
|
||||
add_settings_error(
|
||||
'prautoblogger_image_style_template',
|
||||
'prautoblogger_image_style_template_token',
|
||||
sprintf(
|
||||
/* translators: 1: required token, 2: number of tokens found. */
|
||||
esc_html__( 'The Style Template must contain exactly one %1$s token (found %2$d). Keeping the previous value.', 'prautoblogger' ),
|
||||
esc_html( self::TOKEN ),
|
||||
(int) $token_count
|
||||
)
|
||||
);
|
||||
return (string) get_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active template: admin override wins; blank → default.
|
||||
*
|
||||
* @return string Non-empty template string.
|
||||
*/
|
||||
private static function resolve_template(): string {
|
||||
$override = (string) get_option( 'prautoblogger_image_style_template', '' );
|
||||
if ( '' !== trim( $override ) ) {
|
||||
return $override;
|
||||
}
|
||||
return PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip control characters and clamp length of the rewriter summary
|
||||
* (brief A5). Whitespace runs (including newlines) are collapsed to single
|
||||
* spaces so the summary sits cleanly inside the template.
|
||||
*
|
||||
* @param string $scene Raw rewriter scene.
|
||||
* @return string Sanitised, length-clamped summary (possibly empty).
|
||||
*/
|
||||
private static function sanitize_summary( string $scene ): string {
|
||||
// Remove control characters (C0/C1) except via whitespace collapse below.
|
||||
$clean = preg_replace( '/[\x00-\x1F\x7F]+/u', ' ', $scene );
|
||||
if ( null === $clean ) {
|
||||
// preg_replace can return null on bad UTF-8; degrade conservatively.
|
||||
$clean = preg_replace( '/[\x00-\x1F\x7F]+/', ' ', $scene );
|
||||
$clean = is_string( $clean ) ? $clean : '';
|
||||
}
|
||||
|
||||
// Collapse whitespace runs to single spaces.
|
||||
$collapsed = preg_replace( '/\s+/u', ' ', $clean );
|
||||
$clean = is_string( $collapsed ) ? $collapsed : $clean;
|
||||
$clean = trim( $clean );
|
||||
|
||||
if ( '' === $clean ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Clamp length (multibyte-aware where available).
|
||||
if ( function_exists( 'mb_strlen' ) ) {
|
||||
if ( mb_strlen( $clean ) > self::MAX_SUMMARY_CHARS ) {
|
||||
$clean = rtrim( mb_substr( $clean, 0, self::MAX_SUMMARY_CHARS ) ) . '...';
|
||||
}
|
||||
} elseif ( strlen( $clean ) > self::MAX_SUMMARY_CHARS ) {
|
||||
$clean = rtrim( substr( $clean, 0, self::MAX_SUMMARY_CHARS ) ) . '...';
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every occurrence of the token from a template, tidying the
|
||||
* leftover whitespace so the residual style text is still well-formed.
|
||||
*
|
||||
* @param string $template Template possibly containing the token.
|
||||
* @return string Token-free, trimmed template.
|
||||
*/
|
||||
private static function strip_token( string $template ): string {
|
||||
$stripped = str_replace( self::TOKEN, '', $template );
|
||||
$stripped = preg_replace( '/[ \t]+/', ' ', $stripped );
|
||||
$stripped = is_string( $stripped ) ? $stripped : $template;
|
||||
return trim( $stripped );
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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.15.1
|
||||
* 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.15.1' );
|
||||
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__ ) );
|
||||
@@ -66,6 +66,22 @@ define(
|
||||
'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX',
|
||||
'Style: a single-panel newspaper comic in classic black ink on white paper, simple clean linework like The Far Side or B.C., slightly yellowed newsprint texture, hand-drawn crosshatching for shading, bold panel border, warm and humorous tone, visible in a newspaper comics section. IMPORTANT: Do not render any text, captions, speech bubbles, or words inside the image.'
|
||||
);
|
||||
// Default editorial-illustration prompt template for the image generator.
|
||||
// Updated 2026-05-29 (v0.16.0): pivot from single-panel comic to text-free
|
||||
// editorial scientific illustration. The {{ topic_summary }} token is filled
|
||||
// at build time with a 1-2 sentence topic/mechanism description from the
|
||||
// rewriter LLM; the rest is brand-locked. Replaces the old Style Suffix.
|
||||
// Users may override via Settings -> Images -> Style Template. Per brief A5
|
||||
// the template must contain exactly one {{ topic_summary }} token.
|
||||
define(
|
||||
'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE',
|
||||
"Editorial scientific illustration, flat vector aesthetic, limited palette of\n" .
|
||||
"teal (#1B8A92) and cream (#FAF6EE) with ink (#0D0D0D) line work and accents\n" .
|
||||
"of orange (#FF8A3D) and lime (#7FD600). Subject: {{ topic_summary }}.\n" .
|
||||
"Style reference: Quanta Magazine, The Pudding, scientific editorial illustration.\n" .
|
||||
"Composition: clear focal point, balanced negative space, subtle textural detail.\n" .
|
||||
'Mood: authoritative, clinical, calm. NO TEXT. NO PEOPLE. NO LOGOS. NO LABELS.'
|
||||
);
|
||||
// Opik observability (feature-flag gated).
|
||||
// API credentials must be defined in wp-config.php constants:
|
||||
// - PRAUTOBLOGGER_OPIK_API_KEY: API key for authentication
|
||||
|
||||
@@ -67,11 +67,20 @@ if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER' ) ) {
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'runware' );
|
||||
}
|
||||
|
||||
// Default image style suffix used by ImagePromptBuilder and settings UI.
|
||||
// Default image style suffix used by legacy ImagePromptBuilder paths / settings UI.
|
||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) {
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' );
|
||||
}
|
||||
|
||||
// Editorial style template (v0.16.0): default for prautoblogger_image_style_template.
|
||||
// Must contain exactly one {{ topic_summary }} token (brief A5).
|
||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE' ) ) {
|
||||
define(
|
||||
'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE',
|
||||
"Editorial scientific illustration. Subject: {{ topic_summary }}. NO TEXT. NO PEOPLE. NO LOGOS. NO LABELS."
|
||||
);
|
||||
}
|
||||
|
||||
// WordPress database constants used in $wpdb queries.
|
||||
if ( ! defined( 'ARRAY_A' ) ) {
|
||||
define( 'ARRAY_A', 'ARRAY_A' );
|
||||
|
||||
176
tests/unit/Core/ImageAttacherTest.php
Normal file
176
tests/unit/Core/ImageAttacherTest.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Image_Attacher.
|
||||
*
|
||||
* Validates primary sideload + cost logging (including the v0.17.0 alt-text
|
||||
* fix: the caption, not the model name, becomes alt text) and composed
|
||||
* variant persistence with role/base meta-linking at $0 cost.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class ImageAttacherTest extends BaseTestCase {
|
||||
|
||||
/** @var array Captured update_post_meta calls: [object_id, key, value]. */
|
||||
private array $meta_calls = [];
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
Functions\when( 'is_wp_error' )->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 );
|
||||
}
|
||||
}
|
||||
104
tests/unit/Core/ImageComposerImagickTest.php
Normal file
104
tests/unit/Core/ImageComposerImagickTest.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Image_Composer_Imagick (real renders).
|
||||
*
|
||||
* These run only where ext-imagick is loaded (skipped on the standard CI
|
||||
* matrix); the same renders are exercised by the standalone harness that
|
||||
* produced the PR samples. Asserts the determinism contract — identical
|
||||
* inputs yield byte-identical output in the same environment — plus the
|
||||
* exact variant geometry.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class ImageComposerImagickTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
if ( ! extension_loaded( 'imagick' ) ) {
|
||||
$this->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' );
|
||||
}
|
||||
}
|
||||
144
tests/unit/Core/ImageComposerLayoutTest.php
Normal file
144
tests/unit/Core/ImageComposerLayoutTest.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Image_Composer_Layout.
|
||||
*
|
||||
* Validates the caption clamp (word wrap, hard budgets, ellipsis, multibyte)
|
||||
* and the shape of the layout defaults consumed by the renderers.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class ImageComposerLayoutTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Empty and whitespace-only captions clamp to no lines at all.
|
||||
*/
|
||||
public function test_empty_caption_returns_no_lines(): void {
|
||||
$this->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" );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
325
tests/unit/Core/ImageComposerTest.php
Normal file
325
tests/unit/Core/ImageComposerTest.php
Normal file
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Image_Composer (orchestrator).
|
||||
*
|
||||
* Validates the degradation ladder (Imagick mocked present/absent via the
|
||||
* capability filter), pass-through guarantees, variant config whitelisting,
|
||||
* capability caching, and the uninstall prefix contract.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
use Brain\Monkey\Filters;
|
||||
|
||||
class ImageComposerTest extends BaseTestCase {
|
||||
|
||||
/** @var array Default provider-result fixture fed into compose(). */
|
||||
private array $image_data = [
|
||||
'bytes' => '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. (update_option calls are captured via a
|
||||
* when() alias: an expect() here would be shadowed by the setUp stub.)
|
||||
*/
|
||||
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',
|
||||
],
|
||||
]
|
||||
);
|
||||
$writes = [];
|
||||
Functions\when( 'update_option' )->alias(
|
||||
function ( $key, $value = null, $autoload = null ) use ( &$writes ) {
|
||||
$writes[] = $key;
|
||||
return 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'] );
|
||||
$this->assertSame( [], $writes, 'A matching cache fingerprint must not trigger a probe rewrite.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* A stale fingerprint (host changed) triggers a fresh probe and a cache
|
||||
* rewrite — the auto-invalidation the spec requires. (Captured via a
|
||||
* when() alias: an expect() here would be shadowed by the setUp stub.)
|
||||
*/
|
||||
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',
|
||||
],
|
||||
]
|
||||
);
|
||||
$writes = [];
|
||||
Functions\when( 'update_option' )->alias(
|
||||
function ( $key, $value = null, $autoload = null ) use ( &$writes ) {
|
||||
$writes[] = [ $key, $value, $autoload ];
|
||||
return 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'] );
|
||||
$this->assertCount( 1, $writes, 'A stale fingerprint must rewrite the capability cache.' );
|
||||
$this->assertSame( \PRAutoBlogger_Image_Composer::OPTION_CAPABILITY, $writes[0][0] );
|
||||
$this->assertIsArray( $writes[0][1] );
|
||||
$this->assertArrayHasKey( 'capability', $writes[0][1] );
|
||||
$this->assertNotSame( 'old-host-fingerprint', $writes[0][1]['fingerprint'] ?? '' );
|
||||
$this->assertFalse( $writes[0][2], 'Capability cache must not autoload.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Tests for PRAutoBlogger_Image_Prompt_Builder.
|
||||
*
|
||||
* Validates prompt generation from article content and source data,
|
||||
* including style suffix appending and HTML stripping.
|
||||
* including editorial-template substitution ({{ topic_summary }}) and HTML stripping.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
@@ -68,7 +68,9 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
||||
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_article_prompt( $article_data );
|
||||
|
||||
$this->assertStringContainsString( 'How to Train Your Dragon', $scene );
|
||||
// Style suffix should be appended.
|
||||
// Editorial template wraps the topic summary; token must be substituted.
|
||||
$this->assertStringNotContainsString( '{{ topic_summary }}', $scene );
|
||||
$this->assertStringContainsString( 'Editorial scientific illustration', $scene );
|
||||
$this->assertNotEmpty( $scene );
|
||||
$this->assertGreaterThan( 50, strlen( $scene ) );
|
||||
$this->assertIsString( $caption );
|
||||
|
||||
Reference in New Issue
Block a user