Files
AdPlatform-Server/Creative/Services/ImageGeneratorService.cs
2026-03-14 13:50:09 -07:00

415 lines
16 KiB
C#
Raw Permalink 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.
using System.Text;
using System.Text.Json;
using Creative.Configuration;
using Creative.Models;
using Microsoft.Extensions.Options;
namespace Creative.Services;
/// <summary>
/// Sources images for campaign drafts.
/// Three providers:
/// - emulated: placeholder images (no network calls)
/// - unsplash: free stock photos via Unsplash API
/// - dalle: AI-generated images via OpenAI DALL-E (requires OpenAI key)
///
/// Google Ads Responsive Display Ad image specs:
/// Landscape (1.91:1): 1200×628 recommended
/// Square (1:1): 1200×1200 recommended
/// Portrait (4:5): 960×1200 recommended (optional)
/// </summary>
public class ImageGeneratorService
{
private readonly CreativeConfig _config;
private readonly IHttpClientFactory _httpFactory;
private readonly ILogger<ImageGeneratorService> _logger;
public ImageGeneratorService(
IOptions<CreativeConfig> config,
IHttpClientFactory httpFactory,
ILogger<ImageGeneratorService> logger)
{
_config = config.Value;
_httpFactory = httpFactory;
_logger = logger;
}
/// <summary>
/// Get images matching the analyzed content.
/// Returns a list of ImageAssets and the provider name used.
/// </summary>
public async Task<(List<ImageAsset> Images, string Source)>
GenerateAsync(UrlAnalysis analysis, CancellationToken ct)
{
var provider = (_config.ImageProvider ?? "emulated").ToLowerInvariant();
_logger.LogInformation("[ImageGen] Provider={Provider} for {Url}", provider, analysis.Url);
return provider switch
{
"unsplash" => await GenerateUnsplashAsync(analysis, ct),
"dalle" => await GenerateDalleAsync(analysis, ct),
_ => EmulateGeneration(analysis)
};
}
// ============================================================
// Emulated Provider
// ============================================================
private (List<ImageAsset>, string) EmulateGeneration(UrlAnalysis analysis)
{
_logger.LogInformation("[ImageGen] Emulated images for {Url}", analysis.Url);
var keyword = ExtractSearchKeyword(analysis);
var images = new List<ImageAsset>
{
new()
{
ImageId = $"emu-landscape-{Guid.NewGuid():N}"[..20],
Url = $"https://placehold.co/1200x628/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}",
Source = "emulated",
Orientation = "landscape",
Width = 1200,
Height = 628,
AltText = $"{keyword} - landscape",
Attribution = "Placeholder image"
},
new()
{
ImageId = $"emu-square-{Guid.NewGuid():N}"[..20],
Url = $"https://placehold.co/1200x1200/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}",
Source = "emulated",
Orientation = "square",
Width = 1200,
Height = 1200,
AltText = $"{keyword} - square",
Attribution = "Placeholder image"
},
new()
{
ImageId = $"emu-portrait-{Guid.NewGuid():N}"[..20],
Url = $"https://placehold.co/960x1200/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}",
Source = "emulated",
Orientation = "portrait",
Width = 960,
Height = 1200,
AltText = $"{keyword} - portrait",
Attribution = "Placeholder image"
}
};
return (images.Take(_config.ImageCount).ToList(), "emulated");
}
// ============================================================
// Unsplash Provider
// ============================================================
private async Task<(List<ImageAsset>, string)> GenerateUnsplashAsync(
UrlAnalysis analysis, CancellationToken ct)
{
var keyword = ExtractSearchKeyword(analysis);
_logger.LogInformation("[ImageGen] Unsplash search: '{Keyword}'", keyword);
var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(15);
// Unsplash supports unauthenticated requests at lower rate limits.
// With an access key you get 50 req/hour (free tier).
var hasKey = !string.IsNullOrWhiteSpace(_config.UnsplashAccessKey);
if (hasKey)
client.DefaultRequestHeaders.Add("Authorization", $"Client-ID {_config.UnsplashAccessKey}");
var images = new List<ImageAsset>();
var orientations = new[] { "landscape", "squarish", "portrait" };
foreach (var orientation in orientations.Take(_config.ImageCount))
{
try
{
var queryUrl = hasKey
? $"https://api.unsplash.com/search/photos?query={Uri.EscapeDataString(keyword)}&orientation={orientation}&per_page=1"
: $"https://api.unsplash.com/search/photos?query={Uri.EscapeDataString(keyword)}&orientation={orientation}&per_page=1";
var response = await client.GetAsync(queryUrl, ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("[ImageGen] Unsplash returned {Status} for orientation={Orientation}",
response.StatusCode, orientation);
// If unauthenticated and rate-limited, fall back to source.unsplash.com
images.Add(BuildUnsplashFallback(keyword, orientation));
continue;
}
var json = await response.Content.ReadAsStringAsync(ct);
var parsed = JsonDocument.Parse(json);
var results = parsed.RootElement.GetProperty("results");
if (results.GetArrayLength() == 0)
{
_logger.LogInformation("[ImageGen] No Unsplash results for '{Keyword}' {Orientation}",
keyword, orientation);
images.Add(BuildUnsplashFallback(keyword, orientation));
continue;
}
var photo = results[0];
var mappedOrientation = orientation == "squarish" ? "square" : orientation;
images.Add(new ImageAsset
{
ImageId = photo.GetProperty("id").GetString() ?? $"unsplash-{Guid.NewGuid():N}"[..16],
Url = photo.GetProperty("urls").GetProperty("regular").GetString() ?? "",
DownloadUrl = photo.GetProperty("urls").GetProperty("full").GetString(),
Source = "unsplash",
Orientation = mappedOrientation,
Width = photo.GetProperty("width").GetInt32(),
Height = photo.GetProperty("height").GetInt32(),
AltText = photo.GetProperty("alt_description").ValueKind == JsonValueKind.Null
? keyword
: photo.GetProperty("alt_description").GetString(),
Attribution = BuildUnsplashAttribution(photo)
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[ImageGen] Unsplash error for {Orientation}, using fallback", orientation);
images.Add(BuildUnsplashFallback(keyword, orientation));
}
}
_logger.LogInformation("[ImageGen] Unsplash returned {Count} images", images.Count);
return (images, "unsplash");
}
/// <summary>
/// Fallback using source.unsplash.com redirect (no API key needed, no rate limit).
/// Returns a random photo matching the keyword at the requested dimensions.
/// </summary>
private static ImageAsset BuildUnsplashFallback(string keyword, string orientation)
{
var (w, h, mapped) = orientation switch
{
"landscape" => (1200, 628, "landscape"),
"squarish" => (1200, 1200, "square"),
"portrait" => (960, 1200, "portrait"),
_ => (1200, 628, "landscape")
};
return new ImageAsset
{
ImageId = $"unsplash-fallback-{Guid.NewGuid():N}"[..24],
Url = $"https://source.unsplash.com/{w}x{h}/?{Uri.EscapeDataString(keyword)}",
Source = "unsplash",
Orientation = mapped,
Width = w,
Height = h,
AltText = keyword,
Attribution = "Photo from Unsplash"
};
}
private static string BuildUnsplashAttribution(JsonElement photo)
{
var userName = "Unknown";
var userLink = "";
if (photo.TryGetProperty("user", out var user))
{
userName = user.TryGetProperty("name", out var name)
? name.GetString() ?? "Unknown"
: "Unknown";
if (user.TryGetProperty("links", out var links) &&
links.TryGetProperty("html", out var html))
{
userLink = html.GetString() ?? "";
}
}
// Unsplash TOS requires photographer attribution
return string.IsNullOrEmpty(userLink)
? $"Photo by {userName} on Unsplash"
: $"Photo by {userName} on Unsplash ({userLink})";
}
// ============================================================
// DALL-E Provider (stubbed — ready for OpenAI key)
// ============================================================
private async Task<(List<ImageAsset>, string)> GenerateDalleAsync(
UrlAnalysis analysis, CancellationToken ct)
{
// Guard: DALL-E requires the OpenAI key
if (string.IsNullOrWhiteSpace(_config.OpenAiApiKey))
{
_logger.LogWarning("[ImageGen] DALL-E requested but no OpenAI key configured, falling back to emulated");
return EmulateGeneration(analysis);
}
var keyword = ExtractSearchKeyword(analysis);
var prompt = BuildDallePrompt(analysis, keyword);
_logger.LogInformation("[ImageGen] DALL-E generation: '{Prompt}'", prompt[..Math.Min(80, prompt.Length)]);
var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(_config.OpenAiTimeoutSeconds);
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {_config.OpenAiApiKey}");
var images = new List<ImageAsset>();
// DALL-E 3 supports: 1024x1024, 1792x1024 (landscape), 1024x1792 (portrait)
var dalleVariants = new[]
{
(size: "1792x1024", orientation: "landscape", w: 1792, h: 1024),
(size: "1024x1024", orientation: "square", w: 1024, h: 1024),
(size: "1024x1792", orientation: "portrait", w: 1024, h: 1792)
};
foreach (var variant in dalleVariants.Take(_config.ImageCount))
{
try
{
var requestBody = new
{
model = _config.DalleModel,
prompt = prompt,
n = 1,
size = variant.size,
quality = "standard",
response_format = "url"
};
var json = JsonSerializer.Serialize(requestBody);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await client.PostAsync(
"https://api.openai.com/v1/images/generations", content, ct);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("[ImageGen] DALL-E returned {Status}: {Body}",
response.StatusCode, errorBody);
// Fall back to emulated for this orientation
images.Add(BuildEmulatedFallback(keyword, variant.orientation,
variant.w, variant.h));
continue;
}
var responseJson = await response.Content.ReadAsStringAsync(ct);
var parsed = JsonDocument.Parse(responseJson);
var data = parsed.RootElement.GetProperty("data")[0];
var imageUrl = data.GetProperty("url").GetString() ?? "";
var revisedPrompt = data.TryGetProperty("revised_prompt", out var rp)
? rp.GetString() : null;
images.Add(new ImageAsset
{
ImageId = $"dalle-{variant.orientation}-{Guid.NewGuid():N}"[..24],
Url = imageUrl,
Source = "dalle",
Orientation = variant.orientation,
Width = variant.w,
Height = variant.h,
AltText = revisedPrompt ?? keyword,
Attribution = $"AI-generated image via {_config.DalleModel}"
});
_logger.LogInformation("[ImageGen] DALL-E generated {Orientation} image", variant.orientation);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[ImageGen] DALL-E error for {Orientation}, using fallback",
variant.orientation);
images.Add(BuildEmulatedFallback(keyword, variant.orientation,
variant.w, variant.h));
}
}
_logger.LogInformation("[ImageGen] DALL-E returned {Count} images", images.Count);
return (images, "dalle");
}
/// <summary>
/// Build a DALL-E prompt from the analysis. Aims for clean,
/// professional ad imagery — not artistic or abstract.
/// </summary>
private static string BuildDallePrompt(UrlAnalysis analysis, string keyword)
{
var sb = new StringBuilder();
sb.Append("Professional advertising photograph for a ");
sb.Append(analysis.InferredCategory ?? "business");
sb.Append(" business. ");
if (!string.IsNullOrWhiteSpace(analysis.Title))
{
var businessName = analysis.Title.Split('-', '|', '')[0].Trim();
sb.Append($"Business: {businessName}. ");
}
sb.Append($"Theme: {keyword}. ");
sb.Append("Clean, well-lit, commercial style. ");
sb.Append("No text or watermarks. Suitable for Google Ads display.");
return sb.ToString();
}
private static ImageAsset BuildEmulatedFallback(string keyword, string orientation, int w, int h)
{
return new ImageAsset
{
ImageId = $"fallback-{orientation}-{Guid.NewGuid():N}"[..24],
Url = $"https://placehold.co/{w}x{h}/0066cc/ffffff?text={Uri.EscapeDataString(keyword)}",
Source = "emulated",
Orientation = orientation,
Width = w,
Height = h,
AltText = $"{keyword} - {orientation}",
Attribution = "Placeholder image (provider fallback)"
};
}
// ============================================================
// Shared Helpers
// ============================================================
/// <summary>
/// Extract a concise search keyword from the analysis.
/// Uses category first, then title, then domain.
/// </summary>
private static string ExtractSearchKeyword(UrlAnalysis analysis)
{
// Prefer inferred category (e.g., "Pizza", "Soccer")
if (!string.IsNullOrWhiteSpace(analysis.InferredCategory))
return analysis.InferredCategory;
// Fall back to first meaningful heading
var heading = analysis.Headings?.FirstOrDefault(h => h.Length > 3 && h.Length < 40);
if (!string.IsNullOrWhiteSpace(heading))
return heading;
// Fall back to title (cleaned)
if (!string.IsNullOrWhiteSpace(analysis.Title))
{
var title = analysis.Title.Split('-', '|', '')[0].Trim();
return title.Length > 30 ? title[..30] : title;
}
// Last resort: domain name
try
{
var uri = new Uri(analysis.Url.StartsWith("http") ? analysis.Url : $"https://{analysis.Url}");
return uri.Host.Replace("www.", "").Split('.')[0];
}
catch
{
return "business";
}
}
}