provider = $provider ?? self::create_default_provider(); $this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder(); $this->sideloader = new PRAutoBlogger_Image_Media_Sideloader(); $this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker(); } /** * Instantiate the image provider based on the admin setting. * * @return PRAutoBlogger_Image_Provider_Interface */ private static function create_default_provider(): PRAutoBlogger_Image_Provider_Interface { $provider_id = (string) get_option( 'prautoblogger_image_provider', PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER ); if ( 'openrouter' === $provider_id ) { return new PRAutoBlogger_OpenRouter_Image_Provider(); } return new PRAutoBlogger_Cloudflare_Image_Provider(); } /** * Generate and attach images to a published post. * * Generates Image A (article-driven) and Image B (source-driven). If enabled * in settings. Each image is independently fallible. * * @param int $post_id Post ID to attach images to. * @param array{ * post_title?: string, * post_content?: string, * suggested_title?: string, * } $article_data Article title + content. * @param array{ * title?: string, * selftext?: string, * comments?: string[], * }|null $source_data Optional Reddit source data for Image B. * * @return array{ * image_a_id?: int, * image_b_id?: int, * cost_usd: float, * errors: string[], * } Attachment IDs (if successful) and cost/errors. */ public function generate_and_attach_images( int $post_id, array $article_data, ?array $source_data = null ): array { $result = [ 'cost_usd' => 0.0, 'errors' => [], ]; // Early exit if image generation is disabled. if ( ! get_option( 'prautoblogger_image_enabled' ) ) { PRAutoBlogger_Logger::instance()->info( 'Image generation disabled in settings.', 'image_pipeline' ); return $result; } // Generate Image A (article-driven prompt). $image_a_result = $this->generate_image_a( $post_id, $article_data ); if ( ! is_wp_error( $image_a_result ) && isset( $image_a_result['attachment_id'] ) ) { $result['image_a_id'] = $image_a_result['attachment_id']; // Set featured image immediately so it persists even if the // process times out before Image B finishes or before the // caller gets to handle the return value. set_post_thumbnail( $post_id, $image_a_result['attachment_id'] ); PRAutoBlogger_Logger::instance()->info( sprintf( 'Set featured image (attachment %d) for post %d', $image_a_result['attachment_id'], $post_id ), 'image_pipeline' ); } else { $result['errors'][] = is_wp_error( $image_a_result ) ? $image_a_result->get_error_message() : 'Image A generation produced no attachment ID.'; } if ( ! is_wp_error( $image_a_result ) ) { $result['cost_usd'] += $image_a_result['cost_usd'] ?? 0.0; } // Generate Image B (source-driven prompt) if source data is available. if ( null !== $source_data && ! empty( $source_data ) ) { $image_b_result = $this->generate_image_b( $post_id, $source_data ); if ( ! is_wp_error( $image_b_result ) && isset( $image_b_result['attachment_id'] ) ) { $result['image_b_id'] = $image_b_result['attachment_id']; // Store Image B reference immediately for the same // timeout-resilience reason as Image A above. update_post_meta( $post_id, '_prautoblogger_image_b_id', $image_b_result['attachment_id'] ); PRAutoBlogger_Logger::instance()->info( sprintf( 'Stored Image B (attachment %d) for post %d', $image_b_result['attachment_id'], $post_id ), 'image_pipeline' ); } else { $result['errors'][] = is_wp_error( $image_b_result ) ? $image_b_result->get_error_message() : 'Image B generation produced no attachment ID.'; } if ( ! is_wp_error( $image_b_result ) ) { $result['cost_usd'] += $image_b_result['cost_usd'] ?? 0.0; } } else { PRAutoBlogger_Logger::instance()->info( sprintf( 'Image B skipped for post %d: no source data available.', $post_id ), 'image_pipeline' ); } return $result; } /** * Generate Image A from article content. * * @param int $post_id Post ID. * @param array $article_data Article data. * * @return array{ * attachment_id?: int, * cost_usd: float, * }|\WP_Error */ private function generate_image_a( int $post_id, array $article_data ) { try { // Build the article-driven prompt. $prompt = $this->prompt_builder->build_article_prompt( $article_data ); // Estimate cost before generating. $estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT ); if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) { return new \WP_Error( 'budget_exceeded', 'Image generation would exceed monthly budget.' ); } // Generate the image. $image_data = $this->provider->generate_image( $prompt, self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT ); // Sideload the image into media library. $attachment_id = $this->sideloader->sideload_image( $image_data, $post_id, substr( $prompt, 0, 100 ) // Use first 100 chars of prompt as alt text. ); if ( is_wp_error( $attachment_id ) ) { return $attachment_id; } // Log the cost. $this->cost_tracker->log_image_generation( $image_data['cost_usd'], $image_data['model'] ?? 'unknown', $post_id, 'image_a' ); PRAutoBlogger_Logger::instance()->info( sprintf( 'Image A generated for post %d (attachment %d, cost $%.4f)', $post_id, $attachment_id, $image_data['cost_usd'] ), 'image_pipeline' ); return [ 'attachment_id' => $attachment_id, 'cost_usd' => $image_data['cost_usd'], ]; } catch ( \Throwable $e ) { PRAutoBlogger_Logger::instance()->error( sprintf( 'Image A %s: %s', get_class( $e ), $e->getMessage() ), 'image_pipeline' ); return new \WP_Error( 'image_generation_failed', $e->getMessage() ); } } /** * Generate Image B from source data. * * @param int $post_id Post ID. * @param array $source_data Source Reddit data. * * @return array{ * attachment_id?: int, * cost_usd: float, * }|\WP_Error */ private function generate_image_b( int $post_id, array $source_data ) { try { // Build the source-driven prompt. $prompt = $this->prompt_builder->build_source_prompt( $source_data ); // Estimate cost before generating. $estimated_cost = $this->provider->estimate_cost( self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT ); if ( $this->cost_tracker->would_exceed_budget( $estimated_cost ) ) { return new \WP_Error( 'budget_exceeded', 'Image generation would exceed monthly budget.' ); } // Generate the image. $image_data = $this->provider->generate_image( $prompt, self::DEFAULT_WIDTH, self::DEFAULT_HEIGHT ); // Sideload the image into media library. $attachment_id = $this->sideloader->sideload_image( $image_data, $post_id, substr( $prompt, 0, 100 ) // Use first 100 chars of prompt as alt text. ); if ( is_wp_error( $attachment_id ) ) { return $attachment_id; } // Log the cost. $this->cost_tracker->log_image_generation( $image_data['cost_usd'], $image_data['model'] ?? 'unknown', $post_id, 'image_b' ); PRAutoBlogger_Logger::instance()->info( sprintf( 'Image B generated for post %d (attachment %d, cost $%.4f)', $post_id, $attachment_id, $image_data['cost_usd'] ), 'image_pipeline' ); return [ 'attachment_id' => $attachment_id, 'cost_usd' => $image_data['cost_usd'], ]; } catch ( \Throwable $e ) { PRAutoBlogger_Logger::instance()->error( sprintf( 'Image B %s: %s', get_class( $e ), $e->getMessage() ), 'image_pipeline' ); return new \WP_Error( 'image_generation_failed', $e->getMessage() ); } } }