diff --git a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/CursorProvider.cs b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/CursorProvider.cs new file mode 100644 index 0000000..370b5f7 --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/CursorProvider.cs @@ -0,0 +1,365 @@ +// Copyright © 2017-2025 QL-Win Contributors +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using ImageMagick; +using ImageMagick.Formats; +using QuickLook.Common.Helpers; +using QuickLook.Common.Plugin; +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Input; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using PixelFormat = System.Drawing.Imaging.PixelFormat; +using Size = System.Windows.Size; + +namespace QuickLook.Plugin.ImageViewer.AnimatedImage.Providers; + +/// +/// Provided for `.cur` and `.ani` cursor file +/// +internal class CursorProvider : ImageMagickProvider +{ + private bool _isPlaying; + + public CursorProvider(Uri path, MetaProvider meta, ContextObject contextObject) : base(path, meta, contextObject) + { + } + +#if false // Not supporting thumbnails would be better + public override Task GetThumbnail(Size renderSize) + { + return new Task(() => + { + nint hIcon = IntPtr.Zero; + + try + { + hIcon = NativeMethods.ExtractIcon(IntPtr.Zero, Path.LocalPath, 0); + + if (hIcon != IntPtr.Zero) + { + var bitmapSource = Imaging.CreateBitmapSourceFromHIcon( + hIcon, + Int32Rect.Empty, + BitmapSizeOptions.FromEmptyOptions() + ); + + bitmapSource.Freeze(); + return bitmapSource; + } + } + catch (Exception e) + { + ProcessHelper.WriteLog(e.ToString()); + return null; + } + finally + { + if (hIcon != IntPtr.Zero) + NativeMethods.DestroyIcon(hIcon); + } + + return null; + }); + } +#endif + + public override Task GetRenderedFrame(int index) + { + return new Task(() => + { + var settings = new MagickReadSettings + { + BackgroundColor = MagickColors.None, + Defines = new DngReadDefines + { + OutputColor = DngOutputColor.SRGB, + UseCameraWhiteBalance = true, + DisableAutoBrightness = false + } + }; + + try + { + if (Path.LocalPath.ToLower().EndsWith(".ani")) + { + return AnimatedCursor(Path.LocalPath); + } + + return base.GetRenderedFrame(); + } + catch (Exception e) + { + ProcessHelper.WriteLog(e.ToString()); + return null; + } + }); + } + + public override void Dispose() + { + _isPlaying = false; + base.Dispose(); + } + + public BitmapSource AnimatedCursor(string path) + { + var aniCursor = AniCursorLoader.LoadAniCursor(path); + var frames = aniCursor.ToArray(); + var animatedImg = new AniCursor(frames, frames.Count()); + + var writeableBitmap = Application.Current.Dispatcher.Invoke(() => + { + var frame = animatedImg.Frames.ElementAt(0); + var bitmap = frame.Bitmap; + return bitmap.ToWriteableBitmap(); + }); + + _isPlaying = true; + _ = Task.Factory.StartNew(() => + { + while (_isPlaying) + { + foreach (var frame in animatedImg.Frames) + { + if (!_isPlaying) break; + + writeableBitmap.Dispatcher.Invoke(() => + { + var bitmap = frame.Bitmap; + bitmap.CopyToWriteableBitmap(writeableBitmap); + }); + + Thread.Sleep((int)frame.Duration); + } + } + + animatedImg?.Dispose(); + animatedImg = null; + }, TaskCreationOptions.LongRunning); + + return writeableBitmap; + } + + public static Cursor GetCursor(string path) + { + try + { + Cursor customCursor = new(path); + return customCursor; + } + catch (Exception e) + { + ProcessHelper.WriteLog(e.ToString()); + } + return null; + } +} + +file static class AniCursorLoader +{ + public static IEnumerable LoadAniCursor(string aniFilePath) + { + using var fs = new FileStream(aniFilePath, FileMode.Open, FileAccess.Read); + using var reader = new BinaryReader(fs); + + reader.BaseStream.Seek(16, SeekOrigin.Begin); + int totalFrames = reader.ReadInt16(); + + // TODO: Implement animated images + _ = totalFrames; + + // TODO: Show only first frame now + nint hIcon = IntPtr.Zero; + + try + { + // Get the first frame + hIcon = NativeMethods.ExtractIcon(IntPtr.Zero, aniFilePath, 0); + + if (hIcon != IntPtr.Zero) + { + using Icon icon = Icon.FromHandle(hIcon); + Bitmap bitmap = icon.ToBitmap(); + yield return new AniCursorFrame(bitmap, 0); + } + } + finally + { + if (hIcon != IntPtr.Zero) + NativeMethods.DestroyIcon(hIcon); + } + } +} + +file static class NativeMethods +{ + [DllImport("shell32.dll", SetLastError = true)] + public static extern nint ExtractIcon(nint hInst, string lpszExeFileName, int nIconIndex); + + [DllImport("user32.dll", SetLastError = true)] + public static extern bool DestroyIcon(nint hIcon); +} + +file sealed class AniCursor(IEnumerable frames, int? frameCount = null) : IDisposable +{ + public bool IsDisposed = false; + + public void Dispose(bool disposing) + { + if (IsDisposed) + return; + + if (disposing) + { + // Free any other managed objects here. + FrameCount = 0; + Frames = []; + } + + // Free any unmanaged objects here. + IsDisposed = true; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + ~AniCursor() + { + Dispose(false); + } + + public IEnumerable Frames { get; private set; } = frames; + + public int FrameCount { get; private set; } = frameCount ?? frames.Count(); + + public AniCursorFrame GetFrame(int frameIndex) + { + try + { + return Frames.ElementAtOrDefault(frameIndex); + } + catch { } + + return null; + } +} + +file sealed class AniCursorFrame(Bitmap frame, uint duration) : IDisposable +{ + public Bitmap Bitmap { get; set; } = frame; + + public uint Duration { get; set; } = duration; + + public void Dispose() + { + Bitmap?.Dispose(); + } +} + +file static class Extension +{ + public static WriteableBitmap ToWriteableBitmap(this Bitmap bitmap) + { + if (bitmap == null) throw new ArgumentNullException(nameof(bitmap)); + + var pixelFormat = bitmap.PixelFormat; + var width = bitmap.Width; + var height = bitmap.Height; + + var wpfPixelFormat = pixelFormat switch + { + PixelFormat.Format32bppArgb => PixelFormats.Bgra32, + PixelFormat.Format24bppRgb => PixelFormats.Bgr24, + _ => throw new NotSupportedException($"Unsupported PixelFormat: {pixelFormat}") + }; + + var writeableBitmap = new WriteableBitmap(width, height, 96, 96, wpfPixelFormat, null); + + var bitmapData = bitmap.LockBits( + new Rectangle(0, 0, width, height), + ImageLockMode.ReadOnly, + pixelFormat); + + try + { + writeableBitmap.Lock(); + unsafe + { + Buffer.MemoryCopy( + source: bitmapData.Scan0.ToPointer(), + destination: writeableBitmap.BackBuffer.ToPointer(), + destinationSizeInBytes: writeableBitmap.BackBufferStride * height, + sourceBytesToCopy: bitmapData.Stride * height); + } + + writeableBitmap.AddDirtyRect(new Int32Rect(0, 0, width, height)); + } + finally + { + bitmap.UnlockBits(bitmapData); + writeableBitmap.Unlock(); + } + + return writeableBitmap; + } + + public static void CopyToWriteableBitmap(this Bitmap bitmap, WriteableBitmap writeableBitmap) + { + var pixelFormat = bitmap.PixelFormat; + var width = bitmap.Width; + var height = bitmap.Height; + + var bitmapData = bitmap.LockBits( + new Rectangle(0, 0, width, height), + ImageLockMode.ReadOnly, + pixelFormat); + + try + { + writeableBitmap.Lock(); + unsafe + { + Buffer.MemoryCopy( + source: bitmapData.Scan0.ToPointer(), + destination: writeableBitmap.BackBuffer.ToPointer(), + destinationSizeInBytes: writeableBitmap.BackBufferStride * height, + sourceBytesToCopy: bitmapData.Stride * height); + } + + writeableBitmap.AddDirtyRect(new Int32Rect(0, 0, width, height)); + } + finally + { + bitmap.UnlockBits(bitmapData); + writeableBitmap.Unlock(); + } + } +} diff --git a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/ImageMagickProvider.cs b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/ImageMagickProvider.cs index 17872d6..50b105e 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/ImageMagickProvider.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/ImageMagickProvider.cs @@ -196,6 +196,18 @@ internal class ImageMagickProvider : AnimationProvider return new TransformedBitmap(image, transforms); } + + protected bool IsImageMagickSupported(string path) + { + try + { + return new MagickImageInfo(path).Format != MagickFormat.Unknown; + } + catch + { + return false; + } + } } file static class Extension diff --git a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/Plugin.cs b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/Plugin.cs index 4d49784..d74039e 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/Plugin.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/Plugin.cs @@ -18,7 +18,9 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Windows; +using System.Windows.Input; using ImageMagick; using QuickLook.Common.Helpers; using QuickLook.Common.Plugin; @@ -30,9 +32,9 @@ public class Plugin : IViewer { private static readonly HashSet WellKnownImageExtensions = new( [ - ".apng", ".ari", ".arw", ".avif", + ".apng", ".ari", ".arw", ".avif", ".ani", ".bay", ".bmp", - ".cap", ".cr2", ".cr3", ".crw", + ".cap", ".cr2", ".cr3", ".crw", ".cur", ".dcr", ".dcs", ".dds", ".dng", ".drf", ".eip", ".emf", ".erf", ".exr", ".fff", @@ -79,6 +81,9 @@ public class Plugin : IViewer AnimatedImage.AnimatedImage.Providers.Add( new KeyValuePair([".webp"], typeof(WebPProvider))); + AnimatedImage.AnimatedImage.Providers.Add( + new KeyValuePair([".cur", ".ani"], + typeof(CursorProvider))); AnimatedImage.AnimatedImage.Providers.Add( new KeyValuePair(["*"], typeof(ImageMagickProvider))); @@ -89,24 +94,11 @@ public class Plugin : IViewer return WellKnownImageExtensions.Contains(Path.GetExtension(path.ToLower())); } - private bool IsImageMagickSupported(string path) - { - try - { - return new MagickImageInfo(path).Format != MagickFormat.Unknown; - } - catch - { - return false; - } - } - public bool CanHandle(string path) { // Disabled due mishandling text file types e.g., "*.config". // Only check extension for well known image and animated image types. - // For other image formats, let ImageMagick try to detect by file content. - return !Directory.Exists(path) && (IsWellKnownImageExtension(path)); // || IsImageMagickSupported(path)); + return !Directory.Exists(path) && (IsWellKnownImageExtension(path)); } public void Prepare(string path, ContextObject context) @@ -134,6 +126,12 @@ public class Plugin : IViewer : $"{size.Width}×{size.Height}: {Path.GetFileName(path)}"; _ip.ImageUriSource = Helper.FilePathToFileUrl(path); + + // Load the custom cursor into the preview panel + if (new string[] { ".cur", ".ani" }.Any(path.ToLower().EndsWith)) + { + _ip.Cursor = CursorProvider.GetCursor(path) ?? Cursors.Arrow; + } } public void Cleanup()