Skip to content

Commit ff34540

Browse files
committed
feat: Enhance image generation clients with improved error handling, documentation, and MIME type utilities
1 parent 587c36f commit ff34540

11 files changed

Lines changed: 280 additions & 132 deletions

src/BflImageClient.cs

Lines changed: 44 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,49 @@
55

66
namespace ImageGenCli;
77

8+
/// <summary>
9+
/// Image generation client for Black Forest Labs FLUX API.
10+
/// Supports flux-2-pro, flux-2-flex, and flux-2-max models.
11+
/// </summary>
812
public class BflImageClient : IImageGenerationClient
913
{
10-
private readonly HttpClient _http;
14+
private static readonly HttpClient Http = CreateHttpClient();
1115
private readonly string _apiKey;
1216
private readonly string _model;
1317
private const string BaseUrl = "https://api.bfl.ml";
1418
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1);
1519
private static readonly TimeSpan MaxWaitTime = TimeSpan.FromMinutes(5);
1620

21+
private const int FlexDefaultSteps = 50;
22+
private const double FlexDefaultGuidance = 4.5;
23+
private const double MaxMegapixels = 4.0;
24+
private const int DimensionMultiple = 16;
25+
private const int MinDimension = 64;
26+
private const int MaxReferenceImages = 8;
27+
1728
private static readonly JsonSerializerOptions JsonOptions = new()
1829
{
1930
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
2031
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
2132
};
2233

34+
private static HttpClient CreateHttpClient()
35+
{
36+
return new HttpClient();
37+
}
38+
39+
/// <summary>
40+
/// Creates a new BFL FLUX image client.
41+
/// </summary>
42+
/// <param name="apiKey">The BFL API key.</param>
43+
/// <param name="model">The model to use (default: flux-2-pro).</param>
2344
public BflImageClient(string apiKey, string model = "flux-2-pro")
2445
{
2546
_apiKey = apiKey;
2647
_model = model;
27-
_http = new HttpClient();
28-
_http.DefaultRequestHeaders.Add("x-key", _apiKey);
2948
}
3049

