415 lines
16 KiB
C#
415 lines
16 KiB
C#
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";
|
||
}
|
||
}
|
||
}
|