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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ ImageFactory.OpenAsync(bytes)

// Base64
ImageFactory.OpenBase64Async(base64String)

// URL (download is deferred until the terminal call)
ImageFactory.OpenAsync(new Uri("https://example.com/photo.jpg"))
```

### Resize
Expand Down
23 changes: 23 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,29 @@ var image = await ImageFactory
Console.WriteLine($"Saved: {image.Width}×{image.Height}");
```

## Loading from a URL

`OpenAsync` also accepts a `Uri`. The HTTP download happens lazily, at the same moment the rest of the pipeline executes (when you call a terminal method).

```csharp
var image = await ImageFactory
.OpenAsync(new Uri("https://example.com/photo.jpg"))
.Resize(800, 600)
.ToJpeg(85)
.SaveAsync("output.jpg");
```

Pass a `CancellationToken` to any terminal method to cancel both the download and the processing:

```csharp
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));

var bytes = await ImageFactory
.OpenAsync(new Uri("https://example.com/photo.jpg"))
.Grayscale()
.ToBytesAsync(cts.Token);
```

## Understanding the pipeline

`ImageFactory.OpenAsync(...)` returns an `IImageBuilder`. Each method call on the
Expand Down
44 changes: 44 additions & 0 deletions samples/VisionSharp.BlazorDemo/Components/Pages/Home.razor
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@
Replace image
<InputFile OnChange="HandleFileUploadAsync" accept="image/*" class="file-input-hidden" />
</label>
<div class="url-row">
<input class="text-input url-input" type="url" placeholder="https://…"
@bind="_urlInput" @bind:event="oninput" />
<button class="btn-ghost" @onclick="LoadFromUrlAsync" disabled="@_urlLoading">
@(_urlLoading ? "Loading…" : "Load URL")
</button>
</div>
@if (_urlError is not null)
{
<p class="url-error">@_urlError</p>
}
</div>
</div>

Expand Down Expand Up @@ -293,6 +304,10 @@

private static readonly string[] OutputFormats = ["jpeg", "png", "webp"];

private string _urlInput = string.Empty;
private bool _urlLoading;
private string? _urlError;

private CancellationTokenSource? _cts;
private Timer? _debounce;

Expand Down Expand Up @@ -335,6 +350,35 @@
await ProcessAsync();
}

// ── URL load ─────────────────────────────────────────────────────────────
private async Task LoadFromUrlAsync()
{
_urlError = null;
if (!Uri.TryCreate(_urlInput, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
_urlError = "Please enter a valid HTTP or HTTPS URL.";
return;
}

_urlLoading = true;
StateHasChanged();
try
{
_originalBytes = await ImageFactory.OpenAsync(uri).ToBytesAsync();
await ProcessAsync();
}
catch (Exception ex)
{
_urlError = $"Could not load image: {ex.Message}";
}
finally
{
_urlLoading = false;
StateHasChanged();
}
}

// ── Slider / text handlers (debounced) ───────────────────────────────────
private void OnSlider(ChangeEventArgs e, Action<int> set)
{
Expand Down
23 changes: 22 additions & 1 deletion samples/VisionSharp.BlazorDemo/wwwroot/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,28 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil
padding: .5rem .9rem;
background: var(--surface2);
display: flex;
justify-content: flex-end;
flex-direction: column;
align-items: flex-end;
gap: .4rem;
}

.url-row {
display: flex;
gap: .4rem;
align-items: center;
width: 100%;
}

.url-input {
flex: 1;
min-width: 0;
}

.url-error {
font-size: .75rem;
color: #e05c5c;
margin: 0;
width: 100%;
}

/* ── Controls grid ───────────────────────────────────────────────────────── */
Expand Down
25 changes: 23 additions & 2 deletions samples/VisionSharp.ConsoleDemo/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
continue;
}

if (int.TryParse(input, out int choice) && choice >= 1 && choice <= 15)
if (int.TryParse(input, out int choice) && choice >= 1 && choice <= 16)
await RunDemoAsync(choice, imagePath);
else
WriteError("Invalid option. Type a number from the menu, 0 for all, i to change image, or q to quit.");
Expand Down Expand Up @@ -96,6 +96,7 @@ static void PrintMenu()
Console.WriteLine(" 13 Stream I/O");
Console.WriteLine(" 14 Vintage preset");
Console.WriteLine(" 15 Black & White preset");
Console.WriteLine(" 16 Load from URL");
Console.ForegroundColor = ConsoleColor.DarkCyan;
Console.WriteLine(" ─────────────────────────────────────────────");
Console.ResetColor();
Expand All @@ -122,7 +123,7 @@ static async Task RunAllDemosAsync(string imagePath)
Console.ResetColor();

var sw = Stopwatch.StartNew();
for (int i = 1; i <= 15; i++)
for (int i = 1; i <= 16; i++)
await RunDemoAsync(i, imagePath);

sw.Stop();
Expand Down Expand Up @@ -262,6 +263,17 @@ await ImageFactory.OpenAsync(imagePath)
await ImageFactory.OpenAsync(imagePath).BlackAndWhite().SaveAsync("output/demo_bw.jpg");
WriteOk("output/demo_bw.jpg", sw);
break;

