[backend/drive] Make image processor pluggable
All checks were successful
/ test-build (push) Successful in 30s
/ unit-tests (push) Successful in 29s
/ build-and-push (push) Successful in 2m2s

This commit is contained in:
Laura Hausmann 2024-05-01 20:56:17 +02:00
parent 03191bfa91
commit a2077244f8
Signed by: zotan
GPG key ID: D044E84C5BE01605
9 changed files with 362 additions and 137 deletions

View file

@ -125,6 +125,8 @@ public sealed class Config
}
}
public bool EnableLibVips { get; init; }
public LocalStorageSection? Local { get; init; }
public ObjectStorageSection? ObjectStorage { get; init; }
}

View file

@ -88,7 +88,8 @@ public static class ServiceExtensions
.AddSingleton<RequestVerificationMiddleware>()
.AddSingleton<RequestDurationMiddleware>()
.AddSingleton<PushService>()
.AddSingleton<StreamingService>();
.AddSingleton<StreamingService>()
.AddSingleton<ImageProcessor>();
// Hosted services = long running background tasks
// Note: These need to be added as a singleton as well to ensure data consistency

View file

@ -7,7 +7,6 @@ using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.Options;
using SixLabors.ImageSharp.Memory;
using WebPush;
namespace Iceshrimp.Backend.Core.Extensions;
@ -193,18 +192,11 @@ public static class WebApplicationExtensions
app.Logger.LogInformation("Warming up meta cache...");
await meta.WarmupCache();
SixLabors.ImageSharp.Configuration.Default.MemoryAllocator =
MemoryAllocator.Create(new MemoryAllocatorOptions { AllocationLimitMegabytes = 20 });
var logger = app.Services.GetRequiredService<ILogger<DriveService>>();
NetVips.Log.SetLogHandler("VIPS", NetVips.Enums.LogLevelFlags.Warning | NetVips.Enums.LogLevelFlags.Error,
VipsLogDelegate);
// Initialize image processing
provider.GetRequiredService<ImageProcessor>();
return instanceConfig;
void VipsLogDelegate(string domain, NetVips.Enums.LogLevelFlags _, string message) =>
logger.LogWarning("libvips: {domain} - {message}", domain, message);
}
public static void SetKestrelUnixSocketPermissions(this WebApplication app)

View file

