diff --git a/pipes-and-filters/ImageProcessingPipeline/ImageProcessingPipeline.csproj b/pipes-and-filters/ImageProcessingPipeline/ImageProcessingPipeline.csproj index da9db8cd..02b4fbf3 100644 --- a/pipes-and-filters/ImageProcessingPipeline/ImageProcessingPipeline.csproj +++ b/pipes-and-filters/ImageProcessingPipeline/ImageProcessingPipeline.csproj @@ -5,7 +5,6 @@ Exe enable enable - true @@ -13,8 +12,8 @@ - - + + @@ -29,9 +28,8 @@ - - - Always - + + PreserveNewest + \ No newline at end of file diff --git a/pipes-and-filters/ImageProcessingPipeline/Program.cs b/pipes-and-filters/ImageProcessingPipeline/Program.cs index 997fc0b4..54590909 100644 --- a/pipes-and-filters/ImageProcessingPipeline/Program.cs +++ b/pipes-and-filters/ImageProcessingPipeline/Program.cs @@ -1,7 +1,5 @@ using Azure.Identity; using Microsoft.Extensions.Azure; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; var host = new HostBuilder() @@ -13,7 +11,6 @@ c.UseCredential(new DefaultAzureCredential()); c.AddBlobServiceClient(hostContext.Configuration.GetSection("output")).WithName("processed"); }); - services.AddSingleton(new ManifestEmbeddedFileProvider(typeof(Program).Assembly)); }) .Build(); diff --git a/pipes-and-filters/ImageProcessingPipeline/Resize.cs b/pipes-and-filters/ImageProcessingPipeline/Resize.cs index 54a67c2d..7d31262e 100644 --- a/pipes-and-filters/ImageProcessingPipeline/Resize.cs +++ b/pipes-and-filters/ImageProcessingPipeline/Resize.cs @@ -2,15 +2,14 @@ using Azure.Storage.Blobs.Specialized; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Processing; +using SkiaSharp; namespace ImageProcessingPipeline { - public class Resize(ILogger logger) { private readonly ILogger _logger = logger; + private const int MaxDimension = 600; [Function(nameof(Resize))] [QueueOutput("pipe-fjur", Connection = "pipe")] @@ -21,27 +20,61 @@ public async Task RunAsync( { _logger.LogInformation("Processing image {uri} for resizing.", imageBlob.Uri); - // Download image and resize it - using BlobDownloadStreamingResult imageBlobContents = await imageBlob.DownloadStreamingAsync(null, cancellationToken); - using var image = await Image.LoadAsync(imageBlobContents.Content, cancellationToken); - image.Mutate(i => + try { - i.Resize(new ResizeOptions + // Download image into memory (blob streams are not seekable for SkiaSharp) + using BlobDownloadStreamingResult imageBlobContents = await imageBlob.DownloadStreamingAsync(null, cancellationToken); + using var memoryStream = new MemoryStream(); + await imageBlobContents.Content.CopyToAsync(memoryStream, cancellationToken); + memoryStream.Position = 0; + + using var data = SKData.Create(memoryStream); + using var original = SKBitmap.Decode(data); + + if (original is null) + { + _logger.LogError("Failed to decode image {filePath}: decode returned null.", imageFilePath); + throw new InvalidOperationException($"Image decode failed: {imageFilePath}"); + } + + // Validate image decode succeeded + if (original.Width <= 0 || original.Height <= 0) + { + _logger.LogError("Failed to decode image {filePath}: invalid dimensions {width}x{height}", imageFilePath, original.Width, original.Height); + throw new InvalidOperationException($"Image decode failed or image is empty: {imageFilePath}"); + } + + // Calculate resize scale (constrain to MaxDimension, don't upscale) + float scale = Math.Min( + (float)MaxDimension / original.Width, + (float)MaxDimension / original.Height); + scale = Math.Min(scale, 1.0f); // Don't upscale + + int newWidth = (int)(original.Width * scale); + int newHeight = (int)(original.Height * scale); + + _logger.LogDebug("Resizing image from {originalWidth}x{originalHeight} to {newWidth}x{newHeight}", original.Width, original.Height, newWidth, newHeight); + + // Resize and encode + using var resized = original.Resize(new SKImageInfo(newWidth, newHeight), SKSamplingOptions.Default); + using var resizedImage = SKImage.FromBitmap(resized); + using var encoded = resizedImage.Encode(SKEncodedImageFormat.Png, 100); + + // Write back to blob storage + _logger.LogDebug("Writing resized image back to storage: {uri}.", imageBlob.Uri); + using (var blobStream = await imageBlob.OpenWriteAsync(overwrite: true, cancellationToken: cancellationToken)) { - Mode = ResizeMode.Max, - Size = new Size(600, 600) - }); - }); - - // Write modified image back to storage - _logger.LogDebug("Writing resized image back to storage: {uri}.", imageBlob.Uri); - using (var blobStream = await imageBlob.OpenWriteAsync(overwrite: true, null, cancellationToken)) + encoded.SaveTo(blobStream); + } + + _logger.LogInformation("Image resizing done. Adding image \"{filePath}\" into the next pipe.", imageFilePath); + return imageFilePath; + } + catch (Exception ex) { - await image.SaveAsync(blobStream, image.Metadata.DecodedImageFormat!, cancellationToken); + _logger.LogError(ex, "Error resizing image {filePath}: {message}", imageFilePath, ex.Message); + throw; // Let the queue retry mechanism handle retries } - - _logger.LogInformation("Image resizing done. Adding image \"{filePath}\" into the next pipe.", imageFilePath); - return imageFilePath; } } } diff --git a/pipes-and-filters/ImageProcessingPipeline/Watermark.cs b/pipes-and-filters/ImageProcessingPipeline/Watermark.cs index 8afbe2c0..d4cc97c9 100644 --- a/pipes-and-filters/ImageProcessingPipeline/Watermark.cs +++ b/pipes-and-filters/ImageProcessingPipeline/Watermark.cs @@ -1,17 +1,15 @@ using Azure.Storage.Blobs.Models; using Azure.Storage.Blobs.Specialized; using Microsoft.Azure.Functions.Worker; -using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Processing; +using SkiaSharp; namespace ImageProcessingPipeline { - public class Watermark(ILogger logger, IFileProvider files) + public class Watermark(ILogger logger) { private readonly ILogger _logger = logger; - private readonly IFileProvider _files = files; + private const byte WatermarkAlpha = 128; // 50% opacity [Function(nameof(Watermark))] [QueueOutput("pipe-yhrb", Connection = "pipe")] @@ -22,28 +20,82 @@ public async Task RunAsync( { _logger.LogInformation("Processing image {uri} for watermarking.", imageBlob.Uri); - // Download image and watermark it - using BlobDownloadStreamingResult imageBlobContents = await imageBlob.DownloadStreamingAsync(null, cancellationToken); - using var image = await Image.LoadAsync(imageBlobContents.Content, cancellationToken); + try + { + // Download image into memory (blob streams are not seekable for SkiaSharp) + using BlobDownloadStreamingResult imageBlobContents = await imageBlob.DownloadStreamingAsync(null, cancellationToken); + using var memoryStream = new MemoryStream(); + await imageBlobContents.Content.CopyToAsync(memoryStream, cancellationToken); + memoryStream.Position = 0; - var resources = _files.GetDirectoryContents("/"); - using var watermarkStream = resources.First(resource => resource.Name.Equals("resources/watermark.png")).CreateReadStream(); - using var watermarkImage = await Image.LoadAsync(watermarkStream, cancellationToken); + using var data = SKData.Create(memoryStream); + using var original = SKBitmap.Decode(data); - image.Mutate(i => - { - i.DrawImage(watermarkImage, new Point((image.Width - watermarkImage.Width) / 2, (image.Height - watermarkImage.Height) / 2), 0.5f); - }); + if (original is null) + { + _logger.LogError("Failed to decode image {filePath}: decode returned null.", imageFilePath); + throw new InvalidOperationException($"Image decode failed: {imageFilePath}"); + } + + // Validate image decode succeeded + if (original.Width <= 0 || original.Height <= 0) + { + _logger.LogError("Failed to decode image {filePath}: invalid dimensions {width}x{height}", imageFilePath, original.Width, original.Height); + throw new InvalidOperationException($"Image decode failed or image is empty: {imageFilePath}"); + } + + // Load watermark from resources directory + var watermarkPath = Path.Combine(AppContext.BaseDirectory, "resources", "watermark.png"); + if (!File.Exists(watermarkPath)) + { + _logger.LogError("Watermark file not found at {path}", watermarkPath); + throw new FileNotFoundException($"Watermark file not found: {watermarkPath}"); + } + + using var watermarkBitmap = SKBitmap.Decode(watermarkPath); + + if (watermarkBitmap is null) + { + _logger.LogError("Failed to decode watermark image at {path}: decode returned null.", watermarkPath); + throw new InvalidOperationException($"Watermark decode failed: {watermarkPath}"); + } - // Write modified image back to storage - _logger.LogDebug("Writing watermarked image back to storage: {uri}.", imageBlob.Uri); - using (var blobStream = await imageBlob.OpenWriteAsync(overwrite: true, null, cancellationToken)) + // Validate watermark fits in image + if (watermarkBitmap.Width > original.Width || watermarkBitmap.Height > original.Height) + { + _logger.LogWarning("Watermark {watermarkWidth}x{watermarkHeight} is larger than image {imageWidth}x{imageHeight}. Centering anyway.", + watermarkBitmap.Width, watermarkBitmap.Height, original.Width, original.Height); + } + + // Draw original image and overlay watermark at 50% opacity + using var surface = SKSurface.Create(new SKImageInfo(original.Width, original.Height)); + var canvas = surface.Canvas; + canvas.DrawBitmap(original, 0, 0); + + // Create watermark with transparency + int wmX = (original.Width - watermarkBitmap.Width) / 2; + int wmY = (original.Height - watermarkBitmap.Height) / 2; + using var wmPaint = new SKPaint { Color = SKColors.White.WithAlpha(WatermarkAlpha) }; + canvas.DrawBitmap(watermarkBitmap, wmX, wmY, wmPaint); + canvas.Flush(); + + // Encode and write back to storage + _logger.LogDebug("Writing watermarked image back to storage: {uri}.", imageBlob.Uri); + using var resultImage = surface.Snapshot(); + using var encoded = resultImage.Encode(SKEncodedImageFormat.Png, 100); + using (var blobStream = await imageBlob.OpenWriteAsync(overwrite: true, cancellationToken: cancellationToken)) + { + encoded.SaveTo(blobStream); + } + + _logger.LogInformation("Watermarking done. Adding image \"{filePath}\" into the next pipe.", imageFilePath); + return imageFilePath; + } + catch (Exception ex) { - await image.SaveAsync(blobStream, image.Metadata.DecodedImageFormat!, cancellationToken); + _logger.LogError(ex, "Error watermarking image {filePath}: {message}", imageFilePath, ex.Message); + throw; // Let the queue retry mechanism handle retries } - - _logger.LogInformation("Watermarking done. Adding image \"{filePath}\" into the next pipe.", imageFilePath); - return imageFilePath; } } } diff --git a/pipes-and-filters/bicep/main.bicep b/pipes-and-filters/bicep/main.bicep index b26e28b9..f06c365d 100644 --- a/pipes-and-filters/bicep/main.bicep +++ b/pipes-and-filters/bicep/main.bicep @@ -29,7 +29,7 @@ resource storageQueueDataContributorRole 'Microsoft.Authorization/roleDefinition /*** NEW RESOURCES ***/ @description('The Azure Storage account which will contain the pipes (queues) and the images to be sent through the filters (Azure Functions).') -resource storageAccount 'Microsoft.Storage/storageAccounts@2025-06-01' = { +resource storageAccount 'Microsoft.Storage/storageAccounts@2026-04-01' = { name: storageAccountName location: location sku: {