diff --git a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/WebPAnimationInfo.cs b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/WebPAnimationInfo.cs
new file mode 100644
index 000000000..77dbeaf6b
--- /dev/null
+++ b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/WebPAnimationInfo.cs
@@ -0,0 +1,206 @@
+// Copyright © 2017-2026 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 System;
+using System.IO;
+
+namespace QuickLook.Plugin.ImageViewer.AnimatedImage.Providers;
+
+internal enum WebPAnimationInspectionStatus
+{
+ NotAnimated,
+ Animated,
+ Invalid
+}
+
+internal sealed class WebPAnimationInfo
+{
+ public int Width { get; set; }
+
+ public int Height { get; set; }
+
+ public int FrameCount { get; set; }
+}
+
+///
+/// Reads only the WebP container metadata needed to decide whether an animation is safe to decode.
+/// Image data is deliberately skipped so a malformed file cannot allocate frame-sized buffers here.
+///
+internal static class WebPAnimationInspector
+{
+ private const byte AnimationFlag = 0x02;
+
+ public static WebPAnimationInspectionStatus Inspect(
+ string fileName,
+ int maxFrameCount,
+ out WebPAnimationInfo info,
+ out string error)
+ {
+ info = new WebPAnimationInfo();
+ error = null;
+
+ try
+ {
+ using var stream = new FileStream(
+ fileName,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.ReadWrite | FileShare.Delete);
+
+ if (stream.Length < 12)
+ return WebPAnimationInspectionStatus.NotAnimated;
+
+ var header = new byte[12];
+ ReadExactly(stream, header, header.Length);
+
+ if (!IsFourCc(header, 0, "RIFF") || !IsFourCc(header, 8, "WEBP"))
+ return WebPAnimationInspectionStatus.NotAnimated;
+
+ var riffSize = BitConverter.ToUInt32(header, 4);
+ if (riffSize < 4)
+ return Invalid(info, out error, "RIFF 容器长度无效");
+
+ var riffEnd = 8L + riffSize;
+ if (riffEnd > stream.Length)
+ return Invalid(info, out error, "RIFF 容器被截断");
+
+ var hasVp8x = false;
+ var animationFlagSet = false;
+ var hasAnimChunk = false;
+ var position = 12L;
+ var chunkHeader = new byte[16];
+
+ while (position + 8 <= riffEnd)
+ {
+ stream.Position = position;
+ ReadExactly(stream, chunkHeader, 8);
+
+ var chunkSize = BitConverter.ToUInt32(chunkHeader, 4);
+ var dataStart = position + 8;
+ var nextPosition = dataStart + chunkSize + (chunkSize & 1);
+
+ if (nextPosition < dataStart || nextPosition > riffEnd)
+ return Invalid(info, out error, "WebP 数据块长度无效");
+
+ if (IsFourCc(chunkHeader, 0, "VP8X"))
+ {
+ if (hasVp8x || chunkSize < 10)
+ return Invalid(info, out error, "VP8X 数据块无效");
+
+ hasVp8x = true;
+ stream.Position = dataStart;
+ ReadExactly(stream, chunkHeader, 10);
+
+ animationFlagSet = (chunkHeader[0] & AnimationFlag) != 0;
+ info.Width = ReadUInt24LittleEndian(chunkHeader, 4) + 1;
+ info.Height = ReadUInt24LittleEndian(chunkHeader, 7) + 1;
+ }
+ else if (IsFourCc(chunkHeader, 0, "ANIM"))
+ {
+ if (chunkSize < 6)
+ return Invalid(info, out error, "ANIM 数据块无效");
+
+ hasAnimChunk = true;
+ }
+ else if (IsFourCc(chunkHeader, 0, "ANMF"))
+ {
+ if (chunkSize < 16 || !hasVp8x || info.Width <= 0 || info.Height <= 0)
+ return Invalid(info, out error, "ANMF 数据块缺少有效的画布信息");
+
+ if (info.FrameCount >= maxFrameCount)
+ return Invalid(info, out error, $"动画帧数超过上限 {maxFrameCount}");
+
+ stream.Position = dataStart;
+ ReadExactly(stream, chunkHeader, 12);
+
+ var frameX = ReadUInt24LittleEndian(chunkHeader, 0) * 2L;
+ var frameY = ReadUInt24LittleEndian(chunkHeader, 3) * 2L;
+ var frameWidth = ReadUInt24LittleEndian(chunkHeader, 6) + 1L;
+ var frameHeight = ReadUInt24LittleEndian(chunkHeader, 9) + 1L;
+
+ if (frameX + frameWidth > info.Width || frameY + frameHeight > info.Height)
+ return Invalid(info, out error, "动画帧超出 WebP 画布范围");
+
+ info.FrameCount++;
+ }
+
+ position = nextPosition;
+ }
+
+ if (!hasVp8x)
+ {
+ return info.FrameCount > 0
+ ? Invalid(info, out error, "动画 WebP 缺少 VP8X 数据块")
+ : WebPAnimationInspectionStatus.NotAnimated;
+ }
+
+ if (!animationFlagSet)
+ {
+ return info.FrameCount > 0
+ ? Invalid(info, out error, "WebP 动画标志与 ANMF 数据块不一致")
+ : WebPAnimationInspectionStatus.NotAnimated;
+ }
+
+ if (!hasAnimChunk || info.FrameCount < 2)
+ return Invalid(info, out error, "动画 WebP 的帧信息不完整");
+
+ return WebPAnimationInspectionStatus.Animated;
+ }
+ catch (Exception e) when (e is IOException || e is UnauthorizedAccessException || e is ArgumentException)
+ {
+ return Invalid(info, out error, e.Message);
+ }
+ }
+
+ private static WebPAnimationInspectionStatus Invalid(
+ WebPAnimationInfo info,
+ out string error,
+ string message)
+ {
+ error = message;
+ return WebPAnimationInspectionStatus.Invalid;
+ }
+
+ private static void ReadExactly(Stream stream, byte[] buffer, int count)
+ {
+ var offset = 0;
+
+ while (offset < count)
+ {
+ var read = stream.Read(buffer, offset, count - offset);
+ if (read == 0)
+ throw new EndOfStreamException("WebP 数据意外结束");
+
+ offset += read;
+ }
+ }
+
+ private static bool IsFourCc(byte[] buffer, int offset, string value)
+ {
+ return buffer[offset] == value[0]
+ && buffer[offset + 1] == value[1]
+ && buffer[offset + 2] == value[2]
+ && buffer[offset + 3] == value[3];
+ }
+
+ private static int ReadUInt24LittleEndian(byte[] buffer, int offset)
+ {
+ return buffer[offset]
+ | (buffer[offset + 1] << 8)
+ | (buffer[offset + 2] << 16);
+ }
+}
diff --git a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/WebPProvider.cs b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/WebPProvider.cs
index d793ef39f..b9c437e23 100644
--- a/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/WebPProvider.cs
+++ b/QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/WebPProvider.cs
@@ -17,25 +17,34 @@
using ImageGlass.Base.Photoing.Codecs;
using ImageGlass.WebP;
-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.Linq;
+using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
+using System.Windows.Threading;
namespace QuickLook.Plugin.ImageViewer.AnimatedImage.Providers;
internal class WebPProvider : ImageMagickProvider
{
- private bool _isPlaying;
+ private const int MaxAnimationFrameCount = 1000;
+ private const int MaxCanvasDimension = 16384;
+ private const int BytesPerPixel = 4;
+ private const long MaxSourceFileBytes = 256L * 1024 * 1024;
+ private const long MaxDecodedAnimationBytes = 512L * 1024 * 1024;
+ private const long MaxStaticFrameBytes = 128L * 1024 * 1024;
+
+ private readonly object _playbackLock = new();
+ private CancellationTokenSource _playbackCancellation;
+ private bool _disposed;
public WebPProvider(Uri path, MetaProvider meta, ContextObject contextObject) : base(path, meta, contextObject)
{
@@ -45,98 +54,369 @@ 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,
- }
- };
+ WebPAnimationInfo animationInfo = null;
try
{
// Unfortunately we only support Animated WebP on x64 platforms
if (Environment.Is64BitProcess)
{
- var layers = MagickImageInfo.ReadCollection(Path.LocalPath);
- int count = layers.Count();
+ var status = WebPAnimationInspector.Inspect(
+ Path.LocalPath,
+ MaxAnimationFrameCount,
+ out animationInfo,
+ out var error);
- // Animated WebP image
- if (count > 1)
+ if (status == WebPAnimationInspectionStatus.Invalid)
{
- return AnimatedWebP(Path.LocalPath);
+ return GetStaticFallback(
+ animationInfo,
+ $"Animated WebP rejected before decoding: {error}");
}
- else
+
+ if (status == WebPAnimationInspectionStatus.Animated)
{
- return base.GetRenderedFrame();
+ var fileLength = new FileInfo(Path.LocalPath).Length;
+ if (fileLength > MaxSourceFileBytes)
+ {
+ return GetStaticFallback(
+ animationInfo,
+ $"Animated WebP source is too large ({fileLength} bytes)");
+ }
+
+ if (!TryValidateAnimation(animationInfo, out error))
+ {
+ return GetStaticFallback(
+ animationInfo,
+ $"Animated WebP exceeds decode limits: {error}");
+ }
+
+ return AnimatedWebP(Path.LocalPath, animationInfo);
}
}
return base.GetRenderedFrame();
}
+ catch (OutOfMemoryException e)
+ {
+ StopPlayback();
+ return GetStaticFallback(
+ animationInfo,
+ $"Out of memory while decoding WebP: {e.Message}");
+ }
catch (Exception e)
{
- ProcessHelper.WriteLog(e.ToString());
- return null!;
+ StopPlayback();
+ return GetStaticFallback(
+ animationInfo,
+ $"Failed to decode animated WebP: {e}");
}
});
}
public override void Dispose()
{
- _isPlaying = false;
+ CancellationTokenSource playbackCancellation;
+
+ lock (_playbackLock)
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ playbackCancellation = _playbackCancellation;
+ _playbackCancellation = null;
+ }
+
+ playbackCancellation?.Cancel();
base.Dispose();
}
- private BitmapSource AnimatedWebP(string fileName)
+ private BitmapSource AnimatedWebP(string fileName, WebPAnimationInfo animationInfo)
{
using var webP = new WebPWrapper();
+ var frames = DecodeAnimationFrames(webP, fileName, animationInfo);
+ AnimatedImg animatedImg = null;
- var aniWebP = webP.AnimLoad(fileName);
- var frames = aniWebP.Select(frame =>
+ try
{
- var duration = frame.Duration > 0 ? frame.Duration : 100;
- var bitmap = frame.Bitmap;
+ animatedImg = new AnimatedImg(frames, frames.Count);
- return new AnimatedImgFrame(frame.Bitmap, (uint)duration);
- });
+ var writeableBitmap = Application.Current.Dispatcher.Invoke(() =>
+ {
+ var frame = animatedImg.Frames.GetEnumerator();
+ try
+ {
+ if (!frame.MoveNext() || frame.Current?.Bitmap is not Bitmap bitmap)
+ throw new InvalidDataException("Animated WebP contains no usable frames");
- var animatedImg = new AnimatedImg(frames, frames.Count());
+ return bitmap.ToWriteableBitmap();
+ }
+ finally
+ {
+ frame.Dispose();
+ }
+ });
- var writeableBitmap = Application.Current.Dispatcher.Invoke(() =>
+ if (StartPlayback(animatedImg, writeableBitmap))
+ animatedImg = null;
+
+ return writeableBitmap;
+ }
+ catch
{
- var frame = animatedImg.Frames.ElementAt(0);
- var bitmap = (Bitmap)frame.Bitmap;
- return bitmap.ToWriteableBitmap();
- });
+ if (animatedImg != null)
+ DisposeAnimatedImage(animatedImg);
+ else
+ DisposeFrames(frames);
+
+ throw;
+ }
+ }
- _isPlaying = true;
- _ = Task.Factory.StartNew(() =>
+ private static List DecodeAnimationFrames(
+ WebPWrapper webP,
+ string fileName,
+ WebPAnimationInfo animationInfo)
+ {
+ var frames = new List(animationInfo.FrameCount);
+
+ try
+ {
+ foreach (var frameData in webP.AnimLoad(fileName))
+ {
+ var bitmap = frameData?.Bitmap;
+ if (bitmap == null)
+ throw new InvalidDataException("Animated WebP returned an empty frame");
+
+ if (bitmap.Width != animationInfo.Width || bitmap.Height != animationInfo.Height)
+ {
+ bitmap.Dispose();
+ throw new InvalidDataException(
+ $"Animated WebP frame size {bitmap.Width}x{bitmap.Height} does not match " +
+ $"the declared canvas {animationInfo.Width}x{animationInfo.Height}");
+ }
+
+ var duration = frameData.Duration > 0
+ ? Math.Min(frameData.Duration, 60_000)
+ : 100;
+
+ frames.Add(new AnimatedImgFrame(bitmap, (uint)duration));
+ }
+
+ if (frames.Count != animationInfo.FrameCount)
+ {
+ throw new InvalidDataException(
+ $"Animated WebP decoded {frames.Count} frames, expected {animationInfo.FrameCount}");
+ }
+
+ return frames;
+ }
+ catch
+ {
+ DisposeFrames(frames);
+ throw;
+ }
+ }
+
+ private bool StartPlayback(AnimatedImg animatedImg, WriteableBitmap writeableBitmap)
+ {
+ var cancellation = new CancellationTokenSource();
+ CancellationTokenSource previousCancellation;
+
+ lock (_playbackLock)
+ {
+ if (_disposed)
+ {
+ cancellation.Dispose();
+ DisposeAnimatedImage(animatedImg);
+ return false;
+ }
+
+ previousCancellation = _playbackCancellation;
+ _playbackCancellation = cancellation;
+ }
+
+ previousCancellation?.Cancel();
+
+ try
+ {
+ _ = Task.Factory.StartNew(
+ () => PlayAnimation(animatedImg, writeableBitmap, cancellation),
+ CancellationToken.None,
+ TaskCreationOptions.LongRunning,
+ TaskScheduler.Default);
+ return true;
+ }
+ catch (Exception e)
+ {
+ lock (_playbackLock)
+ {
+ if (ReferenceEquals(_playbackCancellation, cancellation))
+ _playbackCancellation = null;
+ }
+
+ cancellation.Dispose();
+ DisposeAnimatedImage(animatedImg);
+ ProcessHelper.WriteLog($"Unable to start animated WebP playback: {e}");
+ return false;
+ }
+ }
+
+ private void PlayAnimation(
+ AnimatedImg animatedImg,
+ WriteableBitmap writeableBitmap,
+ CancellationTokenSource cancellation)
+ {
+ var token = cancellation.Token;
+
+ try
{
- while (_isPlaying)
+ while (!token.IsCancellationRequested)
{
foreach (var frame in animatedImg.Frames)
{
- if (!_isPlaying) break;
+ token.ThrowIfCancellationRequested();
+
+ if (frame?.Bitmap is not Bitmap bitmap)
+ throw new InvalidDataException("Animated WebP playback encountered an empty frame");
writeableBitmap.Dispatcher.Invoke(() =>
{
- var bitmap = (Bitmap)frame.Bitmap;
+ token.ThrowIfCancellationRequested();
bitmap.CopyToWriteableBitmap(writeableBitmap);
- });
+ }, DispatcherPriority.Normal, token);
+
+ var delay = frame.Duration <= TimeSpan.Zero
+ ? 100
+ : Math.Min((int)frame.Duration.TotalMilliseconds, 60_000);
- Thread.Sleep((int)frame.Duration.TotalMilliseconds);
+ if (token.WaitHandle.WaitOne(delay))
+ break;
}
}
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception e)
+ {
+ ProcessHelper.WriteLog($"Animated WebP playback stopped: {e}");
+ }
+ finally
+ {
+ DisposeAnimatedImage(animatedImg);
+ cancellation.Dispose();
- animatedImg?.Dispose();
- animatedImg = null;
- }, TaskCreationOptions.LongRunning);
+ lock (_playbackLock)
+ {
+ if (ReferenceEquals(_playbackCancellation, cancellation))
+ _playbackCancellation = null;
+ }
+ }
+ }
- return writeableBitmap;
+ private void StopPlayback()
+ {
+ CancellationTokenSource playbackCancellation;
+
+ lock (_playbackLock)
+ {
+ playbackCancellation = _playbackCancellation;
+ _playbackCancellation = null;
+ }
+
+ playbackCancellation?.Cancel();
+ }
+
+ private BitmapSource GetStaticFallback(WebPAnimationInfo animationInfo, string message)
+ {
+ ProcessHelper.WriteLog(message);
+
+ if (!CanDecodeStaticFrame(animationInfo))
+ return null!;
+
+ try
+ {
+ return base.GetRenderedFrame();
+ }
+ catch (OutOfMemoryException e)
+ {
+ ProcessHelper.WriteLog($"Unable to render static WebP fallback: {e.Message}");
+ return null!;
+ }
+ catch (Exception e)
+ {
+ ProcessHelper.WriteLog($"Unable to render static WebP fallback: {e}");
+ return null!;
+ }
+ }
+
+ private static bool TryValidateAnimation(WebPAnimationInfo info, out string error)
+ {
+ if (info.Width <= 0 || info.Height <= 0)
+ {
+ error = "canvas dimensions are invalid";
+ return false;
+ }
+
+ if (info.Width > MaxCanvasDimension || info.Height > MaxCanvasDimension)
+ {
+ error = $"canvas {info.Width}x{info.Height} exceeds {MaxCanvasDimension}px";
+ return false;
+ }
+
+ var frameBytes = (long)info.Width * info.Height * BytesPerPixel;
+ var requiredBytesWithOutputBuffer = frameBytes * (info.FrameCount + 1L);
+
+ if (requiredBytesWithOutputBuffer > MaxDecodedAnimationBytes)
+ {
+ error =
+ $"{info.FrameCount} frames at {info.Width}x{info.Height} would require " +
+ $"{requiredBytesWithOutputBuffer} bytes (limit {MaxDecodedAnimationBytes})";
+ return false;
+ }
+
+ error = null;
+ return true;
+ }
+
+ private static bool CanDecodeStaticFrame(WebPAnimationInfo info)
+ {
+ if (info == null || info.Width <= 0 || info.Height <= 0)
+ return false;
+
+ if (info.Width > MaxCanvasDimension || info.Height > MaxCanvasDimension)
+ return false;
+
+ return (long)info.Width * info.Height * BytesPerPixel <= MaxStaticFrameBytes;
+ }
+
+ private static void DisposeAnimatedImage(AnimatedImg animatedImg)
+ {
+ try
+ {
+ animatedImg.Dispose();
+ }
+ catch (Exception e)
+ {
+ ProcessHelper.WriteLog($"Unable to release animated WebP frames: {e}");
+ }
+ }
+
+ private static void DisposeFrames(IEnumerable frames)
+ {
+ foreach (var frame in frames)
+ {
+ try
+ {
+ frame.Dispose();
+ }
+ catch (Exception e)
+ {
+ ProcessHelper.WriteLog($"Unable to release animated WebP frame: {e}");
+ }
+ }
}
}