@ -1,7 +1,7 @@
using System.Security.Cryptography;
using System.Text;
namespace Iceshrimp.Backend.Core.Federation.Cryptography;
namespace Iceshrimp.Backend.Core.Helpers;
public static class DigestHelpers
{

View file

@ -1,16 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using Blurhash;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Federation.Cryptography;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Queues;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using ImageSharp = SixLabors.ImageSharp.Image;
namespace Iceshrimp.Backend.Core.Services;
@ -22,7 +19,8 @@ public class DriveService(
IOptions<Config.InstanceSection> instanceConfig,
HttpClient httpClient,
QueueService queueSvc,
ILogger<DriveService> logger
ILogger<DriveService> logger,
ImageProcessor imageProcessor
)
{
public async Task<DriveFile?> StoreFile(
@ -106,10 +104,10 @@ public class DriveService(
}
}
public async Task<DriveFile> StoreFile(Stream data, User user, DriveFileCreationRequest request)
public async Task<DriveFile> StoreFile(Stream input, User user, DriveFileCreationRequest request)
{
await using var buf = new BufferedStream(data);
var digest = await DigestHelpers.Sha256DigestAsync(buf);
await using var data = new BufferedStream(input);
var digest = await DigestHelpers.Sha256DigestAsync(data);
logger.LogDebug("Storing file {digest} for user {userId}", digest, user.Id);
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Sha256 == digest);
if (file is { IsLink: false })
@ -131,7 +129,7 @@ public class DriveService(
return clonedFile;
}
buf.Seek(0, SeekOrigin.Begin);
data.Seek(0, SeekOrigin.Begin);
var shouldStore = storageConfig.Value.MediaRetention != null || user.Host == null;
var storedInternal = storageConfig.Value.Provider == Enums.FileStorage.Local;
@ -139,106 +137,30 @@ public class DriveService(
if (request.Uri == null && user.Host != null)
throw GracefulException.UnprocessableEntity("Refusing to store file without uri for remote user");
string? blurhash = null;
Stream? thumbnail = null;
Stream? webpublic = null;
string? blurhash = null;
DriveFile.FileProperties? properties = null;
var isImage = request.MimeType.StartsWith("image/") || request.MimeType == "image";
// skip images larger than 10MB
var isReasonableSize = buf.Length < 10 * 1024 * 1024;
if (isImage && isReasonableSize)
{
try
{
var pre = DateTime.Now;
var ident = await ImageSharp.IdentifyAsync(buf);
var isAnimated = ident.FrameMetadataCollection.Count != 0;
properties = new DriveFile.FileProperties { Width = ident.Size.Width, Height = ident.Size.Height };
// Correct mime type
if (request.MimeType == "image" && ident.Metadata.DecodedImageFormat?.DefaultMimeType != null)
request.MimeType = ident.Metadata.DecodedImageFormat.DefaultMimeType;
buf.Seek(0, SeekOrigin.Begin);
using var image = NetVips.Image.NewFromStream(buf);
using var processed = image.Autorot();
buf.Seek(0, SeekOrigin.Begin);
try
{
// Calculate blurhash using a x200px image for improved performance
using var blurhashImage = processed.ThumbnailImage(200, 200, NetVips.Enums.Size.Down);
var blurBuf = blurhashImage.WriteToMemory();
var blurArr = new Pixel[blurhashImage.Width, blurhashImage.Height];
var idx = 0;
var incr = image.Bands - 3;
for (var i = 0; i < blurhashImage.Height; i++)
{
for (var j = 0; j < blurhashImage.Width; j++)
{
blurArr[j, i] = new Pixel(blurBuf[idx++] / 255d, blurBuf[idx++] / 255d,
blurBuf[idx++] / 255d);
idx += incr;
}
}
blurhash = Blurhash.Core.Encode(blurArr, 7, 7, new Progress<int>());
}
catch (Exception e)
{
logger.LogWarning("Failed to generate blurhash for image with mime type {type}: {e}",
request.MimeType, e.Message);
}
if (shouldStore)
{
// Generate thumbnail
using var thumbnailImage = processed.ThumbnailImage(1000, 1000, NetVips.Enums.Size.Down);
thumbnail = new MemoryStream();
thumbnailImage.WebpsaveStream(thumbnail, 75, false);
thumbnail.Seek(0, SeekOrigin.Begin);
// Generate webpublic for local users, if image is not animated
if (user.Host == null && !isAnimated)
{
using var webpublicImage = processed.ThumbnailImage(2048, 2048, NetVips.Enums.Size.Down);
webpublic = new MemoryStream();
webpublicImage.WebpsaveStream(webpublic, request.MimeType == "image/png" ? 100 : 75, false);
webpublic.Seek(0, SeekOrigin.Begin);
}
}
logger.LogTrace("Image processing took {ms} ms", (int)(DateTime.Now - pre).TotalMilliseconds);
}
catch (Exception e)
{
logger.LogError("Failed to generate thumbnails for image with mime type {type}: {e}",
request.MimeType, e.Message);
// We want to make sure no images are federated out without stripping metadata & converting to webp
if (user.Host == null) throw;
}
buf.Seek(0, SeekOrigin.Begin);
}
string url;
string? thumbnailUrl = null;
string? webpublicUrl = null;
var filename = GenerateFilenameKeepingExtension(request.Filename);
var thumbnailFilename = thumbnail != null ? GenerateWebpFilename("thumbnail-") : null;
var webpublicFilename = webpublic != null ? GenerateWebpFilename("webpublic-") : null;
var isReasonableSize = data.Length < 10 * 1024 * 1024; // skip images larger than 10MB
var isImage = request.MimeType.StartsWith("image/") || request.MimeType == "image";
var filename = GenerateFilenameKeepingExtension(request.Filename);
if (shouldStore)
string? thumbnailFilename = null;
string? webpublicFilename = null;
if (shouldStore && isImage && isReasonableSize)
{
var genWebp = user.Host == null;
var res = await imageProcessor.ProcessImage(data, request, shouldStore, genWebp);
blurhash = res?.Blurhash;
thumbnailFilename = res?.RenderThumbnail != null ? GenerateWebpFilename("thumbnail-") : null;
webpublicFilename = res?.RenderThumbnail != null ? GenerateWebpFilename("webpublic-") : null;
if (storedInternal)
{
var pathBase = storageConfig.Value.Local?.Path ??
@ -246,44 +168,75 @@ public class DriveService(
var path = Path.Combine(pathBase, filename);
await using var writer = File.OpenWrite(path);
await buf.CopyToAsync(writer);
await data.CopyToAsync(writer);
url = $"https://{instanceConfig.Value.WebDomain}/files/{filename}";
if (thumbnailFilename != null && thumbnail is { Length: > 0 })
if (thumbnailFilename != null && res?.RenderThumbnail != null)
{
var thumbPath = Path.Combine(pathBase, thumbnailFilename);
await using var thumbWriter = File.OpenWrite(thumbPath);
await thumbnail.CopyToAsync(thumbWriter);
await thumbnail.DisposeAsync();
thumbnailUrl = $"https://{instanceConfig.Value.WebDomain}/files/{thumbnailFilename}";
try
{
await res.RenderThumbnail(thumbWriter);
thumbnailUrl = $"https://{instanceConfig.Value.WebDomain}/files/{thumbnailFilename}";
}
catch (Exception e)
{
logger.LogDebug("Failed to generate/write thumbnail: {e}", e.Message);
}
}
if (webpublicFilename != null && webpublic is { Length: > 0 })
if (webpublicFilename != null && res?.RenderWebpublic != null)
{
var webpPath = Path.Combine(pathBase, webpublicFilename);
await using var webpWriter = File.OpenWrite(webpPath);
await webpublic.CopyToAsync(webpWriter);
await webpublic.DisposeAsync();
webpublicUrl = $"https://{instanceConfig.Value.WebDomain}/files/{webpublicFilename}";
try
{
await res.RenderWebpublic(webpWriter);
webpublicUrl = $"https://{instanceConfig.Value.WebDomain}/files/{webpublicFilename}";
}
catch (Exception e)
{
logger.LogDebug("Failed to generate/write webp: {e}", e.Message);
}
}
}
else
{
await storageSvc.UploadFileAsync(filename, buf);
data.Seek(0, SeekOrigin.Begin);
await storageSvc.UploadFileAsync(filename, data);
url = storageSvc.GetFilePublicUrl(filename).AbsoluteUri;
if (thumbnailFilename != null && thumbnail is { Length: > 0 })
if (thumbnailFilename != null && res?.RenderThumbnail != null)
{
await storageSvc.UploadFileAsync(thumbnailFilename, thumbnail);
thumbnailUrl = storageSvc.GetFilePublicUrl(thumbnailFilename).AbsoluteUri;
await thumbnail.DisposeAsync();
try
{
await using var stream = new MemoryStream();
await res.RenderThumbnail(stream);
stream.Seek(0, SeekOrigin.Begin);
await storageSvc.UploadFileAsync(thumbnailFilename, stream);
thumbnailUrl = storageSvc.GetFilePublicUrl(thumbnailFilename).AbsoluteUri;
}
catch (Exception e)
{
logger.LogDebug("Failed to generate/write thumbnail: {e}", e.Message);
}
}
if (webpublicFilename != null && webpublic is { Length: > 0 })
if (webpublicFilename != null && res?.RenderWebpublic != null)
{
await storageSvc.UploadFileAsync(webpublicFilename, webpublic);
webpublicUrl = storageSvc.GetFilePublicUrl(webpublicFilename).AbsoluteUri;
await webpublic.DisposeAsync();
try
{
await using var stream = new MemoryStream();
await res.RenderWebpublic(stream);
stream.Seek(0, SeekOrigin.Begin);
await storageSvc.UploadFileAsync(webpublicFilename, stream);
webpublicUrl = storageSvc.GetFilePublicUrl(webpublicFilename).AbsoluteUri;
}
catch (Exception e)
{
logger.LogDebug("Failed to generate/write thumbnail: {e}", e.Message);
}
}
}
}
@ -299,7 +252,7 @@ public class DriveService(
User = user,
UserHost = user.Host,
Sha256 = digest,
Size = (int)buf.Length,
Size = (int)data.Length,
IsLink = !shouldStore && user.Host != null,
AccessKey = filename,
IsSensitive = request.IsSensitive,

View file

@ -0,0 +1,264 @@
using Blurhash.ImageSharp;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database.Tables;
using Microsoft.Extensions.Options;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using ImageSharp = SixLabors.ImageSharp.Image;
#if EnableLibVips
using Blurhash;
#endif
namespace Iceshrimp.Backend.Core.Services;
public class ImageProcessor
{
private readonly ILogger<ImageProcessor> _logger;
#if EnableLibVips
private readonly IOptions<Config.StorageSection> _config;
#endif
public ImageProcessor(ILogger<ImageProcessor> logger, IOptions<Config.StorageSection> config)
{
_logger = logger;
SixLabors.ImageSharp.Configuration.Default.MemoryAllocator =
MemoryAllocator.Create(new MemoryAllocatorOptions { AllocationLimitMegabytes = 20 });
#if EnableLibVips
_config = config;
if (!_config.Value.EnableLibVips)
{
_logger.LogInformation("VIPS support was enabled at compile time, but is not enabled in the configuration, skipping VIPS init");
_logger.LogInformation("Using ImageSharp for image processing.");
return;
}
//TODO: Implement something similar to https://github.com/lovell/sharp/blob/da655a1859744deec9f558effa5c9981ef5fd6d3/lib/utility.js#L153C5-L158
NetVips.NetVips.Concurrency = 1;
// We want to know when we have a memory leak
NetVips.NetVips.Leak = true;
// We don't need the VIPS operation or file cache
NetVips.Cache.Max = 0;
NetVips.Cache.MaxFiles = 0;
NetVips.Cache.MaxMem = 0;
NetVips.Log.SetLogHandler("VIPS", NetVips.Enums.LogLevelFlags.Warning | NetVips.Enums.LogLevelFlags.Error,
VipsLogDelegate);
_logger.LogInformation("Using VIPS for image processing.");
#else
if (config.Value.EnableLibVips)
{
_logger.LogWarning("VIPS support was disabled at compile time, but EnableLibVips is set in the configuration. Either compile with -p:EnableLibVips=true, or disable EnableLibVips in the configuration.");
}
else
{
_logger.LogDebug("VIPS support was disabled at compile time, skipping VIPS init");
}
_logger.LogInformation("Using ImageSharp for image processing.");
#endif
}
public class Result
{
public string? Blurhash;
public Func<Stream, Task>? RenderThumbnail;
public Func<Stream, Task>? RenderWebpublic;
public required DriveFile.FileProperties Properties;
}
public async Task<Result?> ProcessImage(Stream data, DriveFileCreationRequest request, bool genThumb, bool genWebp)
{
try
{
var pre = DateTime.Now;
var ident = await ImageSharp.IdentifyAsync(data);
data.Seek(0, SeekOrigin.Begin);
Result? res = null;
// Correct mime type
if (request.MimeType == "image" && ident.Metadata.DecodedImageFormat?.DefaultMimeType != null)
request.MimeType = ident.Metadata.DecodedImageFormat.DefaultMimeType;
// Don't generate thumb/webp for animated images
if (ident.FrameMetadataCollection.Count != 0)
{
genThumb = false;
genWebp = false;
}
if (ident.Width * ident.Height > 30000000)
{
_logger.LogDebug("Image is larger than 30mpx ({width}x{height}), bypassing image processing pipeline",
ident.Width, ident.Height);
var props = new DriveFile.FileProperties { Width = ident.Size.Width, Height = ident.Size.Height };
return new Result { Properties = props };
}
#if EnableLibVips
if (_config.Value.EnableLibVips)
{
try
{
byte[] buf;
await using (var memoryStream = new MemoryStream())
{
await data.CopyToAsync(memoryStream);
buf = memoryStream.ToArray();
}
res = await ProcessImageVips(buf, ident, request, genThumb, genWebp);
}
catch (Exception e)
{
_logger.LogWarning("Failed to process image of type {type} with VIPS, falling back to ImageSharp: {e}",
request.MimeType, e.Message);
}
}
#endif
try
{
res ??= await ProcessImageSharp(data, ident, request, genThumb, genWebp);
}
catch (Exception e)
{
_logger.LogWarning("Failed to process image of type {type} with ImageSharp: {e}",
request.MimeType, e.Message);
var props = new DriveFile.FileProperties { Width = ident.Size.Width, Height = ident.Size.Height };
return new Result { Properties = props };
}
_logger.LogTrace("Image processing took {ms} ms", (int)(DateTime.Now - pre).TotalMilliseconds);
return res;
}
catch (Exception e)
{
_logger.LogError("Failed to process image with mime type {type}: {e}",
request.MimeType, e.Message);
return null;
}
}
private static async Task<Result> ProcessImageSharp(
Stream data, ImageInfo ident, DriveFileCreationRequest request, bool genThumb, bool genWebp
)
{
var properties = new DriveFile.FileProperties { Width = ident.Size.Width, Height = ident.Size.Height };
var res = new Result { Properties = properties };
// Calculate blurhash using a x200px image for improved performance
{
using var image = await GetImage(data, ident, 200);
res.Blurhash = Blurhasher.Encode(image, 7, 7);
}
if (genThumb)
{
res.RenderThumbnail = async stream =>
{
using var image = await GetImage(data, ident, 1000);
var thumbEncoder = new WebpEncoder { Quality = 75, FileFormat = WebpFileFormatType.Lossy };
await image.SaveAsWebpAsync(stream, thumbEncoder);
};
}
if (genWebp)
{
res.RenderWebpublic = async stream =>
{
using var image = await GetImage(data, ident, 2048);
var q = request.MimeType == "image/png" ? 100 : 75;
var thumbEncoder = new WebpEncoder { Quality = q, FileFormat = WebpFileFormatType.Lossy };
await image.SaveAsWebpAsync(stream, thumbEncoder);
};
}
return res;
}
private static async Task<Image<Rgba32>> GetImage(Stream data, ImageInfo ident, int width, int? height = null)
{
width = Math.Min(ident.Width, width);
height = Math.Min(ident.Height, height ?? width);
var size = new Size(width, height.Value);
var options = new DecoderOptions { MaxFrames = 1, TargetSize = size };
data.Seek(0, SeekOrigin.Begin);
var image = await ImageSharp.LoadAsync<Rgba32>(options, data);
image.Mutate(x => x.AutoOrient());
var opts = new ResizeOptions { Size = size, Mode = ResizeMode.Max };
image.Mutate(p => p.Resize(opts));
return image;
}
#if EnableLibVips
private Task<Result> ProcessImageVips(
byte[] buf, ImageInfo ident, DriveFileCreationRequest request, bool genThumb, bool genWebp
)
{
var properties = new DriveFile.FileProperties { Width = ident.Size.Width, Height = ident.Size.Height };
var res = new Result { Properties = properties };
// Calculate blurhash using a x200px image for improved performance
using var blurhashImage =
NetVips.Image.ThumbnailBuffer(buf, width: 200, height: 200, size: NetVips.Enums.Size.Down);
var blurBuf = blurhashImage.WriteToMemory();
var blurArr = new Pixel[blurhashImage.Width, blurhashImage.Height];
var idx = 0;
var incr = blurhashImage.Bands - 3;
for (var i = 0; i < blurhashImage.Height; i++)
{
for (var j = 0; j < blurhashImage.Width; j++)
{
blurArr[j, i] = new Pixel(blurBuf[idx++] / 255d, blurBuf[idx++] / 255d,
blurBuf[idx++] / 255d);
idx += incr;
}
}
res.Blurhash = Blurhash.Core.Encode(blurArr, 7, 7, new Progress<int>());
if (genThumb)
{
res.RenderThumbnail = stream =>
{
using var thumbnailImage =
NetVips.Image.ThumbnailBuffer(buf, width: 1000, height: 1000, size: NetVips.Enums.Size.Down);
thumbnailImage.WebpsaveStream(stream, 75, false);
return Task.CompletedTask;
};
// Generate webpublic for local users, if image is not animated
if (genWebp)
{
res.RenderWebpublic = stream =>
{
using var webpublicImage =
NetVips.Image.ThumbnailBuffer(buf, width: 2048, height: 2048,
size: NetVips.Enums.Size.Down);
webpublicImage.WebpsaveStream(stream, request.MimeType == "image/png" ? 100 : 75, false);
return Task.CompletedTask;
};
}
}
return Task.FromResult(res);
}
private void VipsLogDelegate(string domain, NetVips.Enums.LogLevelFlags _, string message) =>
_logger.LogWarning("{domain} - {message}", domain, message);
#endif
}

View file

@ -28,7 +28,7 @@
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.1.2" />
<PackageReference Include="AsyncKeyedLock" Version="6.3.4" />
<PackageReference Include="Blurhash.Core" Version="2.0.0" />
<PackageReference Include="Blurhash.ImageSharp" Version="3.0.0" />
<PackageReference Include="cuid.net" Version="5.0.2" />
<PackageReference Include="dotNetRdf.Core" Version="3.2.6-iceshrimp" />
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2" />
@ -49,7 +49,6 @@
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Ini" Version="8.0.0" />
<PackageReference Include="NetVips" Version="2.4.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.2" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
@ -63,22 +62,30 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.4" />
</ItemGroup>
<ItemGroup Condition=" '$(BundleNativeDeps)' == 'true' ">
<PropertyGroup Condition=" '$(EnableLibVips)' == 'true' ">
<DefineConstants>$(DefineConstants);EnableLibVips</DefineConstants>
</PropertyGroup>
<ItemGroup Condition=" '$(EnableLibVips)' == 'true' ">
<PackageReference Include="NetVips" Version="2.4.1" />
</ItemGroup>
<ItemGroup Condition=" '$(EnableLibVips)' == 'true' And '$(BundleNativeDeps)' == 'true' ">
<PackageReference Include="NetVips.Native.linux-x64" Version="8.15.2" />
<PackageReference Include="NetVips.Native.linux-arm64" Version="8.15.2" />
</ItemGroup>
<ItemGroup Condition=" '$(BundleNativeDepsMusl)' == 'true' ">
<ItemGroup Condition=" '$(EnableLibVips)' == 'true' And '$(BundleNativeDepsMusl)' == 'true' ">
<PackageReference Include="NetVips.Native.linux-musl-x64" Version="8.15.2" />
<PackageReference Include="NetVips.Native.linux-musl-arm64" Version="8.15.2" />
</ItemGroup>
<ItemGroup Condition=" $([MSBuild]::IsOSPlatform('macOS')) Or '$(BundleNativeDepsMacOS)' == 'true' ">
<ItemGroup Condition=" '$(EnableLibVips)' == 'true' And ($([MSBuild]::IsOSPlatform('macOS')) Or '$(BundleNativeDepsMacOS)' == 'true') ">
<PackageReference Include="NetVips.Native.osx-x64" Version="8.15.2" />
<PackageReference Include="NetVips.Native.osx-arm64" Version="8.15.2" />
</ItemGroup>
<ItemGroup Condition=" $([MSBuild]::IsOSPlatform('Windows')) Or '$(BundleNativeDepsWindows)' == 'true' ">
<ItemGroup Condition=" '$(EnableLibVips)' == 'true' And ($([MSBuild]::IsOSPlatform('Windows')) Or '$(BundleNativeDepsWindows)' == 'true') ">
<PackageReference Include="NetVips.Native.win-x64" Version="8.15.2" />
<PackageReference Include="NetVips.Native.win-arm64" Version="8.15.2" />
</ItemGroup>

View file

@ -81,6 +81,10 @@ MediaRetention = 30d
CleanAvatars = false
CleanBanners = false
;; Whether to enable LibVIPS support for image processing. Trades faster processing speed for higher memory usage.
;; Note: Requires compilation with -p:EnableLibVips=true
EnableLibVips = false
[Storage:Local]
;; Path where media is stored at. Must be writable for the service user.
Path = /path/to/media/location

View file

@ -80,6 +80,8 @@
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/ANONYMOUS_METHOD_DECLARATION_BRACES/@EntryValue">NEXT_LINE</s:String>
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/CASE_BLOCK_BRACES/@EntryValue">NEXT_LINE</s:String>
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/EMPTY_BLOCK_STYLE/@EntryValue">TOGETHER_SAME_LINE</s:String>
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INDENT_PREPROCESSOR_IF/@EntryValue">USUAL_INDENT</s:String>
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INDENT_PREPROCESSOR_OTHER/@EntryValue">USUAL_INDENT</s:String>
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INITIALIZER_BRACES/@EntryValue">NEXT_LINE</s:String>
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_ASSIGNMENTS/@EntryValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INT_ALIGN_COMMENTS/@EntryValue">True</s:Boolean>