50+
/// <inheritdoc />
3151
public async Task<GenerationResult> GenerateImagesAsync(GenerationRequest request, CancellationToken ct = default)
3252
{
3353
var endpoint = GetEndpoint();
@@ -43,13 +63,13 @@ public async Task<GenerationResult> GenerateImagesAsync(GenerationRequest reques
4363
["output_format"] = "png"
4464
};
4565

46-
// Add reference images if provided (up to 8)
47-
for (int i = 0; i < Math.Min(request.ReferenceImages.Length, 8); i++)
66+
// Add reference images if provided (up to max limit)
67+
for (int i = 0; i < Math.Min(request.ReferenceImages.Length, MaxReferenceImages); i++)
4868
{
4969
var imagePath = request.ReferenceImages[i];
5070
var bytes = await File.ReadAllBytesAsync(imagePath, ct);
5171
var base64 = Convert.ToBase64String(bytes);
52-
var mimeType = GetMimeType(imagePath);
72+
var mimeType = MimeTypeHelper.GetMimeType(imagePath);
5373
var dataUri = $"data:{mimeType};base64,{base64}";
5474

5575
var key = i == 0 ? "input_image" : $"input_image_{i + 1}";
@@ -59,8 +79,8 @@ public async Task<GenerationResult> GenerateImagesAsync(GenerationRequest reques
5979
// Flex-specific parameters
6080
if (_model.Contains("flex", StringComparison.OrdinalIgnoreCase))
6181
{
62-
body["steps"] = 50;
63-
body["guidance"] = 4.5;
82+
body["steps"] = FlexDefaultSteps;
83+
body["guidance"] = FlexDefaultGuidance;
6484
}
6585

6686
var images = new List<GeneratedImage>();
@@ -74,7 +94,11 @@ public async Task<GenerationResult> GenerateImagesAsync(GenerationRequest reques
7494
body["seed"] = Random.Shared.Next();
7595
}
7696

77-
var response = await _http.PostAsJsonAsync(url, body, JsonOptions, ct);
97+
using var requestMessage = new HttpRequestMessage(HttpMethod.Post, url);
98+
requestMessage.Headers.Add("x-key", _apiKey);
99+
requestMessage.Content = JsonContent.Create(body, options: JsonOptions);
100+
101+
var response = await Http.SendAsync(requestMessage, ct);
78102
var content = await response.Content.ReadAsStringAsync(ct);
79103

80104
if (!response.IsSuccessStatusCode)
@@ -108,7 +132,7 @@ public async Task<GenerationResult> GenerateImagesAsync(GenerationRequest reques
108132
{
109133
ct.ThrowIfCancellationRequested();
110134

111-
var response = await _http.GetAsync(pollUrl, ct);
135+
var response = await Http.GetAsync(pollUrl, ct);
112136
var content = await response.Content.ReadAsStringAsync(ct);
113137

114138
if (!response.IsSuccessStatusCode)
@@ -163,9 +187,9 @@ public async Task<GenerationResult> GenerateImagesAsync(GenerationRequest reques
163187
throw new ImageGenerationException($"BFL generation timed out after {MaxWaitTime.TotalMinutes} minutes");
164188
}
165189

166-
private async Task<GeneratedImage> DownloadImageAsync(string imageUrl, CancellationToken ct)
190+
private static async Task<GeneratedImage> DownloadImageAsync(string imageUrl, CancellationToken ct)
167191
{
168-
var response = await _http.GetAsync(imageUrl, ct);
192+
var response = await Http.GetAsync(imageUrl, ct);
169193
if (!response.IsSuccessStatusCode)
170194
{
171195
throw new ImageGenerationException($"Failed to download generated image: {(int)response.StatusCode}");
@@ -202,22 +226,22 @@ private static (int width, int height) MapAspectRatioToSize(string aspectRatio,
202226
_ => 1.0 // 1K default
203227
};
204228

205-
// Calculate dimensions based on aspect ratio, constrained to 4MP max
206-
megapixels = Math.Min(megapixels, 4.0);
229+
// Constrain to max megapixels
230+
megapixels = Math.Min(megapixels, MaxMegapixels);
207231
var totalPixels = megapixels * 1_000_000;
208232

209233
var (ratioW, ratioH) = ParseAspectRatio(aspectRatio);
210234
var scale = Math.Sqrt(totalPixels / (ratioW * ratioH));
211235
var width = (int)(ratioW * scale);
212236
var height = (int)(ratioH * scale);
213237

214-
// Round to nearest multiple of 16 (BFL requirement)
215-
width = (width / 16) * 16;
216-
height = (height / 16) * 16;
238+
// Round to nearest multiple (BFL requirement)
239+
width = (width / DimensionMultiple) * DimensionMultiple;
240+
height = (height / DimensionMultiple) * DimensionMultiple;
217241

218-
// Ensure minimum of 64
219-
width = Math.Max(64, width);
220-
height = Math.Max(64, height);
242+
// Ensure minimum dimension
243+
width = Math.Max(MinDimension, width);
244+
height = Math.Max(MinDimension, height);
221245

222246
return (width, height);
223247
}
@@ -234,20 +258,6 @@ private static (double w, double h) ParseAspectRatio(string aspectRatio)
234258
return (1, 1); // Default to square
235259
}
236260

237-
private static string GetMimeType(string path)
238-
{
239-
var ext = Path.GetExtension(path).ToLowerInvariant();
240-
return ext switch
241-
{
242-
".png" => "image/png",
243-
".jpg" or ".jpeg" => "image/jpeg",
244-
".gif" => "image/gif",
245-
".webp" => "image/webp",
246-
".bmp" => "image/bmp",
247-
_ => "application/octet-stream"
248-
};
249-
}
250-
251261
private class AsyncResponse
252262
{
253263
public string? Id { get; set; }

src/GeminiImageClient.cs

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55

66
namespace ImageGenCli;
77

8+
/// <summary>
9+
/// Image generation client for Google Gemini API.
10+
/// Supports gemini-2.5-flash-image and gemini-3-pro-image-preview models.
11+
/// </summary>
812
public class GeminiImageClient : IImageGenerationClient
913
{
10-
private readonly HttpClient _http;
14+
private static readonly HttpClient Http = new();
1115
private readonly string _apiKey;
1216
private readonly string _model;
1317
private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta/models";
@@ -18,13 +22,18 @@ public class GeminiImageClient : IImageGenerationClient
1822
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
1923
};
2024

25+
/// <summary>
26+
/// Creates a new Gemini image client.
27+
/// </summary>
28+
/// <param name="apiKey">The Gemini API key.</param>
29+
/// <param name="model">The model to use (default: gemini-2.5-flash-image).</param>
2130
public GeminiImageClient(string apiKey, string model = "gemini-2.5-flash-image")
2231
{
2332
_apiKey = apiKey;
2433
_model = model;
25-
_http = new HttpClient();
2634
}
2735

36+
/// <inheritdoc />
2837
public async Task<GenerationResult> GenerateImagesAsync(GenerationRequest request, CancellationToken ct = default)
2938
{
3039
var url = $"{BaseUrl}/{_model}:generateContent?key={_apiKey}";
@@ -39,7 +48,7 @@ public async Task<GenerationResult> GenerateImagesAsync(GenerationRequest reques
3948
{
4049
var bytes = await File.ReadAllBytesAsync(imagePath, ct);
4150
var base64 = Convert.ToBase64String(bytes);
42-
var mimeType = GetMimeType(imagePath);
51+
var mimeType = MimeTypeHelper.GetMimeType(imagePath);
4352
parts.Add(new
4453
{
4554
inline_data = new
@@ -80,7 +89,7 @@ public async Task<GenerationResult> GenerateImagesAsync(GenerationRequest reques
8089
};
8190
}
8291

83-
var response = await _http.PostAsJsonAsync(url, body, JsonOptions, ct);
92+
var response = await Http.PostAsJsonAsync(url, body, JsonOptions, ct);
8493
var content = await response.Content.ReadAsStringAsync(ct);
8594

8695
if (!response.IsSuccessStatusCode)
@@ -106,6 +115,7 @@ public async Task<GenerationResult> GenerateImagesAsync(GenerationRequest reques
106115
{
107116
textParts.Add(textProp.GetString() ?? "");
108117
}
118+
// Handle both camelCase and snake_case property names
109119
else if (part.TryGetProperty("inlineData", out var inlineData) ||
110120
part.TryGetProperty("inline_data", out inlineData))
111121
{
@@ -129,18 +139,4 @@ public async Task<GenerationResult> GenerateImagesAsync(GenerationRequest reques
129139

130140
return result;
131141
}
132-
133-
private static string GetMimeType(string path)
134-
{
135-
var ext = Path.GetExtension(path).ToLowerInvariant();
136-
return ext switch
137-
{
138-
".png" => "image/png",
139-
".jpg" or ".jpeg" => "image/jpeg",
140-
".gif" => "image/gif",
141-
".webp" => "image/webp",
142-
".bmp" => "image/bmp",
143-
_ => "application/octet-stream"
144-
};
145-
}
146142
}

src/IImageGenerationClient.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,18 @@
22

33
namespace ImageGenCli;
44

5+
/// <summary>
6+
/// Interface for image generation providers.
7+
/// Implementations handle provider-specific API communication and response parsing.
8+
/// </summary>
59
public interface IImageGenerationClient
610
{
11+
/// <summary>
12+
/// Generates images based on the provided request.
13+
/// </summary>
14+
/// <param name="request">The generation request containing prompt and parameters.</param>
15+
/// <param name="ct">Cancellation token for the operation.</param>
16+
/// <returns>A result containing generated images and optional text response.</returns>
17+
/// <exception cref="ImageGenerationException">Thrown when the API returns an error.</exception>
718
Task<GenerationResult> GenerateImagesAsync(GenerationRequest request, CancellationToken ct = default);
819
}

src/ImageGenerationException.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
namespace ImageGenCli;
22

3+
/// <summary>
4+
/// Exception thrown when an image generation API returns an error.
5+
/// </summary>
36
public class ImageGenerationException : Exception
47
{
8+
/// <summary>
9+
/// Creates a new image generation exception with the specified message.
10+
/// </summary>
11+
/// <param name="message">The error message describing the API failure.</param>
512
public ImageGenerationException(string message) : base(message) { }
13+
14+
/// <summary>
15+
/// Creates a new image generation exception with the specified message and inner exception.
16+
/// </summary>
17+
/// <param name="message">The error message describing the API failure.</param>
18+
/// <param name="innerException">The underlying exception that caused this error.</param>
19+
public ImageGenerationException(string message, Exception innerException) : base(message, innerException) { }
620
}

src/MimeTypeHelper.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
namespace ImageGenCli;
2+
3+
/// <summary>
4+
/// Provides MIME type detection utilities for image files.
5+
/// </summary>
6+
public static class MimeTypeHelper
7+
{
8+
/// <summary>
9+
/// Gets the MIME type based on file extension.
10+
/// </summary>
11+
/// <param name="path">The file path to determine MIME type for.</param>
12+
/// <returns>The MIME type string (e.g., "image/png").</returns>
13+
public static string GetMimeType(string path)
14+
{
15+
var ext = Path.GetExtension(path).ToLowerInvariant();
16+
return ext switch
17+
{
18+
".png" => "image/png",
19+
".jpg" or ".jpeg" => "image/jpeg",
20+
".gif" => "image/gif",
21+
".webp" => "image/webp",
22+
".bmp" => "image/bmp",
23+
_ => "application/octet-stream"
24+
};
25+
}
26+
27+
/// <summary>
28+
/// Gets the file extension for a MIME type.
29+
/// </summary>
30+
/// <param name="mimeType">The MIME type to get extension for.</param>
31+
/// <returns>The file extension without dot (e.g., "png").</returns>
32+
public static string GetExtension(string mimeType)
33+
{
34+
return mimeType switch
35+
{
36+
"image/png" => "png",
37+
"image/jpeg" => "jpg",
38+
"image/webp" => "webp",
39+
"image/gif" => "gif",
40+
"image/bmp" => "bmp",
41+
_ => "png"
42+
};
43+
}
44+
45+
/// <summary>
46+
/// Guesses MIME type from a URL based on file extension in the URL.
47+
/// </summary>
48+
/// <param name="url">The URL to analyze.</param>
49+
/// <returns>The guessed MIME type.</returns>
50+
public static string GuessMimeTypeFromUrl(string url)
51+
{
52+
if (url.Contains(".png", StringComparison.OrdinalIgnoreCase)) return "image/png";
53+
if (url.Contains(".jpg", StringComparison.OrdinalIgnoreCase) ||
54+
url.Contains(".jpeg", StringComparison.OrdinalIgnoreCase)) return "image/jpeg";
55+
if (url.Contains(".webp", StringComparison.OrdinalIgnoreCase)) return "image/webp";
56+
if (url.Contains(".gif", StringComparison.OrdinalIgnoreCase)) return "image/gif";
57+
return "image/png"; // Default
58+
}
59+
}

src/Models/GeneratedImage.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
namespace ImageGenCli.Models;
22

3+
/// <summary>
4+
/// A single generated image with its data and format.
5+
/// </summary>
36
public class GeneratedImage
47
{
8+
/// <summary>
9+
/// MIME type of the image (e.g., "image/png", "image/jpeg").
10+
/// </summary>
511
public required string MimeType { get; init; }
12+
13+
/// <summary>
14+
/// Raw image data as bytes.
15+
/// </summary>
616
public required byte[] Data { get; init; }
717
}

0 commit comments

Comments
 (0)