Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,15 @@
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.52.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs" Version="6.8.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.4" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<PackageReference Include="Microsoft.Extensions.Azure" Version="1.14.0" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.9" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="SkiaSharp" Version="3.119.4" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.4" />
</ItemGroup>
<ItemGroup>
<None Update="host.json">
Expand All @@ -29,9 +28,8 @@
<Using Include="System.Threading.ExecutionContext" Alias="ExecutionContext" />
</ItemGroup>
<ItemGroup>
<Content Remove="resources/watermark.png" />
<EmbeddedResource Include="resources/watermark.png">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</EmbeddedResource>
<Content Include="resources/watermark.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
3 changes: 0 additions & 3 deletions pipes-and-filters/ImageProcessingPipeline/Program.cs
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -13,7 +11,6 @@
c.UseCredential(new DefaultAzureCredential());
c.AddBlobServiceClient(hostContext.Configuration.GetSection("output")).WithName("processed");
});
services.AddSingleton<IFileProvider>(new ManifestEmbeddedFileProvider(typeof(Program).Assembly));
})
.Build();

Expand Down
73 changes: 53 additions & 20 deletions pipes-and-filters/ImageProcessingPipeline/Resize.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Resize> logger)
{
private readonly ILogger<Resize> _logger = logger;
private const int MaxDimension = 600;

[Function(nameof(Resize))]
[QueueOutput("pipe-fjur", Connection = "pipe")]
Expand All @@ -21,27 +20,61 @@ public async Task<string> 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;
}
}
}
96 changes: 74 additions & 22 deletions pipes-and-filters/ImageProcessingPipeline/Watermark.cs
Original file line number Diff line number Diff line change
@@ -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<Watermark> logger, IFileProvider files)
public class Watermark(ILogger<Watermark> logger)
{
private readonly ILogger<Watermark> _logger = logger;
private readonly IFileProvider _files = files;
private const byte WatermarkAlpha = 128; // 50% opacity

[Function(nameof(Watermark))]
[QueueOutput("pipe-yhrb", Connection = "pipe")]
Expand All @@ -22,28 +20,82 @@ public async Task<string> 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}");
}
Comment thread
ckittel marked this conversation as resolved.

// 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);
Comment thread
v-federicoar marked this conversation as resolved.

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;
}
}
}
2 changes: 1 addition & 1 deletion pipes-and-filters/bicep/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down