using System.Text; using System.Text.Json; using Creative.Configuration; using Creative.Models; using Microsoft.Extensions.Options; namespace Creative.Services; /// /// 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) /// public class ImageGeneratorService { private readonly CreativeConfig _config; private readonly IHttpClientFactory _httpFactory; private readonly ILogger _logger; public ImageGeneratorService( IOptions config, IHttpClientFactory httpFactory, ILogger logger) { _config = config.Value; _httpFactory = httpFactory; _logger = logger; } /// /// Get images matching the analyzed content. /// Returns a list of ImageAssets and the provider name used. /// public async Task<(List 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, string) EmulateGeneration(UrlAnalysis analysis) { _logger.LogInformation("[ImageGen] Emulated images for {Url}", analysis.Url); var keyword = ExtractSearchKeyword(analysis); var images = new List { 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, 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(); 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"); } /// /// Fallback using source.unsplash.com redirect (no API key needed, no rate limit). /// Returns a random photo matching the keyword at the requested dimensions. /// 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, 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(); // 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"); } /// /// Build a DALL-E prompt from the analysis. Aims for clean, /// professional ad imagery — not artistic or abstract. /// 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 // ============================================================ /// /// Extract a concise search keyword from the analysis. /// Uses category first, then title, then domain. /// 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"; } } }