Files
prautoblogger/includes/providers/class-runware-image-provider.php
peptiderepo 2cacaa3512 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
2026-04-21 15:56:35 +00:00

295 lines
9.8 KiB
PHP
Raw Blame History

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