Skip to content

Conversation

@maxpiva
Copy link

@maxpiva maxpiva commented Aug 3, 2025

Added

  • Transcoding support via NetVips
    Adds support for transcoding/converting and serving image formats such as JXL, JPEG 2000, HEIF, AVIF, and TIFF.
    Also switches JPEG encoding/decoding to use JPEGLI instead of the native JPEG implementation, providing faster performance and improved quality-to-size ratio.

  • Removed all SixLabors dependencies.

  • Decoupled image processing into two interfaces:

    • IImageFactory
    • IImage
      Kavita is now decoupled from any specific graphics library.
  • Added two implementations:

    • NetVips (default)
    • ImageMagick (can be enabled via conditional compilation)
  • Extended ImageService:

    • Exposes access to the image factory.
    • Introduces ReplaceImageFileFormat, which auto-converts unsupported formats (e.g., AVIF, JXL) to JPEG based on browser compatibility.
    • Adds tests to validate format handling across supported backends.
    • Allows switching between NetVips and ImageMagick via project defines.
      Note: Only one backend is supported at a time.
  • Refactored ImageExtensions to remove SixLabors usage and delegate to the active backend.

  • Added local packages/ directory containing custom netvips.native NuGet packages with support for JXL, JPEG 2000, and JPEGLI.
    These may be published to NuGet.org pending review from Kleisauke.

Changed

  • Converted some static methods in ImageService to instance methods to support dependency injection of the graphics backend.
  • Moved WillScaleWell and IsLikelyWideImage into ImageExtensions.

Notes

  • Tested successfully on Windows, Linux x64, and Docker over a 2-day period.
    Further testing is recommended for macOS, ARM, and other platforms.

  • Building libvips with JPEGLI, JXL, and JPEG 2000 support required patching and custom builds.
    Special thanks and hat tip to Kleisauke for his foundational work, without it, this integration would have been extremely difficult.
    Custom forks of netvips, libvips-packaging, and build-win64-mxe are available in my GitHub account and can be reused if needed, just be aware I just pushed until it works, some changes might be unrequired.

@maxpiva
Copy link
Author

maxpiva commented Aug 3, 2025

FYI: @majora2007, @DieselTech Additional Notes: Docker this time was not changed since new format support are embedded into the new netvips packages.

This comment was marked as outdated.

@maxpiva
Copy link
Author

maxpiva commented Aug 4, 2025

Canary docker with latest changes in this PR can be found in maxpiva/kavita:nightly

Copy link
Member

@majora2007 majora2007 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a preliminary review from just the code, I will pull this down as well and do some shakeout testing.

private readonly PngEncoder _pngEncoder = new PngEncoder();
private readonly WebpEncoder _webPEncoder = new WebpEncoder();
private readonly IImageFactory _imageFactory;
private const string SourceImage = "C:/Users/josep/Pictures/obey_by_grrsa-d6llkaa_colored_by_me.png";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whatever test this is, we probably need to recreate or remove it. I dont even have this file anymore.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just ported it over, should remove the test?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah

public void ImageFactory_ExtractImage_PNG()
{
var outputDirectory = "C:/Users/josep/Pictures/imagesharp/";
var outputDirectory = "C:/Users/josep/Pictures/netvips/";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to refactor this to use the TestData folder within the project and not paths on my dev machine. (../../../Services/Test Data/ArchiveService/)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will do.

var thumbnail = imageService.ImageFactory.Create(Path.Join(testDirectory, expectedOutputFile));
int width = 320;
int height =(int)(thumbnail.Height * (width / (double)thumbnail.Width));
thumbnail = thumbnail.Thumbnail(width, height);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we not wrap this in the Image Interface so it's not so manual across the tests? Looks like a duplication of logic to me.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also refactor this to use var dims = CoverImageSize.Default.GetDimensions();. It never got updated when I introduced the concept.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second thought, I don't see the use case for this, archiveService.GetCoverImage would internally make the same calls anyway.

// Load and compare similarity

var similarity = expectedFaviconPath.CalculateSimilarity(actualFaviconPath); // Assuming you have this extension
var similarity = _imageService.ImageFactory.CalculateSimilarity(expectedFaviconPath, actualFaviconPath); // Assuming you have this extension
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious why the imageService.CalculateSimularity doesn't internally call the Image factory leading to a much cleaner API?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can go one way or the other, depending of SRP, will change

}

