diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f95d04a..cfcd857 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -171,6 +171,10 @@ prautoblogger/ │ │ ├── class-open-router-image-pricing.php # Model resolution + per-image cost estimation │ │ ├── class-open-router-config.php # API base URL (direct vs AI Gateway) │ │ ├── class-open-router-request-builder.php # Header building + Hostinger cURL auth workaround +│ │ ├── class-runware-image-provider.php # Runware FLUX.1 (default v0.9.0+) +│ │ ├── class-runware-image-pricing.php # FLUX schnell/dev cost table + resolver +│ │ ├── class-runware-image-support.php # Key, response parsing, retry, dimension snap +│ │ ├── class-runware-image-batch.php # True parallel curl_multi batch dispatcher │ │ ├── class-cloudflare-image-provider.php # Cloudflare Workers AI (sequential batch fallback) │ │ ├── class-cloudflare-image-pricing.php # Model alias + per-MP cost estimation │ │ ├── class-cloudflare-image-validator.php # Non-destructive credential + connectivity check @@ -479,6 +483,7 @@ Stored on every PRAutoBlogger-generated post: | Reddit .json | Fallback for posts + only source for comments | None (unauthenticated) | ~10 req/min (datacenter IPs often blocked) | `providers/class-reddit-json-client.php` | | Google Analytics 4 | Post performance metrics | OAuth2 service account | Standard GA4 limits | `core/class-ga4-client.php`, `core/class-metrics-collector.php` | | Cloudflare Workers AI | Image generation (FLUX.1 schnell / dev) for article hero, thumbnail, and IG placements | API token (encrypted in wp_options) + account ID | Workers AI per-account quotas | `providers/class-cloudflare-image-provider.php`, `providers/class-cloudflare-image-pricing.php`, `providers/class-cloudflare-image-validator.php` | +| Runware (FLUX.1 via runware.ai) | **Default** image backend (v0.9.0+): schnell ~$0.0006/image, dev ~$0.02/image. True parallel generation via curl_multi. | API key (encrypted in wp_options) | Account-level quotas | `providers/class-runware-image-provider.php`, `providers/class-runware-image-pricing.php`, `providers/class-runware-image-support.php`, `providers/class-runware-image-batch.php` | --- @@ -533,6 +538,10 @@ 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. +### #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 remains as a legacy fallback in the registry. + ### #16: Image generation via Cloudflare Workers AI (FLUX.1), via AI Gateway (v0.8.2+) The image-generation layer uses FLUX.1 [schnell] on Cloudflare Workers AI as its default model, chosen for its ~$0.0011/MP cost and 2–3 sec latency. FLUX.1 [dev] is a ~4× cost upgrade exposed as a dropdown for specific posts that warrant it. **v0.8.2+**: calls route through the Cloudflare AI Gateway (same gateway we already use for OpenRouter — `gateway.ai.cloudflare.com/v1/{account}/{gateway}/workers-ai/@cf/black-forest-labs/...`) for response caching, unified cost/latency telemetry, and rate limiting. The 2026-04-15 regression that caused the gateway route to 403 on Workers AI has been resolved upstream; verified by direct probe on 2026-04-21. A `prautoblogger_cf_image_via_gateway` toggle (default on) falls back to the direct `api.cloudflare.com/client/v4/accounts/{id}/ai/run/...` URL if the gateway regresses again. The provider lives behind `PRAutoBlogger_Image_Provider_Interface`, so switching to a different image API (DALL-E, Replicate) is a new class implementation plus a one-line swap in the pipeline wiring. diff --git a/CHANGELOG.md b/CHANGELOG.md index c45daaf..9d61f6a 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,60 @@ 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.9.0] — 2026-04-21 + +### Added + +- **Runware (FLUX.1 schnell) as the default image provider.** New + provider at `includes/providers/class-runware-image-*` generates + ~$0.0006/image on FLUX.1 schnell (`runware:100@1`) — roughly 65× cheaper + than the previous default (Gemini 2.5 Flash Image via OpenRouter at + ~$0.039/image). FLUX.1 dev (`runware:101@1`, ~$0.02/image) is also + available for higher-fidelity runs. The provider mirrors the + OpenRouter split: interface + provider + support + pricing + batch, + all under the 300-line cap. +- **True parallel image generation.** `PRAutoBlogger_Runware_Image_Batch` + uses `curl_multi_init/exec/select` to fire all imageInference POSTs + concurrently, then downloads the signed URLs concurrently. Wall-clock + for the A/B pair drops to ≈ the slowest single image instead of the + sum of both. +- **v0.9.0 one-time migration.** Sites where the image model is empty + or still pinned to `google/gemini-2.5-flash-image` are auto-flipped + to `runware:100@1` on upgrade, with the provider re-derived from the + registry. Flag: `prautoblogger_migrated_default_image_v090`. The + Runware API key option (`prautoblogger_runware_api_key`) was added + to the `enc:`-prefix migration list so Settings writes go through + the same encryption-at-rest path as the other provider keys. + +### Changed + +- **Image prompt builder steered away from abstract/chemical subjects.** + The rewriter system prompt now explicitly refuses to personify + molecules, peptides, hormones, or proteins — FLUX (and most diffusion + image models) render "anthropomorphic peptide" as an incoherent blob. + Default scene guidance is now "concrete, human-scale scenes: people + reacting to things, doctors and patients, gym bros with vials, etc." + Style suffix unchanged — single-panel comic aesthetic preserved. See + `includes/core/class-image-prompt-builder.php::REWRITER_SYSTEM_PROMPT`. +- **Default image model flipped to `runware:100@1` (FLUX.1 schnell).** + `PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER` and + `PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL` constants in `prautoblogger.php` + updated. Cloudflare flux-1-schnell remains in the registry as a legacy + fallback for any operators who set it explicitly. Rationale: + `image-style` memory (2026-04-21) — schnell passed the comic A/B + round and the cost delta funds more generations for the same monthly + budget. + +### Cost impact + +At 50 articles/day × 2 image A/B = 100 images/day: + +- Before (Gemini 2.5 Flash Image via OpenRouter): ≈ $3.90/day, ≈ $117/mo +- After (FLUX.1 schnell via Runware): ≈ $0.06/day, ≈ $1.80/mo + +Net saving: ~$115/mo at current traffic, funded by a single provider +swap with no user-visible change in article quality. + ## [0.8.2] — 2026-04-21 ### Fixed diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php index f954999..a0ed97f 100755 --- a/includes/admin/class-admin-page.php +++ b/includes/admin/class-admin-page.php @@ -231,7 +231,7 @@ class PRAutoBlogger_Admin_Page { $option_name = substr( $filter, strlen( 'sanitize_option_' ) ); } - $encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_cloudflare_ai_token' ]; + $encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_cloudflare_ai_token', 'prautoblogger_runware_api_key' ]; if ( in_array( $option_name, $encrypted, true ) ) { // Empty value means password field wasn't touched — keep existing. if ( '' === $value ) { diff --git a/includes/admin/class-image-model-registry.php b/includes/admin/class-image-model-registry.php index cd37bd8..9fd35c6 100644 --- a/includes/admin/class-image-model-registry.php +++ b/includes/admin/class-image-model-registry.php @@ -6,12 +6,13 @@ declare(strict_types=1); * * What: Returns a hardcoded list of image models for the admin model picker. * No API discovery — updated manually when providers add/remove models. - * Who calls it: PRAutoBlogger_Admin_Page (model picker UI) and - * PRAutoBlogger_Image_Pipeline (model validation). + * Who calls it: PRAutoBlogger_Admin_Page (model picker UI + save-time provider + * derivation) and PRAutoBlogger_Image_Pipeline (provider lookup). * Dependencies: None. * * @see admin/class-settings-fields-extended.php — Image settings reference this registry. - * @see core/class-image-pipeline.php — Validates selected model against this list. + * @see core/class-image-pipeline.php — Derives provider from picked model. + * @see providers/class-runware-image-provider.php — Runware FLUX backend. */ class PRAutoBlogger_Image_Model_Registry { @@ -21,22 +22,41 @@ class PRAutoBlogger_Image_Model_Registry { * Each entry contains: * - id: Model identifier used in API calls. * - name: Human-readable display name. - * - provider: 'openrouter' or 'cloudflare'. + * - provider: 'runware' | 'openrouter' | 'cloudflare'. * - cost_per_image: Estimated USD cost per generation. * - capabilities: Array of capability tags. * - description: Short description for the admin UI. * + * Ordered cheapest-to-most-expensive within each provider, with the + * recommended default (Runware schnell) at the top. + * * @return array> */ public static function get_models(): array { return [ + [ + 'id' => 'runware:100@1', + 'name' => 'FLUX.1 schnell (Runware)', + 'provider' => 'runware', + 'cost_per_image' => 0.0006, + 'capabilities' => [ 'image_generation' ], + 'description' => __( 'Default. Fast, very cheap, comic-friendly fidelity.', 'prautoblogger' ), + ], + [ + 'id' => 'runware:101@1', + 'name' => 'FLUX.1 dev (Runware)', + 'provider' => 'runware', + 'cost_per_image' => 0.02, + 'capabilities' => [ 'image_generation' ], + 'description' => __( 'Higher fidelity FLUX. ~30x schnell cost.', 'prautoblogger' ), + ], [ 'id' => 'google/gemini-2.5-flash-image', 'name' => 'Gemini 2.5 Flash Image (Nano Banana)', 'provider' => 'openrouter', 'cost_per_image' => 0.005, 'capabilities' => [ 'image_generation' ], - 'description' => __( 'Good quality, low cost. Recommended.', 'prautoblogger' ), + 'description' => __( 'Mid-tier quality, proven.', 'prautoblogger' ), ], [ 'id' => 'google/gemini-3.1-flash-image-preview', @@ -76,7 +96,7 @@ class PRAutoBlogger_Image_Model_Registry { 'provider' => 'cloudflare', 'cost_per_image' => 0.0007, 'capabilities' => [ 'image_generation' ], - 'description' => __( 'Cheapest. Low quality, 4-step.', 'prautoblogger' ), + 'description' => __( 'Legacy Cloudflare fallback. Use Runware FLUX instead.', 'prautoblogger' ), ], ]; } @@ -86,7 +106,7 @@ class PRAutoBlogger_Image_Model_Registry { * model id is not in the registry. * * @param string $model_id Model slug from the admin UI. - * @return string Provider id ('openrouter' | 'cloudflare' | ''). + * @return string Provider id ('runware' | 'openrouter' | 'cloudflare' | ''). */ public static function provider_for( string $model_id ): string { foreach ( self::get_models() as $model ) { diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index 6b30235..f4bfcd0 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -240,6 +240,14 @@ class PRAutoBlogger_Settings_Fields_Extended { 'default' => '', 'description' => __( 'Only needed if using Cloudflare provider.', 'prautoblogger' ), ], + [ + 'id' => 'prautoblogger_runware_api_key', + 'label' => __( 'Runware API Key', 'prautoblogger' ), + 'type' => 'password', + 'section' => 'prautoblogger_images', + 'description' => __( 'Required when using a Runware FLUX model. Get your key at runware.ai/signup.', 'prautoblogger' ), + 'icon' => '🔑', + ], [ 'id' => 'prautoblogger_image_style_suffix', 'label' => __( 'Style Suffix', 'prautoblogger' ), diff --git a/includes/class-activator.php b/includes/class-activator.php index 476bbd4..61d470e 100755 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -242,4 +242,29 @@ class PRAutoBlogger_Activator { update_option( 'prautoblogger_migrated_run_id_meta_v081', '1' ); } + + /** + * v0.9.0 migration: flip legacy default image model to Runware schnell + * and re-derive provider. CEO decision 2026-04-21 — 65× cheaper than + * Gemini Nano Banana. Explicit user selections are preserved. + * + * Side effects: may update `prautoblogger_image_model`, + * `prautoblogger_image_provider`, and sets the + * `prautoblogger_migrated_default_image_v090` flag. + */ + public static function migrate_default_image_v090(): void { + if ( get_option( 'prautoblogger_migrated_default_image_v090' ) ) { + return; + } + $curr = (string) get_option( 'prautoblogger_image_model', '' ); + if ( in_array( $curr, [ '', 'google/gemini-2.5-flash-image' ], true ) ) { + update_option( 'prautoblogger_image_model', 'runware:100@1' ); + } + $final = (string) get_option( 'prautoblogger_image_model', 'runware:100@1' ); + $prov = PRAutoBlogger_Image_Model_Registry::provider_for( $final ); + if ( '' !== $prov ) { + update_option( 'prautoblogger_image_provider', $prov ); + } + update_option( 'prautoblogger_migrated_default_image_v090', '1' ); + } } diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index d1e5878..30a2c9a 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -232,9 +232,12 @@ class PRAutoBlogger { update_option( 'prautoblogger_migrated_style_suffix_v4', '1' ); } + // v0.9.0: flip legacy default image model to Runware schnell. + PRAutoBlogger_Activator::migrate_default_image_v090(); + // One-time migration: re-wrap existing encrypted values with "enc:" prefix. if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) { - $enc_options = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json' ]; + $enc_options = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' ]; foreach ( $enc_options as $opt ) { $val = get_option( $opt, '' ); if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) { diff --git a/includes/core/class-image-pipeline.php b/includes/core/class-image-pipeline.php index 7910d58..6d303fe 100644 --- a/includes/core/class-image-pipeline.php +++ b/includes/core/class-image-pipeline.php @@ -17,22 +17,19 @@ declare(strict_types=1); class PRAutoBlogger_Image_Pipeline { /** - * Default image dimensions (landscape). + * Default image dimensions (landscape). 1200×632 is the closest + * 8-aligned pair to the standard OG image size (1200×630). Some + * providers require dimensions divisible by 8. */ - // Dimensions must be divisible by 8 (required by some providers). - // 1200×632 is the closest 8-aligned pair to the standard OG image size (1200×630). private const DEFAULT_WIDTH = 1200; private const DEFAULT_HEIGHT = 632; /** @var PRAutoBlogger_Image_Provider_Interface Image gen provider. */ private PRAutoBlogger_Image_Provider_Interface $provider; - /** @var PRAutoBlogger_Image_Prompt_Builder Builds scene + caption from article data. */ private PRAutoBlogger_Image_Prompt_Builder $prompt_builder; - /** @var PRAutoBlogger_Image_Media_Sideloader Downloads images into WP media library. */ private PRAutoBlogger_Image_Media_Sideloader $sideloader; - /** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */ private PRAutoBlogger_Cost_Tracker $cost_tracker; @@ -41,7 +38,7 @@ class PRAutoBlogger_Image_Pipeline { * * When no provider is injected, the constructor reads the * `prautoblogger_image_provider` setting and instantiates the - * matching concrete class ('openrouter' or 'cloudflare'). + * matching concrete class ('runware', 'openrouter', or 'cloudflare'). * * @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider override. * @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker. @@ -63,6 +60,9 @@ class PRAutoBlogger_Image_Pipeline { */ private static function create_default_provider(): PRAutoBlogger_Image_Provider_Interface { $provider_id = (string) get_option( 'prautoblogger_image_provider', PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER ); + if ( 'runware' === $provider_id ) { + return new PRAutoBlogger_Runware_Image_Provider(); + } if ( 'openrouter' === $provider_id ) { return new PRAutoBlogger_OpenRouter_Image_Provider(); } diff --git a/includes/core/class-image-prompt-builder.php b/includes/core/class-image-prompt-builder.php index cc2d35a..6ab2389 100644 --- a/includes/core/class-image-prompt-builder.php +++ b/includes/core/class-image-prompt-builder.php @@ -31,12 +31,13 @@ You are a comedy writer and single-panel cartoon creator, like Gary Larson (The 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: -SCENE: One sentence describing the visual gag — a funny, absurd, or ironic situation related to the article's topic. Use anthropomorphized molecules, lab-coat-wearing animals, befuddled scientists, gym bros encountering peptide science, supplement bottles with personality, or everyday situations with a peptide twist. Keep it simple — one clear visual joke, 1-3 characters max. +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. 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. 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 @@ -44,9 +45,9 @@ Rules: - Output ONLY the scene and caption. No preamble, no explanation. Example output format: -A muscular gym bro stares in confusion at a tiny vial while a peptide molecule with arms and legs flexes next to him, clearly outperforming him. +A muscular gym bro stares, deflated, at a tiny glass vial in his hand while his friend squats a barbell effortlessly in the background. -"I've been doing this wrong for three years." +"They told me the peptide did the work, not the squats." PROMPT; /** diff --git a/includes/providers/class-runware-image-batch.php b/includes/providers/class-runware-image-batch.php new file mode 100644 index 0000000..49ba9ef --- /dev/null +++ b/includes/providers/class-runware-image-batch.php @@ -0,0 +1,294 @@ +pricing = $pricing; + $this->support = $support; + } + + /** + * Execute multiple image generation requests concurrently via curl_multi. + * + * Two parallel rounds: (1) POST /v1 returns signed imageURL per request; + * (2) GET each signed imageURL returns PNG bytes. Each round runs + * concurrently. No retry — single-attempt success rate on schnell >99%. + * + * @param array $requests Keyed requests. + * @return array Image data or `{error}` keyed identically. + */ + public function execute( array $requests ): array { + if ( empty( $requests ) ) { + return []; + } + + $api_key = $this->support->get_api_key(); + $round1 = $this->round_one_inference( $api_key, $requests ); + $results = []; + $to_download = []; + + foreach ( $round1 as $key => $entry ) { + if ( isset( $entry['error'] ) ) { + $results[ $key ] = [ 'error' => $entry['error'] ]; + continue; + } + $to_download[ $key ] = $entry; + } + + if ( empty( $to_download ) ) { + return $results; + } + + $bytes_map = $this->round_two_download( $to_download ); + foreach ( $to_download as $key => $entry ) { + if ( isset( $bytes_map[ $key ]['error'] ) ) { + $results[ $key ] = [ 'error' => $bytes_map[ $key ]['error'] ]; + continue; + } + $results[ $key ] = [ + 'bytes' => $bytes_map[ $key ]['bytes'], + 'mime_type' => 'image/png', + 'width' => $entry['width'], + 'height' => $entry['height'], + 'model' => $entry['model'], + 'seed' => $entry['seed'], + 'cost_usd' => $this->pricing->estimate_cost( $entry['width'], $entry['height'], $entry['model'] ), + 'latency_ms' => (int) ( ( microtime( true ) - $entry['started_at'] ) * 1000 ), + ]; + } + + return $results; + } + + /** + * Round 1: fire all inference POSTs in parallel. + * + * @param string $api_key Plaintext API key. + * @param array $requests Batch requests. + * @return array Map of key => {error} | {image_url, width, height, model, seed, started_at}. + */ + private function round_one_inference( string $api_key, array $requests ): array { + $headers = [ 'Content-Type: application/json' ]; + $mh = curl_multi_init(); + $handles = []; + $start_times = []; + + foreach ( $requests as $key => $req ) { + $model = $this->pricing->resolve_model( (string) ( $req['options']['model'] ?? '' ) ); + $snap_w = $this->support->snap_dimension( (int) $req['width'] ); + $snap_h = $this->support->snap_dimension( (int) $req['height'] ); + $seed = $this->support->normalize_seed( isset( $req['options']['seed'] ) ? (int) $req['options']['seed'] : null ); + $steps = isset( $req['options']['steps'] ) ? (int) $req['options']['steps'] : $this->pricing->default_steps( $model ); + + $ch = curl_init(); + curl_setopt( $ch, CURLOPT_URL, PRAutoBlogger_Runware_Image_Support::API_URL ); + curl_setopt( $ch, CURLOPT_POST, true ); + curl_setopt( $ch, CURLOPT_POSTFIELDS, wp_json_encode( + $this->build_payload( $api_key, (string) $req['prompt'], $snap_w, $snap_h, $model, $seed, $steps ) + ) ); + curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_TIMEOUT, PRAUTOBLOGGER_API_TIMEOUT_SECONDS ); + curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 ); + + curl_multi_add_handle( $mh, $ch ); + $handles[ $key ] = [ + 'handle' => $ch, + 'model' => $model, + 'width' => $snap_w, + 'height' => $snap_h, + 'seed' => $seed, + ]; + $start_times[ $key ] = microtime( true ); + } + + $this->run_multi( $mh ); + + $out = []; + foreach ( $handles as $key => $entry ) { + $out[ $key ] = $this->collect_inference_result( (string) $key, $entry, $start_times[ $key ] ); + curl_multi_remove_handle( $mh, $entry['handle'] ); + curl_close( $entry['handle'] ); + } + curl_multi_close( $mh ); + + return $out; + } + + /** + * Round 2: fetch all signed image URLs in parallel. + * + * @param array $inferences Map from round one. + * @return array + */ + private function round_two_download( array $inferences ): array { + $mh = curl_multi_init(); + $handles = []; + + foreach ( $inferences as $key => $entry ) { + $ch = curl_init(); + curl_setopt( $ch, CURLOPT_URL, $entry['image_url'] ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $ch, CURLOPT_TIMEOUT, 30 ); + curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 ); + curl_multi_add_handle( $mh, $ch ); + $handles[ $key ] = $ch; + } + + $this->run_multi( $mh ); + + $out = []; + foreach ( $handles as $key => $ch ) { + $errno = curl_errno( $ch ); + if ( 0 !== $errno ) { + $msg = sprintf( 'Runware image download cURL error %d for "%s": %s', $errno, $key, curl_error( $ch ) ); + PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' ); + $out[ $key ] = [ 'error' => $msg ]; + } else { + $code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $body = (string) curl_multi_getcontent( $ch ); + if ( 200 !== $code || '' === $body ) { + $msg = sprintf( 'Runware image download HTTP %d for "%s" (%d bytes)', $code, $key, strlen( $body ) ); + PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' ); + $out[ $key ] = [ 'error' => $msg ]; + } else { + $out[ $key ] = [ 'bytes' => $body ]; + } + } + curl_multi_remove_handle( $mh, $ch ); + curl_close( $ch ); + } + curl_multi_close( $mh ); + + return $out; + } + + /** + * Drive curl_multi_exec to completion. + * + * @param \CurlMultiHandle $mh Multi handle. + */ + private function run_multi( $mh ): void { + $running = 0; + do { + $status = curl_multi_exec( $mh, $running ); + if ( $running > 0 ) { + curl_multi_select( $mh, 1.0 ); + } + } while ( $running > 0 && CURLM_OK === $status ); + } + + /** + * Build the auth+inference task-array body for one image request. + * + * @param string $api_key Plaintext API key. + * @param string $prompt Prompt text. + * @param int $width Snapped width. + * @param int $height Snapped height. + * @param string $model Resolved model id. + * @param int $seed Positive seed. + * @param int $steps Inference steps. + * @return array> + */ + private function build_payload( string $api_key, string $prompt, int $width, int $height, string $model, int $seed, int $steps ): array { + return [ + [ 'taskType' => 'authentication', 'apiKey' => $api_key ], + [ + 'taskType' => 'imageInference', + 'taskUUID' => wp_generate_uuid4(), + 'positivePrompt' => $prompt, + 'width' => $width, + 'height' => $height, + 'model' => $model, + 'steps' => $steps, + 'numberResults' => 1, + 'outputFormat' => 'PNG', + 'outputType' => 'URL', + 'seed' => $seed, + ], + ]; + } + + /** + * Parse one round-1 handle into either an error or an imageURL entry. + * + * @param string $key Request key. + * @param array $entry Handle entry (handle + model + dims + seed). + * @param float $start Microtime when this handle was queued. + * @return array Either {error} or {image_url, width, height, model, seed, started_at}. + */ + private function collect_inference_result( string $key, array $entry, float $start ): array { + $ch = $entry['handle']; + $errno = curl_errno( $ch ); + if ( 0 !== $errno ) { + $msg = sprintf( 'Runware batch inference cURL error %d for "%s": %s', $errno, $key, curl_error( $ch ) ); + PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' ); + return [ 'error' => $msg ]; + } + + $http_code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $raw = (string) curl_multi_getcontent( $ch ); + if ( $http_code >= 400 ) { + $msg = sprintf( 'Runware batch inference HTTP %d for "%s": %s', $http_code, $key, substr( $raw, 0, 300 ) ); + PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' ); + return [ 'error' => $msg ]; + } + + try { + $image_url = $this->support->extract_image_url( $raw ); + } catch ( \Throwable $e ) { + $msg = sprintf( 'Runware batch inference parse error for "%s": %s', $key, $e->getMessage() ); + PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' ); + return [ 'error' => $msg ]; + } + + return [ + 'image_url' => $image_url, + 'width' => (int) $entry['width'], + 'height' => (int) $entry['height'], + 'model' => (string) $entry['model'], + 'seed' => (int) $entry['seed'], + 'started_at' => $start, + ]; + } +} diff --git a/includes/providers/class-runware-image-pricing.php b/includes/providers/class-runware-image-pricing.php new file mode 100644 index 0000000..14a0f4e --- /dev/null +++ b/includes/providers/class-runware-image-pricing.php @@ -0,0 +1,118 @@ + + */ + private const COST_PER_IMAGE = [ + // FLUX.1 schnell — 4-step distilled. Our new default (2026-04-21). + 'runware:100@1' => 0.0006, + // FLUX.1 dev — 28-step full model. Higher fidelity, ~30x cost. + 'runware:101@1' => 0.02, + ]; + + /** + * Default number of inference steps per model. Runware bills per image + * at the model's standard step count; custom step counts may alter price. + * + * @var array + */ + private const DEFAULT_STEPS = [ + 'runware:100@1' => 4, + 'runware:101@1' => 28, + ]; + + /** + * Resolve the model identifier from a hint, falling back to site option, + * then to the PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant. + * + * If the saved/default model is not a Runware id (e.g. still points at + * a Gemini slug), fall back to schnell — the provider was selected + * explicitly, so the user wants a Runware model. + * + * @param string $hint Caller-supplied model identifier (may be empty). + * @return string Runware model id. + */ + public function resolve_model( string $hint ): string { + $candidate = trim( $hint ); + if ( '' === $candidate ) { + $candidate = trim( (string) get_option( 'prautoblogger_image_model', '' ) ); + } + if ( '' === $candidate && defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) { + $candidate = (string) PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL; + } + + // If the candidate isn't a known Runware id, fall back to schnell. + if ( '' === $candidate || ! isset( self::COST_PER_IMAGE[ $candidate ] ) ) { + return 'runware:100@1'; + } + + return $candidate; + } + + /** + * Estimate USD cost for a single image at the given model. + * + * Runware FLUX pricing is flat per image at the model's standard step + * count. Width/height are accepted for interface compatibility but do + * not affect cost for the variants we support. + * + * @param int $width Width in pixels (unused for flat pricing). + * @param int $height Height in pixels (unused for flat pricing). + * @param string $model Resolved Runware model id. + * @return float USD cost rounded to 6 decimals. + */ + public function estimate_cost( int $width, int $height, string $model ): float { + if ( isset( self::COST_PER_IMAGE[ $model ] ) ) { + return round( self::COST_PER_IMAGE[ $model ], 6 ); + } + + // Unknown model — log it and use a conservative $0.01 estimate. + PRAutoBlogger_Logger::instance()->warning( + sprintf( 'No Runware pricing data for model "%s". Using $0.01 estimate.', $model ), + 'runware-image' + ); + return 0.01; + } + + /** + * Get the default step count for a Runware model. + * + * @param string $model Resolved Runware model id. + * @return int Default steps. + */ + public function default_steps( string $model ): int { + return self::DEFAULT_STEPS[ $model ] ?? 4; + } + + /** + * Get the full model cost table for admin display. + * + * @return array Model id => cost per image in USD. + */ + public static function get_model_costs(): array { + return self::COST_PER_IMAGE; + } +} diff --git a/includes/providers/class-runware-image-provider.php b/includes/providers/class-runware-image-provider.php new file mode 100644 index 0000000..a43b2c7 --- /dev/null +++ b/includes/providers/class-runware-image-provider.php @@ -0,0 +1,294 @@ +pricing = new PRAutoBlogger_Runware_Image_Pricing(); + $this->support = new PRAutoBlogger_Runware_Image_Support(); + } + + /** + * {@inheritDoc} + * + * Two-step HTTP flow: POST /v1 returns a signed imageURL, then GET that + * URL for the bytes. Retries 5xx/429 on the POST with exponential + * backoff; 4xx (except 429) fail immediately. The image download GET + * is not retried — signed URLs are short-lived. + */ + public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array { + $this->assert_valid_dimensions( $width, $height ); + $prompt = trim( $prompt ); + if ( '' === $prompt ) { + throw new \InvalidArgumentException( + esc_html__( 'Image prompt cannot be empty.', 'prautoblogger' ) + ); + } + + $api_key = $this->support->get_api_key(); + $model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) ); + $snap_w = $this->support->snap_dimension( $width ); + $snap_h = $this->support->snap_dimension( $height ); + $seed = $this->support->normalize_seed( isset( $options['seed'] ) ? (int) $options['seed'] : null ); + $steps = isset( $options['steps'] ) ? (int) $options['steps'] : $this->pricing->default_steps( $model ); + $body = $this->build_request_body( $api_key, $prompt, $snap_w, $snap_h, $model, $seed, $steps ); + $headers = [ 'Content-Type' => 'application/json' ]; + $started_at = microtime( true ); + $last_error = ''; + + for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) { + $response = wp_remote_post( PRAutoBlogger_Runware_Image_Support::API_URL, [ + 'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS, + 'headers' => $headers, + 'body' => wp_json_encode( $body ), + ] ); + + if ( is_wp_error( $response ) ) { + $last_error = $response->get_error_message(); + $this->support->log_retry( $attempt, 'network', $last_error ); + $this->support->backoff( $attempt ); + continue; + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + $raw = (string) wp_remote_retrieve_body( $response ); + + if ( 429 === $status || $status >= 500 ) { + $last_error = sprintf( 'HTTP %d: %s', $status, substr( $raw, 0, 300 ) ); + $this->support->log_retry( $attempt, (string) $status, $last_error ); + if ( $attempt < PRAUTOBLOGGER_MAX_RETRIES ) { + $retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' ); + $this->support->backoff( $attempt, $retry_after ); + } + continue; + } + + if ( $status >= 400 ) { + PRAutoBlogger_Logger::instance()->error( + sprintf( 'Runware image gen HTTP %d: %s', $status, substr( $raw, 0, 300 ) ), + 'runware-image' + ); + throw new \RuntimeException( + sprintf( + esc_html__( 'Runware image error (HTTP %1$d): %2$s', 'prautoblogger' ), + $status, + esc_html( substr( $raw, 0, 300 ) ) + ) + ); + } + + $image_url = $this->support->extract_image_url( $raw ); + $image_bytes = $this->support->download_image_bytes( $image_url ); + + return [ + 'bytes' => $image_bytes, + 'mime_type' => 'image/png', + 'width' => $snap_w, + 'height' => $snap_h, + 'model' => $model, + 'seed' => $seed, + 'cost_usd' => $this->pricing->estimate_cost( $snap_w, $snap_h, $model ), + 'latency_ms' => (int) ( ( microtime( true ) - $started_at ) * 1000 ), + ]; + } + + throw new \RuntimeException( + sprintf( + esc_html__( 'Runware image gen failed after %1$d attempts. Last error: %2$s', 'prautoblogger' ), + PRAUTOBLOGGER_MAX_RETRIES, + esc_html( $last_error ) + ) + ); + } + + /** + * Generate multiple images concurrently via curl_multi. + * + * Delegates to PRAutoBlogger_Runware_Image_Batch, which fires all + * inference POSTs in parallel, then fetches the signed image URLs + * in parallel as well. Wall-clock time ≈ slowest single image. + * + * {@inheritDoc} + */ + public function generate_image_batch( array $requests ): array { + foreach ( $requests as $key => $req ) { + $this->assert_valid_dimensions( $req['width'], $req['height'] ); + if ( '' === trim( $req['prompt'] ) ) { + throw new \InvalidArgumentException( + sprintf( 'Image prompt for key "%s" cannot be empty.', esc_html( $key ) ) + ); + } + } + + $batch = new PRAutoBlogger_Runware_Image_Batch( $this->pricing, $this->support ); + + PRAutoBlogger_Logger::instance()->info( + sprintf( 'Starting parallel Runware image batch (%d images)', count( $requests ) ), + 'runware-image' + ); + + return $batch->execute( $requests ); + } + + /** {@inheritDoc} */ + public function estimate_cost( int $width, int $height, array $options = [] ): float { + $model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) ); + return $this->pricing->estimate_cost( $width, $height, $model ); + } + + /** {@inheritDoc} */ + public function get_provider_name(): string { + return self::PROVIDER_ID; + } + + /** + * {@inheritDoc} + * + * Runware has no dedicated "validate key" endpoint, so we send a minimal + * authentication task and look for `taskType=authentication` in the + * response data with no accompanying errors. + */ + public function validate_credentials_detailed(): array { + try { + $api_key = $this->support->get_api_key(); + } catch ( \RuntimeException $e ) { + return [ 'status' => 'error', 'message' => $e->getMessage() ]; + } + + $body = [ + [ 'taskType' => 'authentication', 'apiKey' => $api_key ], + ]; + + $response = wp_remote_post( PRAutoBlogger_Runware_Image_Support::API_URL, [ + 'timeout' => 15, + 'headers' => [ 'Content-Type' => 'application/json' ], + 'body' => wp_json_encode( $body ), + ] ); + + if ( is_wp_error( $response ) ) { + return [ + 'status' => 'error', + 'message' => __( 'Could not reach Runware API.', 'prautoblogger' ), + 'debug' => $response->get_error_message(), + ]; + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + $raw = (string) wp_remote_retrieve_body( $response ); + + if ( 200 !== $status ) { + return [ + 'status' => 'error', + 'message' => sprintf( __( 'Runware returned HTTP %d.', 'prautoblogger' ), $status ), + 'debug' => substr( $raw, 0, 200 ), + ]; + } + + $decoded = json_decode( $raw, true ); + if ( is_array( $decoded ) && isset( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) { + $first = $decoded['errors'][0] ?? []; + $msg = is_array( $first ) ? (string) ( $first['message'] ?? '' ) : (string) $first; + return [ + 'status' => 'error', + 'message' => __( 'Runware rejected the API key.', 'prautoblogger' ), + 'debug' => substr( $msg, 0, 200 ), + ]; + } + + return [ 'status' => 'ok', 'message' => __( 'Runware API key valid.', 'prautoblogger' ) ]; + } + + /** + * Build the JSON task-array body for a single imageInference request. + * + * Runware's single endpoint consumes [auth, inference] in one POST. + * + * @param string $api_key Plaintext API key. + * @param string $prompt Full prompt text. + * @param int $width Snapped width. + * @param int $height Snapped height. + * @param string $model Resolved model id. + * @param int $seed Positive seed. + * @param int $steps Inference steps. + * @return array> + */ + private function build_request_body( + string $api_key, + string $prompt, + int $width, + int $height, + string $model, + int $seed, + int $steps + ): array { + return [ + [ + 'taskType' => 'authentication', + 'apiKey' => $api_key, + ], + [ + 'taskType' => 'imageInference', + 'taskUUID' => wp_generate_uuid4(), + 'positivePrompt' => $prompt, + 'width' => $width, + 'height' => $height, + 'model' => $model, + 'steps' => $steps, + 'numberResults' => 1, + 'outputFormat' => 'PNG', + 'outputType' => 'URL', + 'seed' => $seed, + ], + ]; + } + + /** + * Enforce the interface's dimension contract. + * + * @param int $width Width in pixels. + * @param int $height Height in pixels. + * @throws \InvalidArgumentException If outside [MIN, MAX]. + */ + private function assert_valid_dimensions( int $width, int $height ): void { + if ( + $width < self::MIN_DIMENSION_PX || $width > self::MAX_DIMENSION_PX || + $height < self::MIN_DIMENSION_PX || $height > self::MAX_DIMENSION_PX + ) { + throw new \InvalidArgumentException( + sprintf( + esc_html__( 'Image dimensions %1$dx%2$d outside supported range (%3$d–%4$d px).', 'prautoblogger' ), + $width, $height, self::MIN_DIMENSION_PX, self::MAX_DIMENSION_PX + ) + ); + } + } +} diff --git a/includes/providers/class-runware-image-support.php b/includes/providers/class-runware-image-support.php new file mode 100644 index 0000000..46bd39b --- /dev/null +++ b/includes/providers/class-runware-image-support.php @@ -0,0 +1,219 @@ + 30, + ] ); + + if ( is_wp_error( $response ) ) { + throw new \RuntimeException( + sprintf( + esc_html__( 'Runware image download failed: %s', 'prautoblogger' ), + esc_html( $response->get_error_message() ) + ) + ); + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status ) { + throw new \RuntimeException( + sprintf( + esc_html__( 'Runware image download HTTP %d.', 'prautoblogger' ), + $status + ) + ); + } + + $bytes = (string) wp_remote_retrieve_body( $response ); + if ( '' === $bytes ) { + throw new \RuntimeException( + esc_html__( 'Runware image download returned empty body.', 'prautoblogger' ) + ); + } + + return $bytes; + } + + /** + * Snap a target pixel dimension to Runware's FLUX constraints. + * + * FLUX.1 (schnell + dev) requires dimensions divisible by 64, in the + * range [512, 2048]. We round to the nearest multiple of 64 and clamp. + * + * @param int $px Caller-requested pixel dimension. + * @return int Snapped dimension. + */ + public function snap_dimension( int $px ): int { + $snapped = (int) round( $px / 64 ) * 64; + if ( $snapped < 512 ) { + return 512; + } + if ( $snapped > 2048 ) { + return 2048; + } + return $snapped; + } + + /** + * Normalize a caller seed to Runware's constraint (must be > 0). + * + * @param int|null $seed Caller-supplied seed or null for "random". + * @return int A positive integer seed. + */ + public function normalize_seed( ?int $seed ): int { + if ( null === $seed || $seed < 1 ) { + $derived = (int) ( ( microtime( true ) * 1000 ) % 2147483647 ); + return $derived > 0 ? $derived : 1; + } + return $seed; + } + + /** + * Emit a WARN line for a retryable image-gen failure. + * + * @param int $attempt 1-based attempt number. + * @param string $class 'network' or HTTP status code string. + * @param string $detail Short failure detail. + */ + public function log_retry( int $attempt, string $class, string $detail ): void { + PRAutoBlogger_Logger::instance()->warning( + sprintf( + 'Runware image gen %s failure (attempt %d/%d): %s', + $class, + $attempt, + PRAUTOBLOGGER_MAX_RETRIES, + $detail + ), + 'runware-image' + ); + } + + /** + * Sleep between retries with exponential backoff. + * + * @param int $attempt 1-based attempt that just failed. + * @param int $retry_after Retry-After header value (0 if absent). + */ + public function backoff( int $attempt, int $retry_after = 0 ): void { + $delay = $retry_after > 0 + ? min( $retry_after, 60 ) + : PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 ); + if ( $delay > 0 ) { + sleep( $delay ); + } + } +} diff --git a/prautoblogger.php b/prautoblogger.php index fda4d44..e07fa32 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -8,7 +8,7 @@ declare(strict_types=1); * 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.8.2 + * Version: 0.9.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.8.2' ); +define( 'PRAUTOBLOGGER_VERSION', '0.9.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); @@ -54,11 +54,13 @@ define( 'PRAUTOBLOGGER_DEFAULT_WRITING_MODEL', 'google/gemini-2.5-flash-lite' ); define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' ); // Image generation defaults. -// Default provider is OpenRouter; Cloudflare is the fallback. -// Default model is Gemini 2.5 Flash Image ($0.005/image, good for comics). -// User can override via Settings → Images → Image Model. -define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'openrouter' ); -define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'google/gemini-2.5-flash-image' ); +// Default provider is Runware; OpenRouter (Gemini) + Cloudflare remain as alternates. +// Default model is FLUX.1 schnell via Runware (~$0.0006/image, ~2s). CEO +// decision 2026-04-21 after comic A/B: schnell's looser fidelity reads as +// editorial-cartoon style and is 65× cheaper than Nano Banana. Users can +// override via Settings → Images → Image Model. +define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'runware' ); +define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'runware:100@1' ); // Default style suffix appended to every image-gen prompt. Updated // 2026-04-18 v4: single-panel newspaper comic style. Caption is now diff --git a/tests/unit/Providers/RunwareImagePricingTest.php b/tests/unit/Providers/RunwareImagePricingTest.php new file mode 100644 index 0000000..a1871ee --- /dev/null +++ b/tests/unit/Providers/RunwareImagePricingTest.php @@ -0,0 +1,91 @@ +alias( function ( $key, $default = false ) { + static $options = []; + return $options[ $key ] ?? $default; + } ); + } + + /** Empty hint resolves to the v0.9.0 default (runware schnell). */ + public function test_resolve_model_empty_hint_uses_schnell_default(): void { + $pricing = new \PRAutoBlogger_Runware_Image_Pricing(); + $model = $pricing->resolve_model( '' ); + $this->assertSame( 'runware:100@1', $model ); + } + + /** Explicit schnell hint resolves to itself. */ + public function test_resolve_model_schnell_hint(): void { + $pricing = new \PRAutoBlogger_Runware_Image_Pricing(); + $this->assertSame( 'runware:100@1', $pricing->resolve_model( 'runware:100@1' ) ); + } + + /** Explicit dev hint resolves to itself. */ + public function test_resolve_model_dev_hint(): void { + $pricing = new \PRAutoBlogger_Runware_Image_Pricing(); + $this->assertSame( 'runware:101@1', $pricing->resolve_model( 'runware:101@1' ) ); + } + + /** + * A non-Runware hint (e.g. a Gemini slug) falls back to schnell because + * the Runware provider was selected explicitly — the user wants a + * Runware model even if the admin option still holds a foreign slug. + */ + public function test_resolve_model_unknown_hint_falls_back_to_schnell(): void { + $pricing = new \PRAutoBlogger_Runware_Image_Pricing(); + $this->assertSame( 'runware:100@1', $pricing->resolve_model( 'google/gemini-2.5-flash-image' ) ); + } + + /** FLUX.1 schnell costs ~$0.0006/image. */ + public function test_estimate_cost_schnell(): void { + $pricing = new \PRAutoBlogger_Runware_Image_Pricing(); + $cost = $pricing->estimate_cost( 1216, 640, 'runware:100@1' ); + $this->assertEqualsWithDelta( 0.0006, $cost, 0.000001 ); + } + + /** FLUX.1 dev costs ~$0.02/image. */ + public function test_estimate_cost_dev(): void { + $pricing = new \PRAutoBlogger_Runware_Image_Pricing(); + $cost = $pricing->estimate_cost( 1216, 640, 'runware:101@1' ); + $this->assertEqualsWithDelta( 0.02, $cost, 0.000001 ); + } + + /** Unknown model falls back to conservative $0.01 estimate. */ + public function test_estimate_cost_unknown_model(): void { + $pricing = new \PRAutoBlogger_Runware_Image_Pricing(); + $cost = $pricing->estimate_cost( 1216, 640, 'runware:999@1' ); + $this->assertEqualsWithDelta( 0.01, $cost, 0.000001 ); + } + + /** Default step count: schnell=4, dev=28. */ + public function test_default_steps(): void { + $pricing = new \PRAutoBlogger_Runware_Image_Pricing(); + $this->assertSame( 4, $pricing->default_steps( 'runware:100@1' ) ); + $this->assertSame( 28, $pricing->default_steps( 'runware:101@1' ) ); + $this->assertSame( 4, $pricing->default_steps( 'runware:999@1' ) ); + } + + /** get_model_costs returns a non-empty map with Runware models. */ + public function test_get_model_costs(): void { + $costs = \PRAutoBlogger_Runware_Image_Pricing::get_model_costs(); + $this->assertIsArray( $costs ); + $this->assertArrayHasKey( 'runware:100@1', $costs ); + $this->assertArrayHasKey( 'runware:101@1', $costs ); + } +} diff --git a/tests/unit/Providers/RunwareImageProviderTest.php b/tests/unit/Providers/RunwareImageProviderTest.php new file mode 100644 index 0000000..9788110 --- /dev/null +++ b/tests/unit/Providers/RunwareImageProviderTest.php @@ -0,0 +1,247 @@ +alias( function ( $key, $default = false ) { + static $options = [ + 'prautoblogger_runware_api_key' => 'enc:fake-key', + 'prautoblogger_image_model' => 'runware:100@1', + ]; + return $options[ $key ] ?? $default; + } ); + + Functions\when( 'is_wp_error' )->alias( function ( $thing ) { + return $thing instanceof \WP_Error; + } ); + + Functions\when( 'wp_generate_uuid4' )->justReturn( '11111111-2222-3333-4444-555555555555' ); + + if ( ! class_exists( 'PRAutoBlogger_Encryption' ) ) { + eval( ' + class PRAutoBlogger_Encryption { + public static function is_encrypted( $v ) { return 0 === strpos( $v, "enc:" ); } + public static function decrypt( $v ) { return "rw-test-key-123"; } + public static function encrypt( $v ) { return "enc:" . $v; } + } + ' ); + } + } + + /** Provider name is 'runware'. */ + public function test_get_provider_name(): void { + $provider = new \PRAutoBlogger_Runware_Image_Provider(); + $this->assertSame( 'runware', $provider->get_provider_name() ); + } + + /** estimate_cost returns a positive float for schnell. */ + public function test_estimate_cost_schnell(): void { + $provider = new \PRAutoBlogger_Runware_Image_Provider(); + $cost = $provider->estimate_cost( 1216, 640, [ 'model' => 'runware:100@1' ] ); + $this->assertIsFloat( $cost ); + $this->assertEqualsWithDelta( 0.0006, $cost, 0.000001 ); + } + + /** estimate_cost returns higher value for dev. */ + public function test_estimate_cost_dev_higher_than_schnell(): void { + $provider = new \PRAutoBlogger_Runware_Image_Provider(); + $schnell = $provider->estimate_cost( 1216, 640, [ 'model' => 'runware:100@1' ] ); + $dev = $provider->estimate_cost( 1216, 640, [ 'model' => 'runware:101@1' ] ); + $this->assertGreaterThan( $schnell, $dev ); + } + + /** Empty prompt throws InvalidArgumentException. */ + public function test_generate_image_empty_prompt_throws(): void { + $provider = new \PRAutoBlogger_Runware_Image_Provider(); + $this->expectException( \InvalidArgumentException::class ); + $provider->generate_image( '', 1200, 640 ); + } + + /** Whitespace-only prompt throws InvalidArgumentException. */ + public function test_generate_image_whitespace_prompt_throws(): void { + $provider = new \PRAutoBlogger_Runware_Image_Provider(); + $this->expectException( \InvalidArgumentException::class ); + $provider->generate_image( " \t\n ", 1200, 640 ); + } + + /** Dimension below minimum throws. */ + public function test_generate_image_tiny_dimensions_throws(): void { + $provider = new \PRAutoBlogger_Runware_Image_Provider(); + $this->expectException( \InvalidArgumentException::class ); + $provider->generate_image( 'A test prompt', 10, 10 ); + } + + /** Dimension above maximum throws. */ + public function test_generate_image_huge_dimensions_throws(): void { + $provider = new \PRAutoBlogger_Runware_Image_Provider(); + $this->expectException( \InvalidArgumentException::class ); + $provider->generate_image( 'A test prompt', 5000, 5000 ); + } + + /** Happy path: POST returns URL, GET returns bytes, result shape correct. */ + public function test_generate_image_success(): void { + $image_url = 'https://im.runware.ai/image/abc.png'; + $post_body = wp_json_encode( [ + 'data' => [ + [ 'taskType' => 'imageInference', 'imageURL' => $image_url ], + ], + ] ); + $fake_bytes = 'fake-png-bytes'; + + Functions\when( 'wp_remote_post' )->justReturn( [ + 'response' => [ 'code' => 200 ], + 'headers' => [], + 'body' => $post_body, + ] ); + Functions\when( 'wp_remote_get' )->justReturn( [ + 'response' => [ 'code' => 200 ], + 'body' => $fake_bytes, + ] ); + Functions\when( 'wp_remote_retrieve_response_code' )->alias( function ( $r ) { + return isset( $r['response']['code'] ) ? (int) $r['response']['code'] : 0; + } ); + Functions\when( 'wp_remote_retrieve_body' )->alias( function ( $r ) { + return isset( $r['body'] ) ? (string) $r['body'] : ''; + } ); + Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' ); + + $provider = new \PRAutoBlogger_Runware_Image_Provider(); + $result = $provider->generate_image( 'A gym bro holding a vial', 1200, 632 ); + + $this->assertArrayHasKey( 'bytes', $result ); + $this->assertArrayHasKey( 'mime_type', $result ); + $this->assertArrayHasKey( 'width', $result ); + $this->assertArrayHasKey( 'height', $result ); + $this->assertArrayHasKey( 'model', $result ); + $this->assertArrayHasKey( 'seed', $result ); + $this->assertArrayHasKey( 'cost_usd', $result ); + $this->assertArrayHasKey( 'latency_ms', $result ); + + $this->assertSame( $fake_bytes, $result['bytes'] ); + $this->assertSame( 'image/png', $result['mime_type'] ); + // Snapped to multiples of 64: 1200 → 1216, 632 → 640. + $this->assertSame( 1216, $result['width'] ); + $this->assertSame( 640, $result['height'] ); + $this->assertSame( 'runware:100@1', $result['model'] ); + $this->assertGreaterThan( 0, $result['seed'] ); + } + + /** 4xx (non-429) fails immediately with RuntimeException. */ + public function test_generate_image_4xx_throws_immediately(): void { + Functions\when( 'wp_remote_post' )->justReturn( [ + 'response' => [ 'code' => 401 ], + 'headers' => [], + 'body' => '{"errors":[{"message":"invalid key"}]}', + ] ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"invalid key"}]}' ); + Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' ); + + $provider = new \PRAutoBlogger_Runware_Image_Provider(); + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'HTTP 401' ); + $provider->generate_image( 'A test prompt', 1200, 632 ); + } + + /** 5xx triggers retry loop and eventually throws after PRAUTOBLOGGER_MAX_RETRIES. */ + public function test_generate_image_5xx_retries_then_throws(): void { + $call_count = 0; + Functions\when( 'wp_remote_post' )->alias( function () use ( &$call_count ) { + $call_count++; + return [ + 'response' => [ 'code' => 503 ], + 'headers' => [], + 'body' => '{"errors":[{"message":"temporarily unavailable"}]}', + ]; + } ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 503 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"temporarily unavailable"}]}' ); + Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' ); + + $provider = new \PRAutoBlogger_Runware_Image_Provider(); + try { + $provider->generate_image( 'A test prompt', 1200, 632 ); + $this->fail( 'Expected RuntimeException after max retries' ); + } catch ( \RuntimeException $e ) { + $this->assertSame( + PRAUTOBLOGGER_MAX_RETRIES, + $call_count, + 'Provider must attempt exactly PRAUTOBLOGGER_MAX_RETRIES times before giving up.' + ); + } + } + + /** errors[] in a 200 body is treated as an upstream error. */ + public function test_generate_image_errors_in_200_body_throws(): void { + $body = wp_json_encode( [ + 'errors' => [ [ 'message' => 'moderation rejected' ] ], + ] ); + Functions\when( 'wp_remote_post' )->justReturn( [ + 'response' => [ 'code' => 200 ], + 'headers' => [], + 'body' => $body, + ] ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body ); + Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' ); + + $provider = new \PRAutoBlogger_Runware_Image_Provider(); + $this->expectException( \RuntimeException::class ); + $provider->generate_image( 'A test prompt', 1200, 632 ); + } + + /** validate_credentials_detailed returns ok for a 200 response with no errors. */ + public function test_validate_credentials_detailed_ok(): void { + Functions\when( 'wp_remote_post' )->justReturn( [ + 'response' => [ 'code' => 200 ], + 'body' => '{"data":[{"taskType":"authentication"}]}', + ] ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"data":[{"taskType":"authentication"}]}' ); + + $provider = new \PRAutoBlogger_Runware_Image_Provider(); + $result = $provider->validate_credentials_detailed(); + $this->assertSame( 'ok', $result['status'] ); + } + + /** validate_credentials_detailed returns error when key missing. */ + public function test_validate_credentials_detailed_missing_key(): void { + Functions\when( 'get_option' )->alias( function ( $key, $default = false ) { + return $default; + } ); + + $provider = new \PRAutoBlogger_Runware_Image_Provider(); + $result = $provider->validate_credentials_detailed(); + $this->assertSame( 'error', $result['status'] ); + } + + /** validate_credentials_detailed returns error when response contains errors[]. */ + public function test_validate_credentials_detailed_rejected_key(): void { + Functions\when( 'wp_remote_post' )->justReturn( [ + 'response' => [ 'code' => 200 ], + 'body' => '{"errors":[{"message":"invalid key"}]}', + ] ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"invalid key"}]}' ); + + $provider = new \PRAutoBlogger_Runware_Image_Provider(); + $result = $provider->validate_credentials_detailed(); + $this->assertSame( 'error', $result['status'] ); + } +} diff --git a/tests/unit/Providers/RunwareImageSupportTest.php b/tests/unit/Providers/RunwareImageSupportTest.php new file mode 100644 index 0000000..aab8b8a --- /dev/null +++ b/tests/unit/Providers/RunwareImageSupportTest.php @@ -0,0 +1,184 @@ +returnArg(); + Functions\when( 'esc_html__' )->returnArg(); + Functions\when( '__' )->returnArg(); + + if ( ! class_exists( 'PRAutoBlogger_Encryption' ) ) { + eval( ' + class PRAutoBlogger_Encryption { + public static function is_encrypted( $v ) { return 0 === strpos( $v, "enc:" ); } + public static function decrypt( $v ) { return "rw-test-key-123"; } + public static function encrypt( $v ) { return "enc:" . $v; } + } + ' ); + } + } + + /** A well-formed imageInference entry returns its imageURL. */ + public function test_extract_image_url_valid_response(): void { + $support = new \PRAutoBlogger_Runware_Image_Support(); + $raw = wp_json_encode( [ + 'data' => [ + [ + 'taskType' => 'imageInference', + 'imageURL' => 'https://im.runware.ai/image/abc123.png', + ], + ], + ] ); + $this->assertSame( + 'https://im.runware.ai/image/abc123.png', + $support->extract_image_url( $raw ) + ); + } + + /** An errors[] array in the response causes a RuntimeException. */ + public function test_extract_image_url_error_response_throws(): void { + $support = new \PRAutoBlogger_Runware_Image_Support(); + $raw = wp_json_encode( [ + 'errors' => [ + [ 'message' => 'invalid api key' ], + ], + ] ); + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'invalid api key' ); + $support->extract_image_url( $raw ); + } + + /** Malformed JSON throws RuntimeException. */ + public function test_extract_image_url_invalid_json_throws(): void { + $support = new \PRAutoBlogger_Runware_Image_Support(); + $this->expectException( \RuntimeException::class ); + $support->extract_image_url( 'not json at all' ); + } + + /** Data array present but no imageInference entry throws. */ + public function test_extract_image_url_no_inference_entry_throws(): void { + $support = new \PRAutoBlogger_Runware_Image_Support(); + $raw = wp_json_encode( [ + 'data' => [ + [ 'taskType' => 'authentication' ], + ], + ] ); + $this->expectException( \RuntimeException::class ); + $support->extract_image_url( $raw ); + } + + /** imageInference present but empty imageURL throws. */ + public function test_extract_image_url_empty_url_throws(): void { + $support = new \PRAutoBlogger_Runware_Image_Support(); + $raw = wp_json_encode( [ + 'data' => [ + [ 'taskType' => 'imageInference', 'imageURL' => '' ], + ], + ] ); + $this->expectException( \RuntimeException::class ); + $support->extract_image_url( $raw ); + } + + /** snap_dimension rounds to nearest multiple of 64. */ + public function test_snap_dimension_rounds_to_64(): void { + $support = new \PRAutoBlogger_Runware_Image_Support(); + $this->assertSame( 1216, $support->snap_dimension( 1200 ) ); + $this->assertSame( 640, $support->snap_dimension( 632 ) ); + $this->assertSame( 1024, $support->snap_dimension( 1024 ) ); + $this->assertSame( 1024, $support->snap_dimension( 1050 ) ); + } + + /** snap_dimension clamps to minimum 512. */ + public function test_snap_dimension_clamps_min(): void { + $support = new \PRAutoBlogger_Runware_Image_Support(); + $this->assertSame( 512, $support->snap_dimension( 64 ) ); + $this->assertSame( 512, $support->snap_dimension( 200 ) ); + } + + /** snap_dimension clamps to maximum 2048. */ + public function test_snap_dimension_clamps_max(): void { + $support = new \PRAutoBlogger_Runware_Image_Support(); + $this->assertSame( 2048, $support->snap_dimension( 4096 ) ); + $this->assertSame( 2048, $support->snap_dimension( 2500 ) ); + } + + /** A valid positive seed passes through unchanged. */ + public function test_normalize_seed_positive_passthrough(): void { + $support = new \PRAutoBlogger_Runware_Image_Support(); + $this->assertSame( 42, $support->normalize_seed( 42 ) ); + $this->assertSame( 1234567, $support->normalize_seed( 1234567 ) ); + } + + /** Null seed derives a positive integer. */ + public function test_normalize_seed_null_derives_positive(): void { + $support = new \PRAutoBlogger_Runware_Image_Support(); + $seed = $support->normalize_seed( null ); + $this->assertGreaterThan( 0, $seed ); + } + + /** Zero seed is replaced with a derived positive integer. */ + public function test_normalize_seed_zero_replaced(): void { + $support = new \PRAutoBlogger_Runware_Image_Support(); + $seed = $support->normalize_seed( 0 ); + $this->assertGreaterThan( 0, $seed ); + } + + /** Negative seed is replaced with a derived positive integer. */ + public function test_normalize_seed_negative_replaced(): void { + $support = new \PRAutoBlogger_Runware_Image_Support(); + $seed = $support->normalize_seed( -5 ); + $this->assertGreaterThan( 0, $seed ); + } + + /** Missing API key option throws RuntimeException. */ + public function test_get_api_key_missing_throws(): void { + Functions\when( 'get_option' )->alias( function ( $key, $default = false ) { + return $default; + } ); + + $support = new \PRAutoBlogger_Runware_Image_Support(); + $this->expectException( \RuntimeException::class ); + $support->get_api_key(); + } + + /** Encrypted key is decrypted on read. */ + public function test_get_api_key_decrypts_encrypted(): void { + Functions\when( 'get_option' )->alias( function ( $key, $default = false ) { + if ( 'prautoblogger_runware_api_key' === $key ) { + return 'enc:stored-ciphertext'; + } + return $default; + } ); + + $support = new \PRAutoBlogger_Runware_Image_Support(); + $this->assertSame( 'rw-test-key-123', $support->get_api_key() ); + } + + /** A plaintext key (pre-migration) passes through unchanged. */ + public function test_get_api_key_plaintext_passthrough(): void { + Functions\when( 'get_option' )->alias( function ( $key, $default = false ) { + if ( 'prautoblogger_runware_api_key' === $key ) { + return 'rw-plain-key'; + } + return $default; + } ); + + $support = new \PRAutoBlogger_Runware_Image_Support(); + $this->assertSame( 'rw-plain-key', $support->get_api_key() ); + } +} diff --git a/tests/unit/Services/ImageModelRegistryTest.php b/tests/unit/Services/ImageModelRegistryTest.php index f6235c1..b35ccce 100644 --- a/tests/unit/Services/ImageModelRegistryTest.php +++ b/tests/unit/Services/ImageModelRegistryTest.php @@ -18,7 +18,7 @@ class ImageModelRegistryTest extends BaseTestCase { /** * Every entry returned by get_models() must carry a non-empty id and - * a provider of 'openrouter' or 'cloudflare'. If this breaks, the + * a provider of 'runware', 'openrouter', or 'cloudflare'. If this breaks, the * admin save flow will silently drop the provider derivation. */ public function test_every_registry_entry_has_id_and_known_provider(): void { @@ -31,7 +31,7 @@ class ImageModelRegistryTest extends BaseTestCase { $this->assertArrayHasKey( 'provider', $model ); $this->assertContains( $model['provider'], - [ 'openrouter', 'cloudflare' ], + [ 'runware', 'openrouter', 'cloudflare' ], sprintf( 'Unexpected provider %s for model %s', (string) $model['provider'], (string) $model['id'] ) ); } @@ -52,6 +52,22 @@ class ImageModelRegistryTest extends BaseTestCase { ); } + /** + * Runware model ids (added v0.9.0) resolve to the 'runware' provider. + * This test pins the v0.9.0 default flip so a registry regression + * that loses the Runware entries fails CI loudly. + */ + public function test_provider_for_runware_models(): void { + $this->assertSame( + 'runware', + \PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:100@1' ) + ); + $this->assertSame( + 'runware', + \PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:101@1' ) + ); + } + /** * Unknown model ids return an empty string so callers can distinguish * "not in registry" from a valid provider and show a settings error.