v0.9.0: Runware FLUX.1 schnell as default image provider

New Runware image backend at ~$0.0006/image (FLUX.1 schnell) — 65× cheaper
than Gemini 2.5 Flash Image. Provider mirrors OpenRouter's split: interface
+ provider + support + pricing + batch, all under the 300-line cap. Batch
class uses curl_multi for true parallel A/B dispatch — wall-clock drops to
the slowest single image instead of the sum.

Changes:
- Add PRAutoBlogger_Runware_Image_Provider + Pricing + Support + Batch
- Flip PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER/MODEL to runware:100@1
- Add prautoblogger_runware_api_key settings field (encrypted at rest)
- v0.9.0 one-time migration flips legacy default model + re-derives provider
- Wire runware branch into Image_Pipeline::create_default_provider()
- Add runware:100@1 and runware:101@1 to Image_Model_Registry (defaults top)
- Update image prompt builder: no more anthropomorphized molecules;
  prefer concrete human-scale scenes (gym bros, doctors, scientists)
- ARCHITECTURE.md + CHANGELOG.md updated with new integration + decision
- Unit tests for pricing, support, provider; update model registry test

Cost impact at 100 images/day (50 articles × A/B):
- Before (Gemini via OpenRouter): $3.90/day ≈ $117/mo
- After  (FLUX schnell via Runware): $0.06/day ≈ $1.80/mo
Net saving: ~$115/mo
This commit is contained in:
peptiderepo
2026-04-21 15:56:35 +00:00
parent 80a7e11a2b
commit 2cacaa3512
18 changed files with 1613 additions and 28 deletions

View File

@@ -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 ) {

View File

@@ -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<int, array<string, mixed>>
*/
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 ) {

View File

@@ -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' ),

View File

@@ -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' );
}
}

View File

@@ -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 ) ) {

View File

@@ -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();
}

View File

