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