case 16:
string url = AskUrl(" Image URL (Enter for demo URL): ", "https://picsum.photos/800/600");
Console.Write($" [16] Load from URL → Resize → Save … ");
await ImageFactory
.OpenAsync(new Uri(url))
.Resize(800, 600)
.ToJpeg(90)
.SaveAsync("output/demo_from_url.jpg");
WriteOk("output/demo_from_url.jpg", sw);
break;
}
}
catch (Exception ex)
Expand Down Expand Up @@ -296,6 +308,15 @@ static int AskInt(string prompt, int defaultValue, int min, int max)
return defaultValue;
}

static string AskUrl(string prompt, string defaultValue)
{
Console.WriteLine();
Console.Write($" {prompt}");
string? raw = Console.ReadLine()?.Trim();
if (!string.IsNullOrEmpty(raw) && Uri.TryCreate(raw, UriKind.Absolute, out _)) return raw;
return defaultValue;
}

static float AskFloat(string prompt, float defaultValue)
{
Console.WriteLine();
Expand Down
3 changes: 3 additions & 0 deletions src/VisionSharp.Abstractions/Interfaces/IImageFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ public interface IImageFactory

/// <summary>Opens an image from a Base64-encoded string.</summary>
IImageBuilder OpenBase64Async(string base64);

/// <summary>Opens an image from a URL. The download is deferred to the terminal call.</summary>
IImageBuilder OpenAsync(Uri url);
}
12 changes: 12 additions & 0 deletions src/VisionSharp/DependencyInjection/ServicedImageFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ namespace VisionSharp.DependencyInjection;
/// </summary>
internal sealed class ServicedImageFactory : IImageFactory
{
private static readonly HttpClient _httpClient = new();
private readonly Func<IImageEngine> _engineFactory;

public ServicedImageFactory(Func<IImageEngine> engineFactory)
Expand All @@ -31,4 +32,15 @@ public IImageBuilder OpenAsync(byte[] bytes)
/// <inheritdoc/>
public IImageBuilder OpenBase64Async(string base64)
=> new ImageBuilder(_engineFactory, (e, ct) => e.LoadFromBase64Async(base64, ct));

/// <inheritdoc/>
public IImageBuilder OpenAsync(Uri url)
{
ArgumentNullException.ThrowIfNull(url);
return new ImageBuilder(_engineFactory, async (e, ct) =>
{
var bytes = await _httpClient.GetByteArrayAsync(url, ct);
await e.LoadFromBytesAsync(bytes, ct);
});
}
}
12 changes: 12 additions & 0 deletions src/VisionSharp/ImageFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ namespace VisionSharp;
public static class ImageFactory
{
private static Func<IImageEngine> _engineFactory = () => new ImageSharpEngine();
private static readonly HttpClient _httpClient = new();

/// <summary>
/// Overrides the engine factory used to create engine instances.
Expand Down Expand Up @@ -57,4 +58,15 @@ public static IImageBuilder OpenBase64Async(string base64)
ArgumentException.ThrowIfNullOrWhiteSpace(base64);
return new ImageBuilder(_engineFactory, (engine, ct) => engine.LoadFromBase64Async(base64, ct));
}

/// <summary>Opens an image from a URL. The download is deferred to the terminal call.</summary>
public static IImageBuilder OpenAsync(Uri url)
{
ArgumentNullException.ThrowIfNull(url);
return new ImageBuilder(_engineFactory, async (engine, ct) =>
{
var bytes = await _httpClient.GetByteArrayAsync(url, ct);
await engine.LoadFromBytesAsync(bytes, ct);
});
}
}
37 changes: 37 additions & 0 deletions tests/VisionSharp.UnitTests/ImageFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,41 @@ public void OpenAsync_ReturnsSameTypeAsImageBuilder()

builder.Should().BeOfType<ImageBuilder>();
}

[Fact]
public void OpenAsync_WithUri_ReturnsImageBuilder()
{
var uri = new Uri("https://example.com/photo.jpg");
var builder = ImageFactory.OpenAsync(uri);

builder.Should().NotBeNull();
builder.Should().BeAssignableTo<IImageBuilder>();
}

[Fact]
public void OpenAsync_WithUri_ReturnsSameTypeAsImageBuilder()
{
var uri = new Uri("https://example.com/photo.jpg");
var builder = ImageFactory.OpenAsync(uri);

builder.Should().BeOfType<ImageBuilder>();
}

[Fact]
public void OpenAsync_NullUri_ThrowsArgumentNullException()
{
var act = () => ImageFactory.OpenAsync((Uri)null!);

act.Should().Throw<ArgumentNullException>();
}

[Fact]
public void OpenAsync_WithRelativeUri_ReturnsImageBuilder()
{
var uri = new Uri("/images/photo.jpg", UriKind.Relative);
var builder = ImageFactory.OpenAsync(uri);

builder.Should().NotBeNull();
builder.Should().BeAssignableTo<IImageBuilder>();
}
}
Loading