Add server-side tool to optimize images
This commit is contained in:
parent
1ec905420f
commit
c1aca1572e
5 changed files with 342 additions and 0 deletions
|
|
@ -12,6 +12,7 @@ using SixLabors.ImageSharp.Formats.Jpeg;
|
|||
using LANCommander.SDK.Enums;
|
||||
using LANCommander.SDK.Extensions;
|
||||
using LANCommander.Server.Services.Extensions;
|
||||
using LANCommander.Server.Services.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
|
|
@ -355,6 +356,164 @@ namespace LANCommander.Server.Services
|
|||
}
|
||||
}
|
||||
|
||||
public async Task<List<MediaOptimizationCandidate>> ScanOptimizationCandidatesAsync(MediaOptimizationOptions options)
|
||||
{
|
||||
var candidates = new List<MediaOptimizationCandidate>();
|
||||
var allMedia = await Include(m => m.StorageLocation, m => m.Game).GetAsync();
|
||||
|
||||
foreach (var media in allMedia)
|
||||
{
|
||||
var path = GetMediaPath(media);
|
||||
|
||||
if (!File.Exists(path))
|
||||
continue;
|
||||
|
||||
var mime = media.MimeType?.ToLowerInvariant();
|
||||
var isPng = mime == "image/png";
|
||||
var isJpeg = mime == "image/jpeg" || mime == "image/jpg";
|
||||
|
||||
if (!isPng && !isJpeg)
|
||||
continue;
|
||||
|
||||
int width;
|
||||
int height;
|
||||
|
||||
try
|
||||
{
|
||||
var info = await Image.IdentifyAsync(path);
|
||||
width = info.Width;
|
||||
height = info.Height;
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var oversized = options.Downscale && Math.Max(width, height) > options.MaxLongEdge;
|
||||
var willConvert = isPng && options.ConvertPngToJpeg && media.Type != MediaType.Logo && media.Type != MediaType.Icon;
|
||||
var willRecompress = isJpeg && options.RecompressJpeg;
|
||||
|
||||
if (!willConvert && !oversized && !willRecompress)
|
||||
continue;
|
||||
|
||||
var actions = new List<string>();
|
||||
|
||||
if (willConvert)
|
||||
actions.Add("PNG → JPEG");
|
||||
if (oversized)
|
||||
actions.Add($"Downscale to {options.MaxLongEdge}px");
|
||||
if (willRecompress)
|
||||
actions.Add("Recompress");
|
||||
if (options.StripMetadata)
|
||||
actions.Add("Strip metadata");
|
||||
|
||||
candidates.Add(new MediaOptimizationCandidate
|
||||
{
|
||||
Id = media.Id,
|
||||
GameTitle = media.Game?.Title ?? media.Name ?? media.Type.ToString(),
|
||||
Type = media.Type,
|
||||
MimeType = media.MimeType,
|
||||
Width = width,
|
||||
Height = height,
|
||||
Size = new FileInfo(path).Length,
|
||||
PlannedAction = string.Join(", ", actions),
|
||||
});
|
||||
}
|
||||
|
||||
return candidates.OrderByDescending(c => c.Size).ToList();
|
||||
}
|
||||
|
||||
public async Task<MediaOptimizationResult> OptimizeMediaAsync(Media media, MediaOptimizationOptions options)
|
||||
{
|
||||
var result = new MediaOptimizationResult();
|
||||
|
||||
if (media.StorageLocation == null)
|
||||
media.StorageLocation = await storageLocationService.GetAsync(media.StorageLocationId);
|
||||
|
||||
var path = GetMediaPath(media);
|
||||
|
||||
if (!File.Exists(path))
|
||||
return result;
|
||||
|
||||
var mime = media.MimeType?.ToLowerInvariant();
|
||||
var isPng = mime == "image/png";
|
||||
var isJpeg = mime == "image/jpeg" || mime == "image/jpg";
|
||||
|
||||
if (!isPng && !isJpeg)
|
||||
return result;
|
||||
|
||||
result.BeforeBytes = new FileInfo(path).Length;
|
||||
|
||||
try
|
||||
{
|
||||
using (var image = await Image.LoadAsync<Rgba32>(path))
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
if (options.Downscale && Math.Max(image.Width, image.Height) > options.MaxLongEdge)
|
||||
{
|
||||
image.Mutate(ctx => ctx.Resize(new ResizeOptions
|
||||
{
|
||||
Mode = ResizeMode.Max,
|
||||
Size = new Size(options.MaxLongEdge, options.MaxLongEdge),
|
||||
Sampler = KnownResamplers.Bicubic,
|
||||
}));
|
||||
|
||||
changed = true;
|
||||
}
|
||||
|
||||
var transparent = isPng && HasTransparentPixels(image);
|
||||
var convertToJpeg = isPng && options.ConvertPngToJpeg && !transparent && media.Type != MediaType.Logo && media.Type != MediaType.Icon;
|
||||
|
||||
var hasMetadata = image.Metadata.ExifProfile != null
|
||||
|| image.Metadata.IccProfile != null
|
||||
|| image.Metadata.XmpProfile != null;
|
||||
|
||||
if (options.StripMetadata && hasMetadata)
|
||||
{
|
||||
image.Metadata.ExifProfile = null;
|
||||
image.Metadata.IccProfile = null;
|
||||
image.Metadata.XmpProfile = null;
|
||||
|
||||
changed = true;
|
||||
}
|
||||
|
||||
var reencodeJpeg = isJpeg && (options.RecompressJpeg || changed);
|
||||
|
||||
if (!convertToJpeg && !reencodeJpeg && !(isPng && changed))
|
||||
return result;
|
||||
|
||||
var tempPath = path + ".optimizing";
|
||||
|
||||
if (convertToJpeg || isJpeg)
|
||||
await image.SaveAsJpegAsync(tempPath, new JpegEncoder { Quality = options.JpegQuality });
|
||||
else
|
||||
await image.SaveAsPngAsync(tempPath);
|
||||
|
||||
File.Delete(path);
|
||||
File.Move(tempPath, path);
|
||||
|
||||
if (convertToJpeg)
|
||||
media.MimeType = MediaTypeNames.Image.Jpeg;
|
||||
}
|
||||
|
||||
media.Crc32 = await SDK.Services.MediaClient.CalculateChecksumAsync(path);
|
||||
|
||||
await GenerateThumbnailAsync(media);
|
||||
|
||||
await UpdateAsync(media);
|
||||
|
||||
result.AfterBytes = new FileInfo(path).Length;
|
||||
result.Changed = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Could not optimize media with ID {MediaId}", media.Id);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<StorageLocation> GetDefaultStorageLocationAsync()
|
||||
{
|
||||
var defaultStorageLocation = await storageLocationService.FirstOrDefaultAsync(l => l.Type == StorageLocationType.Media && l.Default);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
using System;
|
||||
using LANCommander.SDK.Enums;
|
||||
|
||||
namespace LANCommander.Server.Services.Models
|
||||
{
|
||||
public class MediaOptimizationOptions
|
||||
{
|
||||
public bool ConvertPngToJpeg { get; set; } = true;
|
||||
public int JpegQuality { get; set; } = 90;
|
||||
public bool Downscale { get; set; } = true;
|
||||
public int MaxLongEdge { get; set; } = 3840;
|
||||
public bool RecompressJpeg { get; set; } = false;
|
||||
public bool StripMetadata { get; set; } = true;
|
||||
}
|
||||
|
||||
public class MediaOptimizationCandidate
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string GameTitle { get; set; }
|
||||
public MediaType Type { get; set; }
|
||||
public string MimeType { get; set; }
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
public long Size { get; set; }
|
||||
public string PlannedAction { get; set; }
|
||||
}
|
||||
|
||||
public class MediaOptimizationResult
|
||||
{
|
||||
public bool Changed { get; set; }
|
||||
public long BeforeBytes { get; set; }
|
||||
public long AfterBytes { get; set; }
|
||||
}
|
||||
}
|
||||
46
LANCommander.Server/Jobs/Background/OptimizeImages.cs
Normal file
46
LANCommander.Server/Jobs/Background/OptimizeImages.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
using LANCommander.Server.Services;
|
||||
using LANCommander.Server.Services.Models;
|
||||
|
||||
namespace LANCommander.Server.Jobs.Background
|
||||
{
|
||||
public class OptimizeImagesJob : BaseBackgroundJob
|
||||
{
|
||||
private readonly MediaService MediaService;
|
||||
private readonly ILogger<OptimizeImagesJob> Logger;
|
||||
|
||||
public OptimizeImagesJob(
|
||||
ILogger<OptimizeImagesJob> logger,
|
||||
MediaService mediaService) : base(logger)
|
||||
{
|
||||
MediaService = mediaService;
|
||||
Logger = logger;
|
||||
}
|
||||
|
||||
public override Task ExecuteAsync() => ExecuteAsync(new MediaOptimizationOptions());
|
||||
|
||||
public async Task ExecuteAsync(MediaOptimizationOptions options)
|
||||
{
|
||||
var allMedia = await MediaService.Include(m => m.StorageLocation).GetAsync();
|
||||
|
||||
long totalBefore = 0;
|
||||
long totalAfter = 0;
|
||||
var optimized = 0;
|
||||
|
||||
foreach (var media in allMedia)
|
||||
{
|
||||
var result = await MediaService.OptimizeMediaAsync(media, options);
|
||||
|
||||
if (!result.Changed)
|
||||
continue;
|
||||
|
||||
optimized++;
|
||||
totalBefore += result.BeforeBytes;
|
||||
totalAfter += result.AfterBytes;
|
||||
}
|
||||
|
||||
Logger?.LogInformation(
|
||||
"Image optimization complete. Optimized {Count} media, reclaimed {Saved} bytes ({Before} -> {After})",
|
||||
optimized, totalBefore - totalAfter, totalBefore, totalAfter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -71,6 +71,12 @@
|
|||
<h3>Regenerate Thumbnails</h3>
|
||||
<p>Clear out the existing thumbnail cache and generate replacements.</p>
|
||||
<Button Type="ButtonType.Primary" OnClick="RegenerateThumbnails" Loading="RegeneratingThumbnails">Regenerate</Button>
|
||||
|
||||
<Divider />
|
||||
|
||||
<h3>Optimize Images</h3>
|
||||
<p>Convert opaque PNG art to JPEG, downscale oversized media, and strip metadata to reclaim disk space while preserving quality.</p>
|
||||
<a href="/Settings/Tools/OptimizeImages" class="ant-btn ant-btn-primary">Optimize Images</a>
|
||||
</PageContent>
|
||||
|
||||
@code {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
@page "/Settings/Tools/OptimizeImages"
|
||||
@using Hangfire
|
||||
@using LANCommander.Server.Jobs.Background
|
||||
@using LANCommander.Server.Services.Models
|
||||
@inject MediaService MediaService
|
||||
@inject IMessageService MessageService
|
||||
@inject ILogger<OptimizeImages> Logger
|
||||
@attribute [Authorize(Roles = RoleService.AdministratorRoleName)]
|
||||
|
||||
<PageHeader Title="Optimize Images" />
|
||||
|
||||
<PageContent>
|
||||
<p>
|
||||
Reclaim disk space while preserving art quality. Opaque PNG art (backgrounds, covers, screenshots) is converted to high-quality JPEG, oversized images are downscaled, and metadata is stripped. Images with transparency (icons, logos) are never converted. Optimization replaces the original files in place and runs in the background.
|
||||
</p>
|
||||
|
||||
<Form Layout="@FormLayout.Vertical" Model="@Options">
|
||||
<FormItem Label="Convert opaque PNG to JPEG">
|
||||
<Switch @bind-Value="@Options.ConvertPngToJpeg" />
|
||||
</FormItem>
|
||||
<FormItem Label="Downscale oversized images">
|
||||
<Switch @bind-Value="@Options.Downscale" />
|
||||
</FormItem>
|
||||
<FormItem Label="Recompress existing JPEGs (lossy)">
|
||||
<Switch @bind-Value="@Options.RecompressJpeg" />
|
||||
</FormItem>
|
||||
<FormItem Label="Strip metadata (EXIF/ICC/XMP)">
|
||||
<Switch @bind-Value="@Options.StripMetadata" />
|
||||
</FormItem>
|
||||
<FormItem Label="JPEG quality">
|
||||
<AntDesign.InputNumber @bind-Value="@Options.JpegQuality" Min="1" Max="100" />
|
||||
</FormItem>
|
||||
<FormItem Label="Max long edge (px)">
|
||||
<AntDesign.InputNumber @bind-Value="@Options.MaxLongEdge" Min="256" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<Space Align="SpaceAlign.Center" Style="margin-bottom: 16px">
|
||||
<SpaceItem>
|
||||
<Button Type="ButtonType.Primary" OnClick="Scan" Loading="@Loading">Scan</Button>
|
||||
</SpaceItem>
|
||||
<SpaceItem>
|
||||
<Popconfirm OnConfirm="Optimize" Title="Optimize all listed images? Originals will be replaced in place." Disabled="@(Candidates == null || !Candidates.Any())">
|
||||
<Button Disabled="@(Candidates == null || !Candidates.Any())">Optimize Listed</Button>
|
||||
</Popconfirm>
|
||||
</SpaceItem>
|
||||
</Space>
|
||||
</PageContent>
|
||||
|
||||
<Table TItem="MediaOptimizationCandidate" DataSource="@Candidates" Loading="@Loading" PageSize="25" Responsive>
|
||||
<PropertyColumn Property="c => c.GameTitle" Title="Game" />
|
||||
<PropertyColumn Property="c => c.Type" Title="Type" />
|
||||
<PropertyColumn Property="c => c.MimeType" Title="Format" />
|
||||
<Column TData="string" Title="Dimensions">
|
||||
@context.Width x @context.Height
|
||||
</Column>
|
||||
<PropertyColumn Property="c => c.Size" Title="Size" Sortable>
|
||||
<ByteSize Value="context.Size" />
|
||||
</PropertyColumn>
|
||||
<PropertyColumn Property="c => c.PlannedAction" Title="Planned Action" />
|
||||
</Table>
|
||||
|
||||
@code {
|
||||
MediaOptimizationOptions Options = new();
|
||||
ICollection<MediaOptimizationCandidate> Candidates;
|
||||
bool Loading = false;
|
||||
|
||||
async Task Scan()
|
||||
{
|
||||
Loading = true;
|
||||
|
||||
await Task.Yield();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
try
|
||||
{
|
||||
Candidates = await MediaService.ScanOptimizationCandidatesAsync(Options);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Could not scan media for optimization candidates");
|
||||
MessageService.Error("Could not scan media.");
|
||||
}
|
||||
|
||||
Loading = false;
|
||||
|
||||
await Task.Yield();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
void Optimize()
|
||||
{
|
||||
BackgroundJob.Enqueue<OptimizeImagesJob>(x => x.ExecuteAsync(Options));
|
||||
|
||||
MessageService.Success("Optimization is running in the background.");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue