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, ]; } }