// Ensure band count is 4 (RGBA)
if (image.Bands == 1)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to maintain all this logic now even when with NetVips still?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so, The colourspace and similarity algorithms, uses rawpixel data.

using var res = im.Resize(percent / 100f);
float[] pixels = NetVipsImage.GetRGBAFloatImageDataFromImage(res);
if (pixels == null)
return new List<Vector3>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bring to same line. Can we use Array.Empty instead of a new instance?

{
var existingPath = Path.Combine(_directoryService.CoverImageDirectory, person.CoverImage);
var betterImage = existingPath.GetBetterImage(tempFullPath)!;
var betterImage = _imageService.ImageFactory.GetBetterImage(existingPath,tempFullPath)!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Space after ,

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also moved to IImageService.

public const string MacOsMetadataFileStartsWith = @"._";

public const string SupportedExtensions =
public static string SupportedExtensions =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this static and not const?

SupportedExtensions is what we support from the Disk, not from the client.

Copy link
Author

@maxpiva maxpiva Aug 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit tricky to explain, but here we go, there are two distinct concerns here.

I had to switch to a static field because the base value is no longer a constant. It's now a constructed string that depends on the supported formats. Otherwise, we’d have to duplicate the list of extensions here, and I’d rather keep a single source of truth to avoid human error during edits.

You're absolutely right: what the browser or client supports is not the same as what we support from disk.

So, we split the disk-supported formats into two collections, one for formats universally supported by clients, and another for those that aren't.

@maxpiva
Copy link
Author

maxpiva commented Aug 5, 2025

Just pushed the additional changes. Didn't change the Benchmark methods, or the intent of the test method. until have more clarity. also, the IOC part I didn't understand the context.

List<string> supportedExtensions = Parser.UniversalFileImageExtensionArray.ToList();
//Early eject if the browser or api do not provide an Accept header.
if (!request.Headers.ContainsKey("Accept"))
return supportedExtensions;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be on the same line as the if

@majora2007
Copy link
Member

I just got back from a holiday, I'm planning to do one more once over and get this into a canary release for dedicated user testing.

@majora2007 majora2007 changed the base branch from develop to canary August 20, 2025 15:27
@majora2007 majora2007 changed the title Transcoding support using NetVips. Image Operations Refactor. Abstract Image Library from Kavita [Canary] Transcoding support using NetVips. Image Operations Refactor. Abstract Image Library from Kavita Aug 20, 2025
@majora2007 majora2007 added the enhancement New feature or request label Aug 20, 2025
@majora2007 majora2007 requested review from Fesaa and Copilot August 20, 2025 15:29
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR introduces transcoding support for modern image formats (JXL, JPEG 2000, HEIF, AVIF, TIFF) using NetVips library and abstracts Kavita's image processing through a clean interface pattern. The changes decouple the application from specific graphics libraries while enabling automatic format conversion based on browser compatibility.

  • Replaces SixLabors.ImageSharp dependency with NetVips (default) and optional ImageMagick backend
  • Introduces IImageFactory and IImage interfaces to abstract image processing operations
  • Adds automatic image format transcoding with browser compatibility detection via Accept headers

Reviewed Changes

Copilot reviewed 32 out of 54 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
API/Services/Tasks/Scanner/Parser/Parser.cs Adds support for non-universal image formats and browser compatibility mappings
API/Services/ImageServices/*.cs New abstraction layer with NetVips and ImageMagick implementations
API/Services/ImageService.cs Refactored to use new image abstraction with format conversion capabilities
API/Extensions/ImageExtensions.cs Updated image utility methods to work with new abstraction layer
API/Controllers/ReaderController.cs Adds automatic image format replacement based on browser support

var filename = ImageService.GetWebLinkFormat(baseUrl, encodeFormat);

image.WriteToFile(Path.Combine(_directoryService.FaviconDirectory, filename));
await image.SaveAsync(Path.Combine(_directoryService.FaviconDirectory, filename),encodeFormat);
Copy link

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing space after comma. Should be: await image.SaveAsync(Path.Combine(_directoryService.FaviconDirectory, filename), encodeFormat);

Copilot uses AI. Check for mistakes.
{
var existingPath = Path.Combine(_directoryService.CoverImageDirectory, series.CoverImage);
var betterImage = existingPath.GetBetterImage(tempFullPath)!;
var betterImage =_imageService.GetBetterImage(existingPath, tempFullPath)!;
Copy link

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing space after the '=' operator. Should be: var betterImage = _imageService.GetBetterImage(existingPath, tempFullPath)!;

Copilot uses AI. Check for mistakes.
{
var existingPath = Path.Combine(_directoryService.CoverImageDirectory, chapter.CoverImage);
var betterImage = existingPath.GetBetterImage(tempFullPath)!;
var betterImage = _imageService.GetBetterImage(existingPath,tempFullPath)!;
Copy link

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing space after comma. Should be: var betterImage = _imageService.GetBetterImage(existingPath, tempFullPath)!;

Copilot uses AI. Check for mistakes.
{
if (supportedImageFormats == null) return false;

string ext = Path.GetExtension(filename).ToLowerInvariant().Substring(1);
Copy link

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential bug: Calling Substring(1) on an empty string will throw an exception if the filename has no extension. Add a null/empty check before calling Substring.

Suggested change
string ext = Path.GetExtension(filename).ToLowerInvariant().Substring(1);
string extension = Path.GetExtension(filename);
if (string.IsNullOrEmpty(extension) || extension.Length < 2) return false;
string ext = extension.ToLowerInvariant().Substring(1);

Copilot uses AI. Check for mistakes.
}

image = image.Insert(tile, x, y);
image.Composite(tile, (int)x, (int)y);
Copy link

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cast from double to int may cause precision loss. Consider using Math.Round or ensure x and y are already integers.

Suggested change
image.Composite(tile, (int)x, (int)y);
image.Composite(tile, (int)Math.Round(x), (int)Math.Round(y));

Copilot uses AI. Check for mistakes.
if (!extensions.Contains(extension))
{
extensions.Add(extension);
}
Copy link

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method performs a linear search with Contains() for each extension. Consider using a HashSet for better performance when dealing with many extensions.

Suggested change
}
private static void AddExtension(HashSet<string> extensions, string extension)
{
if (string.IsNullOrEmpty(extension))
return;
extensions.Add(extension);

Copilot uses AI. Check for mistakes.
@majora2007 majora2007 moved this from Todo to In Progress in v0.8.8 - Kavita+ / FR Aug 20, 2025
@majora2007 majora2007 changed the title [Canary] Transcoding support using NetVips. Image Operations Refactor. Abstract Image Library from Kavita [Canary] Transcoding support using NetVips Aug 27, 2025
@majora2007
Copy link
Member

I'm back from holiday and going to get this canary released for user testing.

@github-project-automation github-project-automation bot moved this to In progress in Kavita Sep 18, 2025
@majora2007
Copy link
Member

I haven't forgotten about this, just been heads down on the new annotation system. Once I get that stabilized, I'll pull this in and get it to some users.

@majora2007
Copy link
Member

I took care of the merge for you and pushed up the updated code into feature/transcoding-support. I already performed a serious of code cleanups to align with my coding style.

I see that 2 unit tests are broken (JXL/JP2), I would appreciate a quick look @maxpiva

Let's leave this PR open, but please target any changes towards my new branch.

@maxpiva
Copy link
Author

maxpiva commented Sep 19, 2025

letme check :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

2 participants