From 8e26b41a288ed2c9e2548fd4b5bd5eb59b0eaf65 Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Thu, 21 May 2026 23:04:31 +0200 Subject: [PATCH 1/4] JXL support --- PhotoLocator/ImageTransformCommands.cs | 17 ++- PhotoLocator/MainWindow.xaml | 1 + .../GeneralFileFormatHandler.cs | 10 +- .../JpegXlFileFormatHandler.cs | 118 ++++++++++++++++++ .../PictureFileFormats/JpegliEncoder.cs | 3 +- PhotoLocator/Settings/RegistrySettings.cs | 2 +- .../JpegXlFileFormatHandlerTest.cs | 60 +++++++++ 7 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 PhotoLocator/PictureFileFormats/JpegXlFileFormatHandler.cs create mode 100644 PhotoLocatorTest/PictureFileFormats/JpegXlFileFormatHandlerTest.cs diff --git a/PhotoLocator/ImageTransformCommands.cs b/PhotoLocator/ImageTransformCommands.cs index 1705c1f..acd5763 100644 --- a/PhotoLocator/ImageTransformCommands.cs +++ b/PhotoLocator/ImageTransformCommands.cs @@ -1,4 +1,5 @@ -using Microsoft.Win32; +using MeeSoft.ImageProcessing.FileFormats; +using Microsoft.Win32; using PhotoLocator.Helpers; using PhotoLocator.Metadata; using PhotoLocator.PictureFileFormats; @@ -233,10 +234,16 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) overwriteAll = true; } - var (image, itemMetadata) = await LoadImageWithMetadataAsync(item); - await Task.Run(() => GeneralFileFormatHandler.SaveToFile(image, targetFileName, - ExifHandler.ResetOrientation(itemMetadata), _mainViewModel.Settings.JpegQuality), ct); - + if (targetType == "jxl" && Path.GetExtension(item.Name).ToLowerInvariant() is ".jpg" or ".jpeg") + { + JpegXlFileFormatHandler.Transcode(item.FullPath, targetFileName, null, ct); + } + else + { + var (image, itemMetadata) = await LoadImageWithMetadataAsync(item); + await Task.Run(() => GeneralFileFormatHandler.SaveToFile(image, targetFileName, + ExifHandler.ResetOrientation(itemMetadata), _mainViewModel.Settings.JpegQuality), ct); + } progressCallback((double)(++i) / allSelected.Length); } }, "Convert to " + targetType); diff --git a/PhotoLocator/MainWindow.xaml b/PhotoLocator/MainWindow.xaml index d5eef7c..e1b5fd0 100644 --- a/PhotoLocator/MainWindow.xaml +++ b/PhotoLocator/MainWindow.xaml @@ -267,6 +267,7 @@ + diff --git a/PhotoLocator/PictureFileFormats/GeneralFileFormatHandler.cs b/PhotoLocator/PictureFileFormats/GeneralFileFormatHandler.cs index 0a6831c..c7c4afd 100644 --- a/PhotoLocator/PictureFileFormats/GeneralFileFormatHandler.cs +++ b/PhotoLocator/PictureFileFormats/GeneralFileFormatHandler.cs @@ -1,4 +1,5 @@ -using PhotoLocator.Helpers; +using MeeSoft.ImageProcessing.FileFormats; +using PhotoLocator.Helpers; using PhotoLocator.Metadata; using System; using System.IO; @@ -9,7 +10,7 @@ namespace PhotoLocator.PictureFileFormats { static class GeneralFileFormatHandler { - public const string SaveImageFilter = "JPEG|*.jpg|PNG|*.png|TIFF|*.tif|JPEG XR lossless|*.jxr|BMP|*.bmp"; + public const string SaveImageFilter = "JPEG|*.jpg|PNG|*.png|TIFF|*.tif|JPEG XR lossless|*.jxr|JPEG XL|*.jxl|BMP|*.bmp"; static string? _jpegliPath; static bool _jpegliChecked; @@ -75,6 +76,11 @@ public static void SaveToFile(BitmapSource image, string targetPath, BitmapMetad encoder = new BmpBitmapEncoder(); else if (ext is ".jxr") encoder = new WmpBitmapEncoder() { Lossless = true }; + else if (ext is ".jxl") + { + JpegXlFileFormatHandler.SaveToFile(image, targetPath, metadata, jpegQuality, default); + return; + } else throw new UserMessageException("Unsupported file format " + ext); diff --git a/PhotoLocator/PictureFileFormats/JpegXlFileFormatHandler.cs b/PhotoLocator/PictureFileFormats/JpegXlFileFormatHandler.cs new file mode 100644 index 0000000..e606223 --- /dev/null +++ b/PhotoLocator/PictureFileFormats/JpegXlFileFormatHandler.cs @@ -0,0 +1,118 @@ +using PhotoLocator.Helpers; +using PhotoLocator.PictureFileFormats; +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; + +namespace MeeSoft.ImageProcessing.FileFormats +{ + public static class JpegXlFileFormatHandler + { + public const string EncoderName = "cjxl.exe"; + + internal static string _encoderPath = Path.Combine(AppContext.BaseDirectory, "jpegli", EncoderName); + + public static BitmapSource LoadFromStream(Stream source, Rotation rotation, int maxWidth, bool preservePixelFormat, string decoderPath, CancellationToken ct) + { + using var dstStream = new MemoryStream(); + Process(decoderPath, source, ".jxl", dstStream, ".png", ct); + dstStream.Position = 0; + return GeneralFileFormatHandler.LoadFromStream(new OffsetStreamReader(dstStream), rotation, maxWidth, preservePixelFormat, ct); + } + + public static void SaveToStream(Stream dest, BitmapSource bitmap, string encoderPath, int quality, CancellationToken ct) + { + using var srcStream = new MemoryStream(); + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + encoder.Save(srcStream); + srcStream.Position = 0; + Process(encoderPath, srcStream, ".png", dest, ".jxl", ct, $"-q {quality}"); + } + + public static void SaveToFile(BitmapSource image, string targetPath, BitmapMetadata? metadata, int quality, CancellationToken ct) + { + using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write); + SaveToStream(stream, image, _encoderPath, quality, ct); + } + + public static void Transcode(string sourcePath, string targetPath, string? arguments, CancellationToken ct) + { + using var process = new Process(); + process.StartInfo.FileName = _encoderPath; + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.CreateNoWindow = true; + process.StartInfo.Arguments = $"\"{sourcePath}\" \"{targetPath}\" {arguments}"; + process.Start(); + string? output = null; + var outputTask = Task.Run(() => output = process.StandardError.ReadToEnd(), ct); + try + { + try + { + if (!process.WaitForExit(60000)) + throw new TimeoutException(); + if (process.ExitCode != 0) + throw new IOException("Codec failed with exit code " + process.ExitCode); + } + finally + { + if (outputTask.Wait(1000, ct)) + Log.Write(output); + } + } + catch (Exception ex) + { + if (output is null) + throw; + throw new IOException("Codec failed with: " + output, ex); + } + } + + private static void Process(string executablePath, Stream srcStream, string srcFormatExt, Stream dstStream, string dstFormatExt, + CancellationToken ct, string? arguments = null) + { + using var process = new Process(); + process.StartInfo.FileName = executablePath; + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.CreateNoWindow = true; + using var sourcePipe = new AnonymousPipeServerStream(PipeDirection.Out, HandleInheritability.Inheritable); + using var destPipe = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable); + process.StartInfo.Arguments = + $":{sourcePipe.GetClientHandleAsString()}{srcFormatExt} " + + $":{destPipe.GetClientHandleAsString()}{dstFormatExt} {arguments}"; + process.Start(); + sourcePipe.DisposeLocalCopyOfClientHandle(); + destPipe.DisposeLocalCopyOfClientHandle(); + try + { + using (var sourceWriter = new BinaryWriter(sourcePipe)) + { + var srcBytes = new byte[srcStream.Length]; + srcStream.ReadExactly(srcBytes); + sourceWriter.Write(srcBytes.Length); + sourceWriter.Write(srcBytes); + } + using (var destReader = new BinaryReader(destPipe)) + { + var size = destReader.ReadInt32(); + if (size == 0) + throw new IOException("JXL codec failed, result is empty"); + var destBytes = destReader.ReadBytes(size); + dstStream.Write(destBytes, 0, size); + } + } + catch (Exception ex) + { + throw new IOException(ex.Message + '\n' + process.StandardOutput.ReadToEnd() + '\n' + process.StandardError.ReadToEnd(), ex); + } + } + } +} diff --git a/PhotoLocator/PictureFileFormats/JpegliEncoder.cs b/PhotoLocator/PictureFileFormats/JpegliEncoder.cs index 9f4dae0..42672d3 100644 --- a/PhotoLocator/PictureFileFormats/JpegliEncoder.cs +++ b/PhotoLocator/PictureFileFormats/JpegliEncoder.cs @@ -53,8 +53,7 @@ private static void Process(string executablePath, Stream srcStream, string srcF using (var sourceWriter = new BinaryWriter(sourcePipe)) { var srcBytes = new byte[srcStream.Length]; - if (srcStream.Read(srcBytes, 0, srcBytes.Length) < srcBytes.Length) - throw new IOException("Failed to read from source stream"); + srcStream.ReadExactly(srcBytes); sourceWriter.Write(srcBytes.Length); sourceWriter.Write(srcBytes); } diff --git a/PhotoLocator/Settings/RegistrySettings.cs b/PhotoLocator/Settings/RegistrySettings.cs index fd77226..c4d7ec2 100644 --- a/PhotoLocator/Settings/RegistrySettings.cs +++ b/PhotoLocator/Settings/RegistrySettings.cs @@ -6,7 +6,7 @@ namespace PhotoLocator.Settings { sealed class RegistrySettings : IDisposable, IRegistrySettings { - public const string DefaultPhotoFileExtensions = ".jpg, .jpeg, .png, .cr2, .cr3, .arw, .nef, .dng, .psd, .tif, .tiff, .jxr, .mp4, .mov"; + public const string DefaultPhotoFileExtensions = ".jpg, .jpeg, .png, .cr2, .cr3, .arw, .nef, .dng, .psd, .tif, .tiff, .jxr, .jxl, .mp4, .mov"; public RegistryKey Key = Registry.CurrentUser.CreateSubKey(@"SOFTWARE\MeeSoft\PhotoLocator"); diff --git a/PhotoLocatorTest/PictureFileFormats/JpegXlFileFormatHandlerTest.cs b/PhotoLocatorTest/PictureFileFormats/JpegXlFileFormatHandlerTest.cs new file mode 100644 index 0000000..e46d38b --- /dev/null +++ b/PhotoLocatorTest/PictureFileFormats/JpegXlFileFormatHandlerTest.cs @@ -0,0 +1,60 @@ +using MeeSoft.ImageProcessing.FileFormats; +using PhotoLocator.Metadata; +using System.Diagnostics; +using System.Windows.Media.Imaging; + +namespace PhotoLocator.PictureFileFormats +{ + [TestClass] + public class JpegXlFileFormatHandlerTest + { + const string DecoderPath = @"jpegli\djxl.exe"; + + public TestContext TestContext { get; set; } + + [TestMethod] + public void SaveToFile_ShouldCreateJxl() + { + if (!File.Exists(JpegXlFileFormatHandler._encoderPath)) + Assert.Inconclusive("Encoder not found in " + Path.GetFullPath(JpegXlFileFormatHandler._encoderPath)); + + const string SourcePath = @"TestData\2022-06-17_19.03.02.jpg"; + const string TargetPathJxl = @"test.jxl"; + + Debug.WriteLine($"Source size: {new FileInfo(SourcePath).Length / 1024} kb"); + + using var sourceFile = File.OpenRead(SourcePath); + var source = GeneralFileFormatHandler.LoadFromStream(sourceFile, Rotation.Rotate0, int.MaxValue, true, TestContext.CancellationToken); + sourceFile.Position = 0; + var metadata = ExifHandler.LoadMetadata(sourceFile); + + JpegXlFileFormatHandler.SaveToFile(source, TargetPathJxl, metadata, 95, TestContext.CancellationToken); + + Assert.IsTrue(File.Exists(TargetPathJxl), "Target file was not created"); + var targetSize = new FileInfo(TargetPathJxl).Length; + Debug.WriteLine($"Target size: {targetSize / 1024} kb"); + } + + [TestMethod] + public void Transcode_ShouldBeLossless() + { + if (!File.Exists(JpegXlFileFormatHandler._encoderPath)) + Assert.Inconclusive("Encoder not found in " + JpegXlFileFormatHandler._encoderPath); + if (!File.Exists(DecoderPath)) + Assert.Inconclusive("Decoder not found in " + Path.GetFullPath(DecoderPath)); + + const string SourcePath = @"TestData\2022-06-17_19.03.02.jpg"; + string _targetPathJxl = Path.GetFileNameWithoutExtension(SourcePath) + ".jxl"; + + Debug.WriteLine($"Source size: {new FileInfo(SourcePath).Length / 1024} kb"); + + JpegXlFileFormatHandler.Transcode(SourcePath, _targetPathJxl, null, TestContext.CancellationToken); + + Assert.IsTrue(File.Exists(_targetPathJxl), "Target file was not created"); + var targetSize = new FileInfo(_targetPathJxl).Length; + Debug.WriteLine($"Target size: {targetSize / 1024} kb"); + + // Decode the JXL back to compare with the original + } + } +} From 80f252146f7239be9c58bfca65125a39005bdf40 Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Fri, 22 May 2026 23:27:15 +0200 Subject: [PATCH 2/4] Reuse process --- PhotoLocator/ImageTransformCommands.cs | 5 +- .../GeneralFileFormatHandler.cs | 13 ++-- .../JpegXlFileFormatHandler.cs | 74 ++++--------------- .../PictureFileFormats/JpegliEncoder.cs | 12 +-- PhotoLocator/Settings/RegistrySettings.cs | 2 +- .../JpegXlFileFormatHandlerTest.cs | 36 +++++---- 6 files changed, 50 insertions(+), 92 deletions(-) diff --git a/PhotoLocator/ImageTransformCommands.cs b/PhotoLocator/ImageTransformCommands.cs index acd5763..cb1ecf6 100644 --- a/PhotoLocator/ImageTransformCommands.cs +++ b/PhotoLocator/ImageTransformCommands.cs @@ -1,5 +1,4 @@ -using MeeSoft.ImageProcessing.FileFormats; -using Microsoft.Win32; +using Microsoft.Win32; using PhotoLocator.Helpers; using PhotoLocator.Metadata; using PhotoLocator.PictureFileFormats; @@ -236,7 +235,7 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) if (targetType == "jxl" && Path.GetExtension(item.Name).ToLowerInvariant() is ".jpg" or ".jpeg") { - JpegXlFileFormatHandler.Transcode(item.FullPath, targetFileName, null, ct); + JpegXlFileFormatHandler.TranscodeToJxl(item.FullPath, targetFileName, null, ct); } else { diff --git a/PhotoLocator/PictureFileFormats/GeneralFileFormatHandler.cs b/PhotoLocator/PictureFileFormats/GeneralFileFormatHandler.cs index c7c4afd..3cb91b3 100644 --- a/PhotoLocator/PictureFileFormats/GeneralFileFormatHandler.cs +++ b/PhotoLocator/PictureFileFormats/GeneralFileFormatHandler.cs @@ -1,5 +1,4 @@ -using MeeSoft.ImageProcessing.FileFormats; -using PhotoLocator.Helpers; +using PhotoLocator.Helpers; using PhotoLocator.Metadata; using System; using System.IO; @@ -44,7 +43,7 @@ public static BitmapSource LoadFromStream(Stream source, Rotation rotation, int return bitmap; } - public static void SaveToFile(BitmapSource image, string targetPath, BitmapMetadata? metadata = null, int jpegQuality = 95) + public static void SaveToFile(BitmapSource bitmap, string targetPath, BitmapMetadata? metadata = null, int jpegQuality = 95) { var ext = Path.GetExtension(targetPath).ToLowerInvariant(); BitmapEncoder encoder; @@ -63,7 +62,7 @@ public static void SaveToFile(BitmapSource image, string targetPath, BitmapMetad if (_jpegliPath is not null) { Log.Write("Saving using " + _jpegliPath); - JpegliEncoder.SaveToFile(image, targetPath, metadata, jpegQuality, _jpegliPath); + JpegliEncoder.SaveToFile(bitmap, targetPath, metadata, jpegQuality, _jpegliPath); return; } encoder = new JpegBitmapEncoder() { QualityLevel = jpegQuality }; @@ -78,16 +77,16 @@ public static void SaveToFile(BitmapSource image, string targetPath, BitmapMetad encoder = new WmpBitmapEncoder() { Lossless = true }; else if (ext is ".jxl") { - JpegXlFileFormatHandler.SaveToFile(image, targetPath, metadata, jpegQuality, default); + JpegXlFileFormatHandler.SaveToFile(bitmap, targetPath, metadata, jpegQuality); return; } else throw new UserMessageException("Unsupported file format " + ext); if (metadata is null) - encoder.Frames.Add(BitmapFrame.Create(image)); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); else - encoder.Frames.Add(BitmapFrame.Create(image, null, ExifHandler.CreateMetadataForEncoder(metadata, encoder), null)); + encoder.Frames.Add(BitmapFrame.Create(bitmap, null, ExifHandler.CreateMetadataForEncoder(metadata, encoder), null)); using var fileStream = new FileStream(targetPath, FileMode.Create); encoder.Save(fileStream); diff --git a/PhotoLocator/PictureFileFormats/JpegXlFileFormatHandler.cs b/PhotoLocator/PictureFileFormats/JpegXlFileFormatHandler.cs index e606223..31a68ff 100644 --- a/PhotoLocator/PictureFileFormats/JpegXlFileFormatHandler.cs +++ b/PhotoLocator/PictureFileFormats/JpegXlFileFormatHandler.cs @@ -1,49 +1,42 @@ using PhotoLocator.Helpers; -using PhotoLocator.PictureFileFormats; +using PhotoLocator.Metadata; using System; using System.Diagnostics; using System.IO; -using System.IO.Pipes; using System.Threading; using System.Threading.Tasks; using System.Windows.Media.Imaging; -namespace MeeSoft.ImageProcessing.FileFormats +namespace PhotoLocator.PictureFileFormats { public static class JpegXlFileFormatHandler { public const string EncoderName = "cjxl.exe"; + internal static string EncoderPath { get; } = Path.Combine(AppContext.BaseDirectory, "jpegli", EncoderName); - internal static string _encoderPath = Path.Combine(AppContext.BaseDirectory, "jpegli", EncoderName); - - public static BitmapSource LoadFromStream(Stream source, Rotation rotation, int maxWidth, bool preservePixelFormat, string decoderPath, CancellationToken ct) - { - using var dstStream = new MemoryStream(); - Process(decoderPath, source, ".jxl", dstStream, ".png", ct); - dstStream.Position = 0; - return GeneralFileFormatHandler.LoadFromStream(new OffsetStreamReader(dstStream), rotation, maxWidth, preservePixelFormat, ct); - } - - public static void SaveToStream(Stream dest, BitmapSource bitmap, string encoderPath, int quality, CancellationToken ct) + public static void SaveToStream(BitmapSource bitmap, Stream dest, string encoderPath, BitmapMetadata? metadata, int quality) { using var srcStream = new MemoryStream(); var encoder = new PngBitmapEncoder(); - encoder.Frames.Add(BitmapFrame.Create(bitmap)); + if (metadata is null) + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + else + encoder.Frames.Add(BitmapFrame.Create(bitmap, null, ExifHandler.CreateMetadataForEncoder(metadata, encoder), null)); encoder.Save(srcStream); srcStream.Position = 0; - Process(encoderPath, srcStream, ".png", dest, ".jxl", ct, $"-q {quality}"); + JpegliEncoder.Process(encoderPath, srcStream, ".png", dest, ".jxl", $"-q {quality}"); } - public static void SaveToFile(BitmapSource image, string targetPath, BitmapMetadata? metadata, int quality, CancellationToken ct) + public static void SaveToFile(BitmapSource image, string targetPath, BitmapMetadata? metadata, int quality) { using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write); - SaveToStream(stream, image, _encoderPath, quality, ct); + SaveToStream(image, stream, EncoderPath, metadata, quality); } - public static void Transcode(string sourcePath, string targetPath, string? arguments, CancellationToken ct) + public static void TranscodeToJxl(string sourcePath, string targetPath, string? arguments, CancellationToken ct) { using var process = new Process(); - process.StartInfo.FileName = _encoderPath; + process.StartInfo.FileName = EncoderPath; process.StartInfo.UseShellExecute = false; process.StartInfo.RedirectStandardError = true; process.StartInfo.CreateNoWindow = true; @@ -73,46 +66,5 @@ public static void Transcode(string sourcePath, string targetPath, string? argum throw new IOException("Codec failed with: " + output, ex); } } - - private static void Process(string executablePath, Stream srcStream, string srcFormatExt, Stream dstStream, string dstFormatExt, - CancellationToken ct, string? arguments = null) - { - using var process = new Process(); - process.StartInfo.FileName = executablePath; - process.StartInfo.UseShellExecute = false; - process.StartInfo.RedirectStandardOutput = true; - process.StartInfo.RedirectStandardError = true; - process.StartInfo.CreateNoWindow = true; - using var sourcePipe = new AnonymousPipeServerStream(PipeDirection.Out, HandleInheritability.Inheritable); - using var destPipe = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable); - process.StartInfo.Arguments = - $":{sourcePipe.GetClientHandleAsString()}{srcFormatExt} " + - $":{destPipe.GetClientHandleAsString()}{dstFormatExt} {arguments}"; - process.Start(); - sourcePipe.DisposeLocalCopyOfClientHandle(); - destPipe.DisposeLocalCopyOfClientHandle(); - try - { - using (var sourceWriter = new BinaryWriter(sourcePipe)) - { - var srcBytes = new byte[srcStream.Length]; - srcStream.ReadExactly(srcBytes); - sourceWriter.Write(srcBytes.Length); - sourceWriter.Write(srcBytes); - } - using (var destReader = new BinaryReader(destPipe)) - { - var size = destReader.ReadInt32(); - if (size == 0) - throw new IOException("JXL codec failed, result is empty"); - var destBytes = destReader.ReadBytes(size); - dstStream.Write(destBytes, 0, size); - } - } - catch (Exception ex) - { - throw new IOException(ex.Message + '\n' + process.StandardOutput.ReadToEnd() + '\n' + process.StandardError.ReadToEnd(), ex); - } - } } } diff --git a/PhotoLocator/PictureFileFormats/JpegliEncoder.cs b/PhotoLocator/PictureFileFormats/JpegliEncoder.cs index 42672d3..b329ed4 100644 --- a/PhotoLocator/PictureFileFormats/JpegliEncoder.cs +++ b/PhotoLocator/PictureFileFormats/JpegliEncoder.cs @@ -11,10 +11,10 @@ namespace PhotoLocator.PictureFileFormats { class JpegliEncoder { - public static void SaveToFile(BitmapSource image, string targetPath, BitmapMetadata? metadata, int quality, string encoderPath) + public static void SaveToFile(BitmapSource bitmap, string targetPath, BitmapMetadata? metadata, int quality, string encoderPath) { var pngEncoder = new PngBitmapEncoder(); - pngEncoder.Frames.Add(BitmapFrame.Create(image)); + pngEncoder.Frames.Add(BitmapFrame.Create(bitmap)); using var srcStream = new MemoryStream(); pngEncoder.Save(srcStream); srcStream.Position = 0; @@ -29,7 +29,7 @@ public static void SaveToFile(BitmapSource image, string targetPath, BitmapMetad finalStream.CopyTo(fileStream); } - private static void Process(string executablePath, Stream srcStream, string srcFormatExt, Stream dstStream, string dstFormatExt, string? arguments = null) + internal static void Process(string executablePath, Stream srcStream, string srcFormatExt, Stream dstStream, string dstFormatExt, string? arguments = null) { using var process = new Process(); process.StartInfo.FileName = executablePath; @@ -61,7 +61,7 @@ private static void Process(string executablePath, Stream srcStream, string srcF { var size = destReader.ReadInt32(); if (size <= 0) - throw new IOException("jpegli encoder failed, result is empty"); + throw new IOException(Path.GetFileNameWithoutExtension(executablePath) + " failed, result is empty"); var destBytes = destReader.ReadBytes(size); if (destBytes.Length != size) throw new IOException("Failed to read all bytes from destination pipe"); @@ -70,7 +70,7 @@ private static void Process(string executablePath, Stream srcStream, string srcF if (!process.WaitForExit(60000)) throw new TimeoutException(); if (process.ExitCode != 0) - throw new IOException("jpegli failed with exit code " + process.ExitCode); + throw new IOException(Path.GetFileNameWithoutExtension(executablePath) + " failed with exit code " + process.ExitCode); } finally { @@ -82,7 +82,7 @@ private static void Process(string executablePath, Stream srcStream, string srcF { if (output is null) throw; - throw new IOException("jpegli failed with: " + output, ex); + throw new IOException(Path.GetFileNameWithoutExtension(executablePath) + " failed with: " + output, ex); } } } diff --git a/PhotoLocator/Settings/RegistrySettings.cs b/PhotoLocator/Settings/RegistrySettings.cs index c4d7ec2..8cfdba6 100644 --- a/PhotoLocator/Settings/RegistrySettings.cs +++ b/PhotoLocator/Settings/RegistrySettings.cs @@ -64,7 +64,7 @@ public string SavedFilePostfix public int JpegQuality { - get => Key.GetValue(nameof(JpegQuality)) as int? ?? 93; + get => Key.GetValue(nameof(JpegQuality)) as int? ?? 90; set => Key.SetValue(nameof(JpegQuality), value); } diff --git a/PhotoLocatorTest/PictureFileFormats/JpegXlFileFormatHandlerTest.cs b/PhotoLocatorTest/PictureFileFormats/JpegXlFileFormatHandlerTest.cs index e46d38b..c5c437f 100644 --- a/PhotoLocatorTest/PictureFileFormats/JpegXlFileFormatHandlerTest.cs +++ b/PhotoLocatorTest/PictureFileFormats/JpegXlFileFormatHandlerTest.cs @@ -1,5 +1,4 @@ -using MeeSoft.ImageProcessing.FileFormats; -using PhotoLocator.Metadata; +using PhotoLocator.Metadata; using System.Diagnostics; using System.Windows.Media.Imaging; @@ -15,8 +14,8 @@ public class JpegXlFileFormatHandlerTest [TestMethod] public void SaveToFile_ShouldCreateJxl() { - if (!File.Exists(JpegXlFileFormatHandler._encoderPath)) - Assert.Inconclusive("Encoder not found in " + Path.GetFullPath(JpegXlFileFormatHandler._encoderPath)); + if (!File.Exists(JpegXlFileFormatHandler.EncoderPath)) + Assert.Inconclusive("Encoder not found in " + Path.GetFullPath(JpegXlFileFormatHandler.EncoderPath)); const string SourcePath = @"TestData\2022-06-17_19.03.02.jpg"; const string TargetPathJxl = @"test.jxl"; @@ -28,7 +27,7 @@ public void SaveToFile_ShouldCreateJxl() sourceFile.Position = 0; var metadata = ExifHandler.LoadMetadata(sourceFile); - JpegXlFileFormatHandler.SaveToFile(source, TargetPathJxl, metadata, 95, TestContext.CancellationToken); + JpegXlFileFormatHandler.SaveToFile(source, TargetPathJxl, metadata, 95); Assert.IsTrue(File.Exists(TargetPathJxl), "Target file was not created"); var targetSize = new FileInfo(TargetPathJxl).Length; @@ -36,25 +35,34 @@ public void SaveToFile_ShouldCreateJxl() } [TestMethod] - public void Transcode_ShouldBeLossless() + public void TranscodeToJxl_ShouldBeLossless() { - if (!File.Exists(JpegXlFileFormatHandler._encoderPath)) - Assert.Inconclusive("Encoder not found in " + JpegXlFileFormatHandler._encoderPath); + if (!File.Exists(JpegXlFileFormatHandler.EncoderPath)) + Assert.Inconclusive("Encoder not found in " + JpegXlFileFormatHandler.EncoderPath); if (!File.Exists(DecoderPath)) Assert.Inconclusive("Decoder not found in " + Path.GetFullPath(DecoderPath)); const string SourcePath = @"TestData\2022-06-17_19.03.02.jpg"; - string _targetPathJxl = Path.GetFileNameWithoutExtension(SourcePath) + ".jxl"; + var targetJxl = Path.GetFileNameWithoutExtension(SourcePath) + ".jxl"; + var restoredJpg = Path.ChangeExtension(targetJxl, ".jpg"); - Debug.WriteLine($"Source size: {new FileInfo(SourcePath).Length / 1024} kb"); + var sourceBytes = File.ReadAllBytes(SourcePath); + Debug.WriteLine($"Source size: {sourceBytes.Length / 1024} kb"); - JpegXlFileFormatHandler.Transcode(SourcePath, _targetPathJxl, null, TestContext.CancellationToken); + JpegXlFileFormatHandler.TranscodeToJxl(SourcePath, targetJxl, null, TestContext.CancellationToken); - Assert.IsTrue(File.Exists(_targetPathJxl), "Target file was not created"); - var targetSize = new FileInfo(_targetPathJxl).Length; + var targetSize = new FileInfo(targetJxl).Length; Debug.WriteLine($"Target size: {targetSize / 1024} kb"); - // Decode the JXL back to compare with the original + Process.Start(new ProcessStartInfo + { + FileName = DecoderPath, + Arguments = $"\"{targetJxl}\" \"{restoredJpg}\"", + CreateNoWindow = true, + })?.WaitForExit(); + + var restoredBytes = File.ReadAllBytes(restoredJpg); + Assert.IsTrue(Enumerable.SequenceEqual(sourceBytes, restoredBytes), "The restored image is not identical to the original"); } } } From 9e8e838d43d5724575c87e1253f8ed5a6e42cfbc Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Sat, 23 May 2026 23:45:42 +0200 Subject: [PATCH 3/4] Set metadata --- PhotoLocator/ImageTransformCommands.cs | 10 +-- PhotoLocator/Metadata/ExifHandler.cs | 2 +- PhotoLocator/Metadata/ExifTool.cs | 65 ++++++++++++++++--- .../GeneralFileFormatHandler.cs | 11 ++-- .../PictureFileFormats/JpegTransformations.cs | 2 +- .../JpegXlFileFormatHandler.cs | 8 ++- PhotoLocator/Settings/RegistrySettings.cs | 3 +- PhotoLocator/VideoTransformCommands.cs | 11 ++-- PhotoLocatorTest/Metadata/ExifHandlerTest.cs | 32 ++++++++- .../JpegXlFileFormatHandlerTest.cs | 15 +++-- .../PictureFileFormats/JpegliEncoderTest.cs | 4 +- 11 files changed, 123 insertions(+), 40 deletions(-) diff --git a/PhotoLocator/ImageTransformCommands.cs b/PhotoLocator/ImageTransformCommands.cs index cb1ecf6..583e1c3 100644 --- a/PhotoLocator/ImageTransformCommands.cs +++ b/PhotoLocator/ImageTransformCommands.cs @@ -88,7 +88,7 @@ await Task.Run(() => metadata = ExifHandler.LoadMetadata(file); } catch { } // Ignore if there is no supported metadata - GeneralFileFormatHandler.SaveToFile(pictureSource, sourceFileName, metadata, _mainViewModel.Settings.JpegQuality); + GeneralFileFormatHandler.SaveToFile(pictureSource, sourceFileName, metadata, _mainViewModel.Settings); }, ct); } await Task.Run(() => JpegTransformations.Crop(sourceFileName, targetFileName, cropRectangle), ct); @@ -170,7 +170,7 @@ private async Task SaveProcessedImageAsync(LocalContrastViewModel localContrastV await using var pause = _mainViewModel.PauseFileSystemWatcher(); var sameDir = Path.GetDirectoryName(selectedItem.FullPath) == Path.GetDirectoryName(dlg.FileName); await Task.Run(() => GeneralFileFormatHandler.SaveToFile(localContrastViewModel.PreviewPictureSource!, dlg.FileName, - ExifHandler.ResetOrientation(metadata), _mainViewModel.Settings.JpegQuality)); + ExifHandler.ResetOrientation(metadata), _mainViewModel.Settings)); if (sameDir) await _mainViewModel.AddOrUpdateItemAsync(dlg.FileName, false, false); } @@ -187,14 +187,14 @@ await _mainViewModel.RunProcessWithProgressBarAsync((progressCallback, ct) => Ta if (item == selectedItem) { GeneralFileFormatHandler.SaveToFile(localContrastViewModel.PreviewPictureSource!, targetFileName, - ExifHandler.ResetOrientation(metadata), _mainViewModel.Settings.JpegQuality); + ExifHandler.ResetOrientation(metadata), _mainViewModel.Settings); } else { var (image, itemMetadata) = await LoadImageWithMetadataAsync(item); image = localContrastViewModel.ApplyOperations(image); GeneralFileFormatHandler.SaveToFile(image, targetFileName, - ExifHandler.ResetOrientation(itemMetadata), _mainViewModel.Settings.JpegQuality); + ExifHandler.ResetOrientation(itemMetadata), _mainViewModel.Settings); } progressCallback((double)(++i) / allSelected.Length); } @@ -241,7 +241,7 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) { var (image, itemMetadata) = await LoadImageWithMetadataAsync(item); await Task.Run(() => GeneralFileFormatHandler.SaveToFile(image, targetFileName, - ExifHandler.ResetOrientation(itemMetadata), _mainViewModel.Settings.JpegQuality), ct); + ExifHandler.ResetOrientation(itemMetadata), _mainViewModel.Settings), ct); } progressCallback((double)(++i) / allSelected.Length); } diff --git a/PhotoLocator/Metadata/ExifHandler.cs b/PhotoLocator/Metadata/ExifHandler.cs index 7521b42..f6527b4 100644 --- a/PhotoLocator/Metadata/ExifHandler.cs +++ b/PhotoLocator/Metadata/ExifHandler.cs @@ -116,7 +116,7 @@ public static BitmapMetadata EncodePngMetadata(Rational? exposureTime, Location? return result; } - static (Rational? ExposureTime, Location? Location) DecodePngMetadata(BitmapMetadata metadata) + internal static (Rational? ExposureTime, Location? Location) DecodePngMetadata(BitmapMetadata metadata) { if (metadata.GetQuery("/Text/Description") is not string str) return (null, null); diff --git a/PhotoLocator/Metadata/ExifTool.cs b/PhotoLocator/Metadata/ExifTool.cs index 4852bd0..dc7c7c1 100644 --- a/PhotoLocator/Metadata/ExifTool.cs +++ b/PhotoLocator/Metadata/ExifTool.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; using System.Windows.Media.Imaging; @@ -21,19 +22,19 @@ public static async Task AdjustTimestampAsync(string sourceFileName, string targ throw new UserMessageException("Offset must have a sign followed by a value"); var sign = offset[0]; offset = offset[1..]; - var startInfo = new ProcessStartInfo(exifToolPath, $"\"-AllDates{sign}={offset}\" \"{sourceFileName}\" "); + var startInfo = new ProcessStartInfo(exifToolPath, $"\"-AllDates{sign}={offset}\" \"{sourceFileName}\""); await RunExifToolAsync(sourceFileName, targetFileName, startInfo, ct); } public static async Task SetTimestampAsync(string sourceFileName, string targetFileName, string timestamp, string exifToolPath, CancellationToken ct) { - var startInfo = new ProcessStartInfo(exifToolPath, $"\"-AllDates={timestamp}\" \"{sourceFileName}\" "); + var startInfo = new ProcessStartInfo(exifToolPath, $"\"-AllDates={timestamp}\" \"{sourceFileName}\""); await RunExifToolAsync(sourceFileName, targetFileName, startInfo, ct); } public static async Task TransferMetadataAsync(string metadataFileName, string sourceFileName, string targetFileName, string exifToolPath, CancellationToken ct) { - var startInfo = new ProcessStartInfo(exifToolPath, $"-tagsfromfile \"{metadataFileName}\" \"{sourceFileName}\" "); // -exif + var startInfo = new ProcessStartInfo(exifToolPath, $"-tagsfromfile \"{metadataFileName}\" \"{sourceFileName}\""); // -exif await RunExifToolAsync(sourceFileName, targetFileName, startInfo, ct); } @@ -44,25 +45,69 @@ public static async Task SetGeotagAsync(string sourceFileName, string targetFile ExifHandler.SetJpegGeotag(sourceFileName, targetFileName, location); return; } + var startInfo = new ProcessStartInfo(exifToolPath, GetLocationParameters(location) + $"\"{sourceFileName}\""); + await RunExifToolAsync(sourceFileName, targetFileName, startInfo, ct); + } + + public static async Task SetMetadataAsync(string sourceFileName, string targetFileName, BitmapMetadata metadata, string exifToolPath, CancellationToken ct) + { + var sb = new StringBuilder(); + + var cameraModel = metadata.CameraModel; + if (!string.IsNullOrEmpty(cameraModel)) + sb.Append(CultureInfo.InvariantCulture, $"-Model=\"{cameraModel}\" "); + + var location = ExifHandler.GetGeotag(metadata); + if (location is not null) + sb.Append(GetLocationParameters(location)); + + var altitude = ExifHandler.GetRelativeAltitude(metadata); + if (altitude.HasValue) + sb.Append(CultureInfo.InvariantCulture, $"-RelativeAltitude={altitude.Value} "); + + var exposureTime = Rational.Decode(metadata.GetQuery(ExifHandler.ExposureTimeQuery1) ?? metadata.GetQuery(ExifHandler.ExposureTimeQuery2)); + if (exposureTime is null && metadata.TryGetFormat() == "png") + (exposureTime, var _) = ExifHandler.DecodePngMetadata(metadata); + if (exposureTime is not null && exposureTime.Numerator > 0 && exposureTime.Denominator > 0) + sb.Append(CultureInfo.InvariantCulture, $"-ExposureTime={exposureTime.ToDouble()} "); + + var lensAperture = Rational.Decode(metadata.GetQuery(ExifHandler.LensApertureQuery1) ?? metadata.GetQuery(ExifHandler.LensApertureQuery2)); + if (lensAperture is not null && lensAperture.Numerator > 0 && lensAperture.Denominator > 0) + sb.Append(CultureInfo.InvariantCulture, $"-FNumber={lensAperture.ToDouble()} "); - var startInfo = new ProcessStartInfo(exifToolPath, string.Create(CultureInfo.InvariantCulture, - //"-m " + // Ignore minor errors and warnings + var focalLength = Rational.Decode(metadata.GetQuery(ExifHandler.FocalLengthQuery1) ?? metadata.GetQuery(ExifHandler.FocalLengthQuery2)); + if (focalLength is not null && focalLength.Numerator > 0 && focalLength.Denominator > 0) + sb.Append(CultureInfo.InvariantCulture, $"-FocalLength={focalLength.ToDouble()} "); + + var iso = metadata.GetQuery(ExifHandler.IsoQuery1) ?? metadata.GetQuery(ExifHandler.IsoQuery2); + if (iso is not null) + sb.Append(CultureInfo.InvariantCulture, $"-ISO={iso} "); + + if (sb.Length == 0) + return; + sb.Append(CultureInfo.InvariantCulture, $"\"{sourceFileName}\""); + var startInfo = new ProcessStartInfo(exifToolPath, sb.ToString()); + await RunExifToolAsync(sourceFileName, targetFileName, startInfo, ct); + } + + private static string GetLocationParameters(Location location) + { + return string.Create(CultureInfo.InvariantCulture, + //$"-m " + // Ignore minor errors and warnings $"-GPSLatitude={location.Latitude} " + $"-GPSLatitudeRef={Math.Sign(location.Latitude)} " + $"-GPSLongitude={location.Longitude} " + - $"-GPSLongitudeRef={Math.Sign(location.Longitude)} " + - $"\"{sourceFileName}\" ")); - await RunExifToolAsync(sourceFileName, targetFileName, startInfo, ct); + $"-GPSLongitudeRef={Math.Sign(location.Longitude)} "); } private static async Task RunExifToolAsync(string sourceFileName, string targetFileName, ProcessStartInfo startInfo, CancellationToken ct) { if (targetFileName == sourceFileName) - startInfo.Arguments += "-overwrite_original"; + startInfo.Arguments += " -overwrite_original"; else { File.Delete(targetFileName); - startInfo.Arguments += $"-out \"{targetFileName}\""; + startInfo.Arguments += $" -out \"{targetFileName}\""; } startInfo.CreateNoWindow = true; startInfo.RedirectStandardOutput = true; diff --git a/PhotoLocator/PictureFileFormats/GeneralFileFormatHandler.cs b/PhotoLocator/PictureFileFormats/GeneralFileFormatHandler.cs index 3cb91b3..30deefa 100644 --- a/PhotoLocator/PictureFileFormats/GeneralFileFormatHandler.cs +++ b/PhotoLocator/PictureFileFormats/GeneralFileFormatHandler.cs @@ -1,5 +1,6 @@ using PhotoLocator.Helpers; using PhotoLocator.Metadata; +using PhotoLocator.Settings; using System; using System.IO; using System.Threading; @@ -11,6 +12,8 @@ static class GeneralFileFormatHandler { public const string SaveImageFilter = "JPEG|*.jpg|PNG|*.png|TIFF|*.tif|JPEG XR lossless|*.jxr|JPEG XL|*.jxl|BMP|*.bmp"; + public const int DefaultJpegQuality = 90; + static string? _jpegliPath; static bool _jpegliChecked; @@ -43,7 +46,7 @@ public static BitmapSource LoadFromStream(Stream source, Rotation rotation, int return bitmap; } - public static void SaveToFile(BitmapSource bitmap, string targetPath, BitmapMetadata? metadata = null, int jpegQuality = 95) + public static void SaveToFile(BitmapSource bitmap, string targetPath, BitmapMetadata? metadata = null, ISettings? settings = null) { var ext = Path.GetExtension(targetPath).ToLowerInvariant(); BitmapEncoder encoder; @@ -62,10 +65,10 @@ public static void SaveToFile(BitmapSource bitmap, string targetPath, BitmapMeta if (_jpegliPath is not null) { Log.Write("Saving using " + _jpegliPath); - JpegliEncoder.SaveToFile(bitmap, targetPath, metadata, jpegQuality, _jpegliPath); + JpegliEncoder.SaveToFile(bitmap, targetPath, metadata, settings?.JpegQuality ?? DefaultJpegQuality, _jpegliPath); return; } - encoder = new JpegBitmapEncoder() { QualityLevel = jpegQuality }; + encoder = new JpegBitmapEncoder() { QualityLevel = settings?.JpegQuality ?? DefaultJpegQuality }; } else if (ext is ".tif" or ".tiff") encoder = new TiffBitmapEncoder(); // Default is best compression @@ -77,7 +80,7 @@ public static void SaveToFile(BitmapSource bitmap, string targetPath, BitmapMeta encoder = new WmpBitmapEncoder() { Lossless = true }; else if (ext is ".jxl") { - JpegXlFileFormatHandler.SaveToFile(bitmap, targetPath, metadata, jpegQuality); + JpegXlFileFormatHandler.SaveToFile(bitmap, targetPath, metadata, settings); return; } else diff --git a/PhotoLocator/PictureFileFormats/JpegTransformations.cs b/PhotoLocator/PictureFileFormats/JpegTransformations.cs index 687eff0..1151de7 100644 --- a/PhotoLocator/PictureFileFormats/JpegTransformations.cs +++ b/PhotoLocator/PictureFileFormats/JpegTransformations.cs @@ -12,7 +12,7 @@ static class JpegTransformations { public static bool IsFileTypeSupported(string fileName) { - return Path.GetExtension(fileName).ToUpperInvariant() is ".JPG" or ".JPEG"; + return Path.GetExtension(fileName).ToLowerInvariant() is ".jpg" or ".jpeg"; } public static void Rotate(string sourceFileName, string newFileName, int angleDegrees) diff --git a/PhotoLocator/PictureFileFormats/JpegXlFileFormatHandler.cs b/PhotoLocator/PictureFileFormats/JpegXlFileFormatHandler.cs index 31a68ff..a3cc7aa 100644 --- a/PhotoLocator/PictureFileFormats/JpegXlFileFormatHandler.cs +++ b/PhotoLocator/PictureFileFormats/JpegXlFileFormatHandler.cs @@ -27,10 +27,12 @@ public static void SaveToStream(BitmapSource bitmap, Stream dest, string encoder JpegliEncoder.Process(encoderPath, srcStream, ".png", dest, ".jxl", $"-q {quality}"); } - public static void SaveToFile(BitmapSource image, string targetPath, BitmapMetadata? metadata, int quality) + public static void SaveToFile(BitmapSource image, string targetPath, BitmapMetadata? metadata = null, Settings.ISettings? settings = null) { - using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write); - SaveToStream(image, stream, EncoderPath, metadata, quality); + using (var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write)) + SaveToStream(image, stream, EncoderPath, metadata, settings?.JpegQuality ?? GeneralFileFormatHandler.DefaultJpegQuality); + if (metadata is not null && !string.IsNullOrEmpty(settings?.ExifToolPath)) + ExifTool.SetMetadataAsync(targetPath, targetPath, metadata, settings.ExifToolPath, CancellationToken.None).Wait(); } public static void TranscodeToJxl(string sourcePath, string targetPath, string? arguments, CancellationToken ct) diff --git a/PhotoLocator/Settings/RegistrySettings.cs b/PhotoLocator/Settings/RegistrySettings.cs index 8cfdba6..02733ee 100644 --- a/PhotoLocator/Settings/RegistrySettings.cs +++ b/PhotoLocator/Settings/RegistrySettings.cs @@ -1,4 +1,5 @@ using Microsoft.Win32; +using PhotoLocator.PictureFileFormats; using System; using System.Windows.Media; @@ -64,7 +65,7 @@ public string SavedFilePostfix public int JpegQuality { - get => Key.GetValue(nameof(JpegQuality)) as int? ?? 90; + get => Key.GetValue(nameof(JpegQuality)) as int? ?? GeneralFileFormatHandler.DefaultJpegQuality; set => Key.SetValue(nameof(JpegQuality), value); } diff --git a/PhotoLocator/VideoTransformCommands.cs b/PhotoLocator/VideoTransformCommands.cs index 50d6fc4..995dba2 100644 --- a/PhotoLocator/VideoTransformCommands.cs +++ b/PhotoLocator/VideoTransformCommands.cs @@ -1045,10 +1045,9 @@ async Task RunAverageProcessingAsync(string outFileName, Stopwatch sw, C { using var process = new AverageFramesOperation(DarkFramePath, ParseRegistrationSettings(), ct); await _videoTransforms.RunFFmpegWithStreamOutputImagesAsync($"{InputArguments} {ProcessArguments}", process.ProcessImage, ProcessStdError, ct).ConfigureAwait(false); - if (process.Supports16BitResult() && Path.GetExtension(outFileName).ToUpperInvariant() is ".PNG" or ".TIF" or ".TIFF" or ".JXR") - GeneralFileFormatHandler.SaveToFile(process.GetResult16(), outFileName, CreateImageMetadata()); - else - GeneralFileFormatHandler.SaveToFile(process.GetResult8(), outFileName, CreateImageMetadata(), _mainViewModel.Settings.JpegQuality); + GeneralFileFormatHandler.SaveToFile( + process.Supports16BitResult() && Path.GetExtension(outFileName).ToLowerInvariant() is ".png" or ".tif" or ".tiff" or ".jxr" ? process.GetResult16() : process.GetResult8(), + outFileName, CreateImageMetadata(), _mainViewModel.Settings); return $"Processed {process.ProcessedImages} frames in {sw.Elapsed.TotalSeconds:N1}s"; } @@ -1056,7 +1055,7 @@ async Task RunMaxProcessingAsync(string outFileName, Stopwatch sw, Cance { using var process = new MaxFramesOperation(DarkFramePath, ParseRegistrationSettings(), ct); await _videoTransforms.RunFFmpegWithStreamOutputImagesAsync($"{InputArguments} {ProcessArguments}", process.ProcessImage, ProcessStdError, ct).ConfigureAwait(false); - GeneralFileFormatHandler.SaveToFile(process.GetResult8(), outFileName, CreateImageMetadata(), _mainViewModel.Settings.JpegQuality); + GeneralFileFormatHandler.SaveToFile(process.GetResult8(), outFileName, CreateImageMetadata(), _mainViewModel.Settings); return $"Processed {process.ProcessedImages} frames in {sw.Elapsed.TotalSeconds:N1}s"; } @@ -1095,7 +1094,7 @@ await _videoTransforms.RunFFmpegWithStreamOutputImagesAsync($"{InputArguments} { var timeSliceImage = CombineFramesMode == CombineFramesMode.TimeSliceInterpolated ? timeSlice.GenerateTimeSliceImageInterpolated() : timeSlice.GenerateTimeSliceImage(); - GeneralFileFormatHandler.SaveToFile(timeSliceImage, outFileName, CreateImageMetadata(), _mainViewModel.Settings.JpegQuality); + GeneralFileFormatHandler.SaveToFile(timeSliceImage, outFileName, CreateImageMetadata(), _mainViewModel.Settings); } else { diff --git a/PhotoLocatorTest/Metadata/ExifHandlerTest.cs b/PhotoLocatorTest/Metadata/ExifHandlerTest.cs index a486f97..1cf25f0 100644 --- a/PhotoLocatorTest/Metadata/ExifHandlerTest.cs +++ b/PhotoLocatorTest/Metadata/ExifHandlerTest.cs @@ -1,4 +1,5 @@ using PhotoLocator.PictureFileFormats; +using PhotoLocator.Settings; using System.Diagnostics; using System.Globalization; using System.Windows.Media; @@ -76,6 +77,33 @@ public void SetMetadata_ShouldSetJpegMetadataOnJpegXr() //Assert.AreEqual(ExifHandler.GetGeotag(source), ExifHandler.GetGeotag(target)); } + [TestMethod] + public void SetMetadata_ShouldSetJpegMetadataOnJpegXl() + { + if (!File.Exists(ExifToolTest.ExifToolPath)) + Assert.Inconclusive("ExifTool not found"); + + const string TestFileName = "fromJpeg.jxl"; + + var settings = new ObservableSettings(); + settings.ExifToolPath = ExifToolTest.ExifToolPath; + + using var sourceStream = GetType().Assembly.GetManifestResourceStream(@"PhotoLocator.TestData.2022-06-17_19.03.02.jpg") + ?? throw new FileNotFoundException("Resource not found"); + var source = ExifHandler.LoadMetadata(sourceStream) ?? throw new Exception("Unable to load metadata"); + Assert.IsFalse(string.IsNullOrEmpty(source.CameraModel)); + + var bitmap = BitmapSource.Create(2, 2, 96, 96, PixelFormats.Gray8, null, new byte[4], 2); + + GeneralFileFormatHandler.SaveToFile(bitmap, TestFileName, source, settings); + + using var targetStream = File.OpenRead(TestFileName); + var target = ExifHandler.LoadMetadata(targetStream)!; + Assert.AreEqual(source.CameraModel, target.CameraModel); + Assert.AreEqual(ExifHandler.GetMetadataString(source, sourceStream), ExifHandler.GetMetadataString(target, targetStream)); + Assert.AreEqual(ExifHandler.GetGeotag(source), ExifHandler.GetGeotag(target)); + } + [TestMethod] public void SetMetadata_ShouldSetJpegMetadataOnPng() { @@ -279,7 +307,7 @@ public void ResetOrientation_ShouldBeApplied_WhenSavingProcessedImage() // Apply ResetOrientation and save var resetMetadata = ExifHandler.ResetOrientation(metadata); - GeneralFileFormatHandler.SaveToFile(bitmap, TestFileName, resetMetadata, 90); + GeneralFileFormatHandler.SaveToFile(bitmap, TestFileName, resetMetadata); // Verify orientation was reset using var targetStream = File.OpenRead(TestFileName); @@ -309,7 +337,7 @@ public void ResetOrientation_ShouldPreserveOtherMetadata() // Create and save bitmap var bitmap = BitmapSource.Create(10, 10, 96, 96, PixelFormats.Bgr24, null, new byte[10 * 10 * 3], 10 * 3); - GeneralFileFormatHandler.SaveToFile(bitmap, TestFileName, resetMetadata, 90); + GeneralFileFormatHandler.SaveToFile(bitmap, TestFileName, resetMetadata); // Verify other metadata is preserved using var targetStream = File.OpenRead(TestFileName); diff --git a/PhotoLocatorTest/PictureFileFormats/JpegXlFileFormatHandlerTest.cs b/PhotoLocatorTest/PictureFileFormats/JpegXlFileFormatHandlerTest.cs index c5c437f..7520776 100644 --- a/PhotoLocatorTest/PictureFileFormats/JpegXlFileFormatHandlerTest.cs +++ b/PhotoLocatorTest/PictureFileFormats/JpegXlFileFormatHandlerTest.cs @@ -20,14 +20,14 @@ public void SaveToFile_ShouldCreateJxl() const string SourcePath = @"TestData\2022-06-17_19.03.02.jpg"; const string TargetPathJxl = @"test.jxl"; + File.Delete(TargetPathJxl); + Debug.WriteLine($"Source size: {new FileInfo(SourcePath).Length / 1024} kb"); using var sourceFile = File.OpenRead(SourcePath); var source = GeneralFileFormatHandler.LoadFromStream(sourceFile, Rotation.Rotate0, int.MaxValue, true, TestContext.CancellationToken); - sourceFile.Position = 0; - var metadata = ExifHandler.LoadMetadata(sourceFile); - JpegXlFileFormatHandler.SaveToFile(source, TargetPathJxl, metadata, 95); + JpegXlFileFormatHandler.SaveToFile(source, TargetPathJxl); Assert.IsTrue(File.Exists(TargetPathJxl), "Target file was not created"); var targetSize = new FileInfo(TargetPathJxl).Length; @@ -45,6 +45,8 @@ public void TranscodeToJxl_ShouldBeLossless() const string SourcePath = @"TestData\2022-06-17_19.03.02.jpg"; var targetJxl = Path.GetFileNameWithoutExtension(SourcePath) + ".jxl"; var restoredJpg = Path.ChangeExtension(targetJxl, ".jpg"); + File.Delete(targetJxl); + File.Delete(restoredJpg); var sourceBytes = File.ReadAllBytes(SourcePath); Debug.WriteLine($"Source size: {sourceBytes.Length / 1024} kb"); @@ -54,12 +56,15 @@ public void TranscodeToJxl_ShouldBeLossless() var targetSize = new FileInfo(targetJxl).Length; Debug.WriteLine($"Target size: {targetSize / 1024} kb"); - Process.Start(new ProcessStartInfo + var decoder = Process.Start(new ProcessStartInfo { FileName = DecoderPath, Arguments = $"\"{targetJxl}\" \"{restoredJpg}\"", CreateNoWindow = true, - })?.WaitForExit(); + RedirectStandardError = true, + }) ?? throw new InvalidOperationException("Failed to start decoder process"); + Debug.WriteLine(decoder.StandardError.ReadToEnd()); + decoder.WaitForExit(); var restoredBytes = File.ReadAllBytes(restoredJpg); Assert.IsTrue(Enumerable.SequenceEqual(sourceBytes, restoredBytes), "The restored image is not identical to the original"); diff --git a/PhotoLocatorTest/PictureFileFormats/JpegliEncoderTest.cs b/PhotoLocatorTest/PictureFileFormats/JpegliEncoderTest.cs index cb4ebe2..26f711c 100644 --- a/PhotoLocatorTest/PictureFileFormats/JpegliEncoderTest.cs +++ b/PhotoLocatorTest/PictureFileFormats/JpegliEncoderTest.cs @@ -28,11 +28,11 @@ public void SaveToFile_ShouldIncludeMetadata() sourceFile.Position = 0; var metadata = ExifHandler.LoadMetadata(sourceFile); - GeneralFileFormatHandler.SaveToFile(source, TargetPathJpeg, metadata, 95); + GeneralFileFormatHandler.SaveToFile(source, TargetPathJpeg, metadata); var sizeJpeg = new FileInfo(TargetPathJpeg).Length; Debug.WriteLine($"Dest size jpeg: {sizeJpeg / 1024} kb"); - JpegliEncoder.SaveToFile(source, TargetPathJpegli, metadata, 94, EncoderPath); + JpegliEncoder.SaveToFile(source, TargetPathJpegli, metadata, GeneralFileFormatHandler.DefaultJpegQuality, EncoderPath); var sizeJpegli = new FileInfo(TargetPathJpegli).Length; Debug.WriteLine($"Dest size jpegli: {sizeJpegli / 1024} kb"); Debug.WriteLine($"{100.0 * sizeJpegli / sizeJpeg:0.0}%"); From 65e5ea44635d16904b2f6b33d1f711bcfeb558ee Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Tue, 2 Jun 2026 22:55:18 +0200 Subject: [PATCH 4/4] cr --- PhotoLocator/ImageTransformCommands.cs | 2 +- PhotoLocator/Metadata/ExifHandler.cs | 2 +- PhotoLocator/Metadata/ExifTool.cs | 6 +++--- PhotoLocator/VideoTransformCommands.cs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/PhotoLocator/ImageTransformCommands.cs b/PhotoLocator/ImageTransformCommands.cs index 583e1c3..3ad6dcb 100644 --- a/PhotoLocator/ImageTransformCommands.cs +++ b/PhotoLocator/ImageTransformCommands.cs @@ -235,7 +235,7 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) if (targetType == "jxl" && Path.GetExtension(item.Name).ToLowerInvariant() is ".jpg" or ".jpeg") { - JpegXlFileFormatHandler.TranscodeToJxl(item.FullPath, targetFileName, null, ct); + await Task.Run(() => JpegXlFileFormatHandler.TranscodeToJxl(item.FullPath, targetFileName, null, ct), ct); } else { diff --git a/PhotoLocator/Metadata/ExifHandler.cs b/PhotoLocator/Metadata/ExifHandler.cs index f6527b4..7c8ecb1 100644 --- a/PhotoLocator/Metadata/ExifHandler.cs +++ b/PhotoLocator/Metadata/ExifHandler.cs @@ -116,7 +116,7 @@ public static BitmapMetadata EncodePngMetadata(Rational? exposureTime, Location? return result; } - internal static (Rational? ExposureTime, Location? Location) DecodePngMetadata(BitmapMetadata metadata) + internal static (Rational? ExposureTime, Location? Location) DecodePngMetadata(BitmapMetadata metadata) { if (metadata.GetQuery("/Text/Description") is not string str) return (null, null); diff --git a/PhotoLocator/Metadata/ExifTool.cs b/PhotoLocator/Metadata/ExifTool.cs index dc7c7c1..7232ebe 100644 --- a/PhotoLocator/Metadata/ExifTool.cs +++ b/PhotoLocator/Metadata/ExifTool.cs @@ -49,13 +49,13 @@ public static async Task SetGeotagAsync(string sourceFileName, string targetFile await RunExifToolAsync(sourceFileName, targetFileName, startInfo, ct); } - public static async Task SetMetadataAsync(string sourceFileName, string targetFileName, BitmapMetadata metadata, string exifToolPath, CancellationToken ct) + public static async Task SetMetadataAsync(string sourceFileName, string targetFileName, BitmapMetadata metadata, string exifToolPath, CancellationToken ct) { var sb = new StringBuilder(); var cameraModel = metadata.CameraModel; if (!string.IsNullOrEmpty(cameraModel)) - sb.Append(CultureInfo.InvariantCulture, $"-Model=\"{cameraModel}\" "); + sb.Append(CultureInfo.InvariantCulture, $"-Model=\"{cameraModel.Replace('"', '\'')}\" "); var location = ExifHandler.GetGeotag(metadata); if (location is not null) @@ -87,7 +87,7 @@ public static async Task SetMetadataAsync(string sourceFileName, string targetF return; sb.Append(CultureInfo.InvariantCulture, $"\"{sourceFileName}\""); var startInfo = new ProcessStartInfo(exifToolPath, sb.ToString()); - await RunExifToolAsync(sourceFileName, targetFileName, startInfo, ct); + await RunExifToolAsync(sourceFileName, targetFileName, startInfo, ct).ConfigureAwait(false); } private static string GetLocationParameters(Location location) diff --git a/PhotoLocator/VideoTransformCommands.cs b/PhotoLocator/VideoTransformCommands.cs index 995dba2..3de34a3 100644 --- a/PhotoLocator/VideoTransformCommands.cs +++ b/PhotoLocator/VideoTransformCommands.cs @@ -1046,7 +1046,7 @@ async Task RunAverageProcessingAsync(string outFileName, Stopwatch sw, C using var process = new AverageFramesOperation(DarkFramePath, ParseRegistrationSettings(), ct); await _videoTransforms.RunFFmpegWithStreamOutputImagesAsync($"{InputArguments} {ProcessArguments}", process.ProcessImage, ProcessStdError, ct).ConfigureAwait(false); GeneralFileFormatHandler.SaveToFile( - process.Supports16BitResult() && Path.GetExtension(outFileName).ToLowerInvariant() is ".png" or ".tif" or ".tiff" or ".jxr" ? process.GetResult16() : process.GetResult8(), + process.Supports16BitResult() && Path.GetExtension(outFileName).ToLowerInvariant() is ".png" or ".tif" or ".tiff" or ".jxr" or ".jxl" ? process.GetResult16() : process.GetResult8(), outFileName, CreateImageMetadata(), _mainViewModel.Settings); return $"Processed {process.ProcessedImages} frames in {sw.Elapsed.TotalSeconds:N1}s"; }