diff --git a/PhotoLocator/ImageTransformCommands.cs b/PhotoLocator/ImageTransformCommands.cs index 1705c1f..3ad6dcb 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); } @@ -233,10 +233,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") + { + await Task.Run(() => JpegXlFileFormatHandler.TranscodeToJxl(item.FullPath, targetFileName, null, ct), ct); + } + else + { + var (image, itemMetadata) = await LoadImageWithMetadataAsync(item); + await Task.Run(() => GeneralFileFormatHandler.SaveToFile(image, targetFileName, + ExifHandler.ResetOrientation(itemMetadata), _mainViewModel.Settings), 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/Metadata/ExifHandler.cs b/PhotoLocator/Metadata/ExifHandler.cs index 7521b42..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; } - 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..7232ebe 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.Replace('"', '\'')}\" "); + + 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 startInfo = new ProcessStartInfo(exifToolPath, string.Create(CultureInfo.InvariantCulture, - //"-m " + // Ignore minor errors and warnings + 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 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).ConfigureAwait(false); + } + + 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 0a6831c..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; @@ -9,7 +10,9 @@ 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"; + + 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 image, 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 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, 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 @@ -75,13 +78,18 @@ 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(bitmap, targetPath, metadata, settings); + 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/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 new file mode 100644 index 0000000..a3cc7aa --- /dev/null +++ b/PhotoLocator/PictureFileFormats/JpegXlFileFormatHandler.cs @@ -0,0 +1,72 @@ +using PhotoLocator.Helpers; +using PhotoLocator.Metadata; +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; + +namespace PhotoLocator.PictureFileFormats +{ + public static class JpegXlFileFormatHandler + { + public const string EncoderName = "cjxl.exe"; + internal static string EncoderPath { get; } = Path.Combine(AppContext.BaseDirectory, "jpegli", EncoderName); + + public static void SaveToStream(BitmapSource bitmap, Stream dest, string encoderPath, BitmapMetadata? metadata, int quality) + { + using var srcStream = new MemoryStream(); + var encoder = new PngBitmapEncoder(); + 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; + JpegliEncoder.Process(encoderPath, srcStream, ".png", dest, ".jxl", $"-q {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, 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) + { + 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); + } + } + } +} diff --git a/PhotoLocator/PictureFileFormats/JpegliEncoder.cs b/PhotoLocator/PictureFileFormats/JpegliEncoder.cs index 9f4dae0..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; @@ -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); } @@ -62,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"); @@ -71,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 { @@ -83,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 fd77226..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; @@ -6,7 +7,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"); @@ -64,7 +65,7 @@ public string SavedFilePostfix public int JpegQuality { - get => Key.GetValue(nameof(JpegQuality)) as int? ?? 93; + 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..3de34a3 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" or ".jxl" ? 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 new file mode 100644 index 0000000..7520776 --- /dev/null +++ b/PhotoLocatorTest/PictureFileFormats/JpegXlFileFormatHandlerTest.cs @@ -0,0 +1,73 @@ +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"; + + 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); + + JpegXlFileFormatHandler.SaveToFile(source, TargetPathJxl); + + 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 TranscodeToJxl_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"; + 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"); + + JpegXlFileFormatHandler.TranscodeToJxl(SourcePath, targetJxl, null, TestContext.CancellationToken); + + var targetSize = new FileInfo(targetJxl).Length; + Debug.WriteLine($"Target size: {targetSize / 1024} kb"); + + var decoder = Process.Start(new ProcessStartInfo + { + FileName = DecoderPath, + Arguments = $"\"{targetJxl}\" \"{restoredJpg}\"", + CreateNoWindow = true, + 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}%");