From 30074ea156ecea99abbc39323726904b3884449f Mon Sep 17 00:00:00 2001 From: bobo198504 <32607316+bobo198504@users.noreply.github.com> Date: Wed, 11 Nov 2026 01:46:33 +0800 Subject: [PATCH] Bound animated WebP decoding resources Validate animated WebP metadata before libwebp decodes frames, cap frame memory and count, materialize the lazy frame sequence only once, and cancel playback cleanly on dispose. --- .../Providers/WebPAnimationInfo.cs | 206 ++++++++++ .../AnimatedImage/Providers/WebPProvider.cs | 376 +++++++++++++++--- 2 files changed, 534 insertions(+), 48 deletions(-) create mode 100644 QuickLook.Plugin/QuickLook.Plugin.ImageViewer/AnimatedImage/Providers/WebPAnimationInfo.cs 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}"); + } + } } }