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}%");