@@ -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;
/**

View File

@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_close
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_errno
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_error
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_getinfo
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_init
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_exec
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_select
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_close
/**
* Parallel image generation via curl_multi for Runware.
*
* Fires multiple POST /v1 inference calls concurrently, then fetches
* the returned signed image URLs in a second parallel round. Cuts
* wall-clock time from N×2s to ~2s regardless of batch size — matches
* the Image A/B parallelism pattern already in use for OpenRouter.
*
* Triggered by: PRAutoBlogger_Runware_Image_Provider::generate_image_batch().
* Dependencies: PRAutoBlogger_Runware_Image_Support, PRAutoBlogger_Runware_Image_Pricing,
* PRAutoBlogger_Logger.
*
* @see class-runware-image-provider.php — Sole caller.
* @see class-runware-image-support.php — Response parsing + image URL fetch.
* @see class-open-router-image-batch.php — Sibling pattern for OpenRouter.
*/
class PRAutoBlogger_Runware_Image_Batch {
/** @var PRAutoBlogger_Runware_Image_Pricing */
private PRAutoBlogger_Runware_Image_Pricing $pricing;
/** @var PRAutoBlogger_Runware_Image_Support */
private PRAutoBlogger_Runware_Image_Support $support;
public function __construct(
PRAutoBlogger_Runware_Image_Pricing $pricing,
PRAutoBlogger_Runware_Image_Support $support
) {
$this->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<string, array{prompt: string, width: int, height: int, options?: array}> $requests Keyed requests.
* @return array<string, 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<string, 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<string, array> $inferences Map from round one.
* @return array<string, array{bytes?: string, error?: string}>
*/
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<int, array<string, mixed>>
*/
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,
];
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
/**
* Pricing + model-id resolution helpers for the Runware image provider.
*
* Each Runware FLUX model has a flat per-image cost sourced from
* runware.ai/pricing (April 2026). Unlike OpenRouter models, Runware
* prices scale slightly with steps — the quoted rate here assumes the
* provider's default step count (4 for schnell, 28 for dev).
*
* Triggered by: PRAutoBlogger_Runware_Image_Provider, PRAutoBlogger_Runware_Image_Batch.
* Dependencies: None — pure calculation + option reads.
*
* @see class-runware-image-provider.php — Primary caller.
* @see class-open-router-image-pricing.php — Sibling pattern for OpenRouter.
*/
class PRAutoBlogger_Runware_Image_Pricing {
/**
* Flat per-image cost (USD) for each supported Runware model, April 2026.
* Update here when Runware reprices or new models launch.
*
* Runware model IDs use `family:variant@version` form. The two FLUX
* variants we support are the CEO-sanctioned defaults.
*
* @var array<string, float>
*/
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<string, int>
*/
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<string, float> Model id => cost per image in USD.
*/
public static function get_model_costs(): array {
return self::COST_PER_IMAGE;
}
}

View File

@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
/**
* Runware image provider — FLUX.1 schnell / dev via runware.ai.
*
* Default image backend as of 2026-04-21: schnell (runware:100@1) at
* ~$0.0006/image, ~2s latency. 65× cheaper than Gemini Nano Banana with
* quality that reads as "stylistic looseness" for our comic aesthetic.
*
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation.
* Dependencies: Runware_Image_Support (key/parsing/backoff),
* Runware_Image_Pricing (model/cost),
* Runware_Image_Batch (parallel curl_multi), Logger.
*
* @see interface-image-provider.php — Contract.
* @see class-runware-image-batch.php — Parallel generation via curl_multi.
* @see class-runware-image-support.php — Key, response parsing, retry.
* @see class-runware-image-pricing.php — Model + cost table.
* @see class-open-router-image-provider.php — Sibling pattern for OpenRouter.
* @see ARCHITECTURE.md — External API Integrations table.
*/
class PRAutoBlogger_Runware_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
private const PROVIDER_ID = 'runware';
private const MIN_DIMENSION_PX = 64;
private const MAX_DIMENSION_PX = 2048;
/** @var PRAutoBlogger_Runware_Image_Pricing */
private PRAutoBlogger_Runware_Image_Pricing $pricing;
/** @var PRAutoBlogger_Runware_Image_Support */
private PRAutoBlogger_Runware_Image_Support $support;
public function __construct() {
$this->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<int, array<string, mixed>>
*/
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
)
);
}
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
/**
* Support helpers for the Runware image provider — API key lookup,
* response parsing (URL fetch), retry backoff, and logging.
*
* Split from the provider to keep both files under the 300-line cap.
*
* Triggered by: PRAutoBlogger_Runware_Image_Provider, PRAutoBlogger_Runware_Image_Batch.
* Dependencies: PRAutoBlogger_Encryption, PRAutoBlogger_Logger.
*
* @see class-runware-image-provider.php — Primary caller.
* @see class-runware-image-batch.php — Parallel caller.
*/
class PRAutoBlogger_Runware_Image_Support {
/**
* Runware task-based single endpoint. Unlike OpenRouter /chat/completions,
* Runware accepts a JSON array of tasks (auth + inference) at this URL.
*/
public const API_URL = 'https://api.runware.ai/v1';
/**
* Get the decrypted Runware API key from settings.
*
* @return string Plaintext key.
* @throws \RuntimeException If key is not configured or decrypt fails.
*/
public function get_api_key(): string {
$stored = (string) get_option( 'prautoblogger_runware_api_key', '' );
if ( '' === $stored ) {
throw new \RuntimeException(
__( 'Runware API key is not configured. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' )
);
}
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
if ( '' === $decrypted ) {
throw new \RuntimeException(
__( 'Runware API key could not be decrypted. Re-enter in Settings.', 'prautoblogger' )
);
}
return $decrypted;
}
return $stored;
}
/**
* Extract the image URL from Runware's JSON response body.
*
* The response is `{"data": [{"taskType":"imageInference","imageURL":"..."}]}`.
* We scan `data[]` for the first imageInference entry with a non-empty URL.
*
* @param string $raw Raw JSON response body from POST /v1.
* @return string The imageURL to fetch bytes from.
* @throws \RuntimeException If the response is malformed or contains no image.
*/
public function extract_image_url( string $raw ): string {
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) ) {
throw new \RuntimeException(
esc_html__( 'Runware returned invalid JSON.', 'prautoblogger' )
);
}
// Runware wraps errors in an `errors` array.
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
$first = $decoded['errors'][0] ?? [];
$msg = is_array( $first )
? (string) ( $first['message'] ?? wp_json_encode( $first ) )
: (string) $first;
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware upstream error: %s', 'prautoblogger' ),
esc_html( substr( $msg, 0, 300 ) )
)
);
}
$data = $decoded['data'] ?? [];
if ( ! is_array( $data ) || empty( $data ) ) {
throw new \RuntimeException(
esc_html__( 'Runware response contained no data.', 'prautoblogger' )
);
}
foreach ( $data as $entry ) {
if ( ! is_array( $entry ) ) {
continue;
}
if ( 'imageInference' === ( $entry['taskType'] ?? '' ) ) {
$url = (string) ( $entry['imageURL'] ?? '' );
if ( '' !== $url ) {
return $url;
}
}
}
throw new \RuntimeException(
esc_html__( 'Runware response contained no imageInference imageURL.', 'prautoblogger' )
);
}
/**
* Download the image bytes from a signed Runware delivery URL.
*
* Runware returns the image URL separately and bills the inference call;
* downloading is a regular GET against `im.runware.ai`.
*
* @param string $image_url Signed image URL from extract_image_url().
* @return string Raw PNG bytes.
* @throws \RuntimeException On HTTP error or empty body.
*/
public function download_image_bytes( string $image_url ): string {
$response = wp_remote_get( $image_url, [
'timeout' => 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 );
}
}
}