refactor(525): extract RemoteImageDecodeBudget from ImageElementBase

This commit is contained in:
2026-07-21 12:00:01 +02:00
parent e450d4983c
commit f795db0731
4 changed files with 117 additions and 138 deletions
@@ -1,5 +1,6 @@
using System.Runtime.InteropServices;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Images;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg.State;
using SixLabors.ImageSharp;
@@ -17,24 +18,10 @@ namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public abstract class ImageElementBase(IRemoteImageFetcher remoteImageFetcher) : GraphicsElement, IDisposable
{
/// <summary>
/// Ceiling on TOTAL decoded pixels for a remote image — width x height x frames, as one
/// product. Checking dimensions and frame count independently does not bound the decode:
/// 2500x2500 x 600 frames is 60 KiB on the wire, passes a 50 MP dimension check and a 600
/// frame check, and costs ~14 GiB to decode. Only the product catches that.
/// 8K is ~33 MP, so a single large still fits comfortably.
/// </summary>
internal const long MaxRemoteDecodedPixels = 50_000_000;
/// <summary>
/// Frame ceiling for a remote animation, kept alongside the product budget as a cheap,
/// legible guard against absurd frame counts of tiny frames.
/// </summary>
internal const int MaxRemoteFrames = 600;
/// <summary>
/// Ceiling on total pixels RETAINED after scaling — frames x scaled width x scaled height.
/// Independent of the source budget above: a 100x100 source is trivial to decode but, at 600
/// Independent of the source decode budget in <see cref="RemoteImageDecodeBudget" />: a
/// 100x100 source is trivial to decode but, at 600
/// frames scaled to 1920x1080, retains ~5 GB of <see cref="SKBitmap" />. At 4 bytes per
/// pixel this bounds retention at ~800 MB, which still allows ~96 full-frame 1080p frames
/// (~3s at 30fps) or 600 frames of a 577x577 logo.
@@ -170,9 +157,9 @@ public abstract class ImageElementBase(IRemoteImageFetcher remoteImageFetcher) :
// FrameMetadataCollection.Count == 0 while the decoder happily produces 600 frames, so a
// header-derived frame budget is enforced on a number the decoder does not honor — a
// 134 KiB file decodes to ~36 GiB. (Second adversarial re-review; ersatztv#511.)
EnsureDimensionsAffordable(info.Width, info.Height, uri);
RemoteImageDecodeBudget.EnsureDimensionsAffordable(info.Width, info.Height, uri);
int affordableFrames = AffordableFrames(info.Width, info.Height);
int affordableFrames = RemoteImageDecodeBudget.AffordableFrames(info.Width, info.Height);
stream.Position = 0;
@@ -190,7 +177,7 @@ public abstract class ImageElementBase(IRemoteImageFetcher remoteImageFetcher) :
{
// re-verify against REALITY rather than against the header. this is the check that
// actually holds; everything above it only avoids decoding when we can tell in advance.
EnsureDecodeAffordable(image.Width, image.Height, image.Frames.Count, uri);
RemoteImageDecodeBudget.EnsureDecodeAffordable(image.Width, image.Height, image.Frames.Count, uri);
return image;
}
catch
@@ -200,56 +187,6 @@ public abstract class ImageElementBase(IRemoteImageFetcher remoteImageFetcher) :
}
}
/// <summary>Rejects a single frame that cannot fit the decode budget on its own.</summary>
internal static void EnsureDimensionsAffordable(int width, int height, Uri uri)
{
long pixels = (long)width * height;
if (pixels > MaxRemoteDecodedPixels)
{
throw new InvalidOperationException(
$"Remote image {uri} is {width}x{height} ({pixels} pixels), over the "
+ $"{MaxRemoteDecodedPixels} pixel limit");
}
}
/// <summary>
/// How many frames of this size the decode budget affords. Used to cap the DECODER, so the
/// bound does not depend on the header's frame count being honest.
/// </summary>
internal static int AffordableFrames(int width, int height)
{
long perFrame = Math.Max((long)width * height, 1);
return (int)Math.Clamp(MaxRemoteDecodedPixels / perFrame, 1, MaxRemoteFrames);
}
/// <summary>
/// The decode-budget policy, kept free of I/O so the arithmetic can be tested at every
/// boundary without materializing multi-gigabyte images. Call this with the number of frames
/// the decoder ACTUALLY produced — never with a header-reported count, which can be zero for
/// an animation the decoder then expands to hundreds of frames.
/// </summary>
internal static void EnsureDecodeAffordable(int width, int height, int frameCount, Uri uri)
{
int frames = Math.Max(frameCount, 1);
if (frames > MaxRemoteFrames)
{
throw new InvalidOperationException(
$"Remote image {uri} has {frames} frames, over the {MaxRemoteFrames} frame limit");
}
// THE PRODUCT is the real bound. Checking dimensions and frames separately lets a 60 KiB
// 2500x2500 x600 GIF through at a ~14 GiB decode cost. (Found by adversarial re-review of
// the first fix for this, which checked them independently.)
long totalPixels = (long)width * height * frames;
if (totalPixels > MaxRemoteDecodedPixels)
{
throw new InvalidOperationException(
$"Remote image {uri} decodes to {width}x{height} x{frames} frames "
+ $"({totalPixels} pixels), over the {MaxRemoteDecodedPixels} pixel limit");
}
}
/// <summary>
/// Bounds what is RETAINED after scaling. Separate from the source budget because the two
/// are independent: a cheap-to-decode 100x100 source scaled to 1920x1080 across 600 frames