diff --git a/Source/APNGKit/APNGDecoder.swift b/Source/APNGKit/APNGDecoder.swift index 72e798c..b7a58fb 100644 --- a/Source/APNGKit/APNGDecoder.swift +++ b/Source/APNGKit/APNGDecoder.swift @@ -35,7 +35,12 @@ class APNGDecoder { let imageHeader: IHDR let animationControl: acTL - + + // The scale applied to the rendering canvas to limit its memory footprint. `1.0` means the image is rendered at + // its native pixel size. A value in `(0, 1)` means the canvas (and so every decoded frame) is downsampled by this + // factor. It is derived from the `maxSize` passed when creating the decoder. + let renderScale: CGFloat + private let decodingQueue = DispatchQueue(label: "com.onevcat.apngkit.decodingQueue", qos: .userInteractive) // Holds decoded frame data and chunk info. @@ -46,8 +51,34 @@ class APNGDecoder { var defaultImageChunks: [IDAT] { firstFrameResult?.defaultImageChunks ?? [] } private(set) var firstFrameResult: FirstFrameResult? - var canvasFullRect: CGRect { .init(origin: .zero, size: canvasFullSize) } - private var canvasFullSize: CGSize { .init(width: imageHeader.width, height: imageHeader.height) } + // The full rendering canvas rectangle, in the render (downsampled) coordinate space. When `renderScale` is `1.0` + // this equals the native image size. + var canvasFullRect: CGRect { .init(x: 0, y: 0, width: renderWidth, height: renderHeight) } + + // The width of the rendering canvas in pixels, after applying `renderScale`. + var renderWidth: Int { scaledLength(imageHeader.width) } + // The height of the rendering canvas in pixels, after applying `renderScale`. + var renderHeight: Int { scaledLength(imageHeader.height) } + // The bytes per row of the rendering canvas, after applying `renderScale`. + var renderBytesPerRow: Int { renderWidth * Int(imageHeader.bytesPerPixel) } + + // Scales a single length (a width or a height) from the native coordinate space into a render space. A positive + // length is clamped to a minimum of one pixel so a downsampled canvas is never degenerate, while a zero length + // stays zero. Declared `static` so it can also be used during `init`, before `self` is fully formed. + static func scaledLength(_ value: Int, scale: CGFloat) -> Int { + guard scale < 1.0, value > 0 else { return value } + return max(1, Int((CGFloat(value) * scale).rounded())) + } + + private func scaledLength(_ value: Int) -> Int { Self.scaledLength(value, scale: renderScale) } + + // Scales a rectangle expressed in the native image coordinate space into the render (downsampled) space. Origins + // and sizes are scaled by the same factor so neighbouring frame regions keep sharing their boundaries. When + // `renderScale` is `1.0` the input rectangle is returned unchanged. + func renderRect(_ rect: CGRect) -> CGRect { + guard renderScale < 1.0 else { return rect } + return rect.applying(CGAffineTransform(scaleX: renderScale, y: renderScale)) + } // The data chunks shared by all frames: after IHDR and before the actual IDAT or fdAT chunk. // Use this to revert to a valid PNG for creating a CG data provider. @@ -57,18 +88,18 @@ class APNGDecoder { // reader is set to this position before starting another read process. private(set) var resetStatus: ResetStatus! - convenience init(data: Data, options: APNGImage.DecodingOptions = []) throws { + convenience init(data: Data, options: APNGImage.DecodingOptions = [], maxSize: CGSize? = nil) throws { let reader = DataReader(data: data) - try self.init(reader: reader, options: options) + try self.init(reader: reader, options: options, maxSize: maxSize) } - - convenience init(fileURL: URL, options: APNGImage.DecodingOptions = []) throws { + + convenience init(fileURL: URL, options: APNGImage.DecodingOptions = [], maxSize: CGSize? = nil) throws { let reader = try FileReader(url: fileURL) - try self.init(reader: reader, options: options) + try self.init(reader: reader, options: options, maxSize: maxSize) } - - private init(reader: Reader, options: APNGImage.DecodingOptions) throws { - + + private init(reader: Reader, options: APNGImage.DecodingOptions, maxSize: CGSize? = nil) throws { + self.reader = reader self.options = options @@ -83,7 +114,21 @@ class APNGDecoder { } let ihdr = try reader.readChunk(type: IHDR.self, skipChecksumVerify: skipChecksumVerify) imageHeader = ihdr.chunk - + + // Determine the rendering scale from the requested `maxSize`. We only ever scale down: if the image already + // fits inside `maxSize` (or no limit is given) the native size is kept. Downsampling keeps the rendering canvas + // and every cached frame small, which is what prevents oversized images from exhausting memory. + if let maxSize = maxSize, maxSize.width > 0, maxSize.height > 0, + imageHeader.width > 0, imageHeader.height > 0 { + let fitScale = min( + maxSize.width / CGFloat(imageHeader.width), + maxSize.height / CGFloat(imageHeader.height) + ) + renderScale = min(1.0, fitScale) + } else { + renderScale = 1.0 + } + let acTLResult: UntilChunkResult do { acTLResult = try reader.readUntil(type: acTL.self, skipChecksumVerify: skipChecksumVerify) @@ -119,7 +164,12 @@ class APNGDecoder { } else { // Optimization: Auto determine if we want to cache the image based on image information. if acTLResult.chunk.numberOfPlays == 0 { // Although it is not accurate enough, we only use the image header and animation control chunk to estimate. - let estimatedTotalBytes = imageHeader.height * imageHeader.bytesPerRow * numberOfFrames + // Use the render (downsampled) dimensions, since that is the size each cached frame actually takes. The + // `static` `scaledLength` is used (rather than `renderWidth`/`renderHeight`) because `self` is not yet + // fully initialized here. + let scaledWidth = Self.scaledLength(imageHeader.width, scale: renderScale) + let scaledHeight = Self.scaledLength(imageHeader.height, scale: renderScale) + let estimatedTotalBytes = scaledHeight * scaledWidth * Int(imageHeader.bytesPerPixel) * numberOfFrames // Cache images when it does not take too much memory. cachePolicy = estimatedTotalBytes < APNGImage.maximumCacheSize ? .cache : .noCache } else { diff --git a/Source/APNGKit/APNGImage.swift b/Source/APNGKit/APNGImage.swift index b0b6619..351ed01 100644 --- a/Source/APNGKit/APNGImage.swift +++ b/Source/APNGKit/APNGImage.swift @@ -136,15 +136,21 @@ public class APNGImage { /// - Parameters: /// - name: The name of the image file in the main bundle. /// - decodingOptions: The decoding options being used while decoding the image data. + /// - maxSize: The maximum pixel size the image should be rendered at. If the image is larger than this size, it is + /// downsampled to fit, which bounds its memory footprint. This affects only the rendering resolution and memory + /// use: the image's logical `size` (and an `APNGImageView`'s `intrinsicContentSize`) still reflects the native + /// dimensions, and the downsampled output is scaled up to fill that size when displayed. Pass `nil` (the + /// default) to render at native size. /// - Returns: The image object that best matches the given name. /// /// This method guesses what is the image you want to load based on the given `name`. It searches the possible /// combinations of file name, extensions and image scales in the bundle. public convenience init( named name: String, - decodingOptions: DecodingOptions = [] + decodingOptions: DecodingOptions = [], + maxSize: CGSize? = nil ) throws { - try self.init(named: name, decodingOptions: decodingOptions, in: nil, subdirectory: nil) + try self.init(named: name, decodingOptions: decodingOptions, in: nil, subdirectory: nil, maxSize: maxSize) } /// Creates an APNG image object using the named image file in the specified bundle and subdirectory. @@ -153,6 +159,11 @@ public class APNGImage { /// - decodingOptions: The decoding options being used while decoding the image data. /// - bundle: The bundle in which APNGKit should search in for the image. /// - subpath: The subdirectory path in the bundle where the image is put. + /// - maxSize: The maximum pixel size the image should be rendered at. If the image is larger than this size, it is + /// downsampled to fit, which bounds its memory footprint. This affects only the rendering resolution and memory + /// use: the image's logical `size` (and an `APNGImageView`'s `intrinsicContentSize`) still reflects the native + /// dimensions, and the downsampled output is scaled up to fill that size when displayed. Pass `nil` (the + /// default) to render at native size. /// - Returns: The image object that best matches the given name, bundle and subpath. /// /// This method guesses what is the image you want to load based on the given `name`. It searches the possible @@ -161,13 +172,14 @@ public class APNGImage { named name: String, decodingOptions: DecodingOptions = [], in bundle: Bundle?, - subdirectory subpath: String? = nil + subdirectory subpath: String? = nil, + maxSize: CGSize? = nil ) throws { let guessing = FileNameGuessing(name: name) guard let resource = guessing.load(in: bundle, subpath: subpath) else { throw APNGKitError.imageError(.resourceNotFound(name: name, bundle: bundle ?? .main)) } - try self.init(fileURL: resource.fileURL, scale: resource.scale, decodingOptions: decodingOptions) + try self.init(fileURL: resource.fileURL, scale: resource.scale, decodingOptions: decodingOptions, maxSize: maxSize) } /// Creates an APNG image object using the file path. @@ -175,14 +187,20 @@ public class APNGImage { /// - filePath: The path of APNG file. /// - scale: The desired image scale. If not set, APNGKit will guess from the file name. /// - decodingOptions: The decoding options being used while decoding the image data. + /// - maxSize: The maximum pixel size the image should be rendered at. If the image is larger than this size, it is + /// downsampled to fit, which bounds its memory footprint. This affects only the rendering resolution and memory + /// use: the image's logical `size` (and an `APNGImageView`'s `intrinsicContentSize`) still reflects the native + /// dimensions, and the downsampled output is scaled up to fill that size when displayed. Pass `nil` (the + /// default) to render at native size. /// - Returns: The image object that loaded from the given file path. public convenience init( filePath: String, scale: CGFloat? = nil, - decodingOptions: DecodingOptions = [] + decodingOptions: DecodingOptions = [], + maxSize: CGSize? = nil ) throws { let fileURL = URL(fileURLWithPath: filePath) - try self.init(fileURL: fileURL, scale: scale, decodingOptions: decodingOptions) + try self.init(fileURL: fileURL, scale: scale, decodingOptions: decodingOptions, maxSize: maxSize) } /// Creates an APNG image object using the file URL. @@ -190,15 +208,21 @@ public class APNGImage { /// - fileURL: The URL of APNG file on disk. /// - scale: The desired image scale. If not set, APNGKit will guess from the file name. /// - decodingOptions: The decoding options being used while decoding the image data. + /// - maxSize: The maximum pixel size the image should be rendered at. If the image is larger than this size, it is + /// downsampled to fit, which bounds its memory footprint. This affects only the rendering resolution and memory + /// use: the image's logical `size` (and an `APNGImageView`'s `intrinsicContentSize`) still reflects the native + /// dimensions, and the downsampled output is scaled up to fill that size when displayed. Pass `nil` (the + /// default) to render at native size. /// - Returns: The image object that loaded from the given file URL. public init( fileURL: URL, scale: CGFloat? = nil, - decodingOptions: DecodingOptions = [] + decodingOptions: DecodingOptions = [], + maxSize: CGSize? = nil ) throws { self.scale = scale ?? fileURL.imageScale do { - decoder = try APNGDecoder(fileURL: fileURL, options: decodingOptions) + decoder = try APNGDecoder(fileURL: fileURL, options: decodingOptions, maxSize: maxSize) let repeatCount = decoder.animationControl.numberOfPlays numberOfPlays = repeatCount == 0 ? nil : repeatCount } catch { @@ -218,15 +242,21 @@ public class APNGImage { /// - data: The data containing APNG information and frames. /// - scale: The desired image scale. If not set, `1.0` is used. /// - decodingOptions: The decoding options being used while decoding the image data. + /// - maxSize: The maximum pixel size the image should be rendered at. If the image is larger than this size, it is + /// downsampled to fit, which bounds its memory footprint. This affects only the rendering resolution and memory + /// use: the image's logical `size` (and an `APNGImageView`'s `intrinsicContentSize`) still reflects the native + /// dimensions, and the downsampled output is scaled up to fill that size when displayed. Pass `nil` (the + /// default) to render at native size. /// - Returns: The image object that loaded from the given data. public init( data: Data, scale: CGFloat = 1.0, - decodingOptions: DecodingOptions = [] + decodingOptions: DecodingOptions = [], + maxSize: CGSize? = nil ) throws { self.scale = scale do { - self.decoder = try APNGDecoder(data: data, options: decodingOptions) + self.decoder = try APNGDecoder(data: data, options: decodingOptions, maxSize: maxSize) let repeatCount = decoder.animationControl.numberOfPlays numberOfPlays = repeatCount == 0 ? nil : repeatCount } catch { diff --git a/Source/APNGKit/APNGImageRenderer.swift b/Source/APNGKit/APNGImageRenderer.swift index 6213ed0..128f692 100644 --- a/Source/APNGKit/APNGImageRenderer.swift +++ b/Source/APNGKit/APNGImageRenderer.swift @@ -29,12 +29,15 @@ class APNGImageRenderer { self.reader = try decoder.reader.clone() let imageHeader = decoder.imageHeader + // The canvas is allocated at the render (possibly downsampled) size. When `decoder.renderScale` is `1.0` these + // are the native dimensions; otherwise they shrink the buffer — and every frame drawn into it — to fit the + // requested `maxSize`, keeping the memory footprint bounded. guard let outputBuffer = CGContext( data: nil, - width: imageHeader.width, - height: imageHeader.height, + width: decoder.renderWidth, + height: decoder.renderHeight, bitsPerComponent: imageHeader.bitDepthPerComponent, - bytesPerRow: imageHeader.bytesPerRow, + bytesPerRow: decoder.renderBytesPerRow, space: imageHeader.colorSpace, bitmapInfo: imageHeader.bitmapInfo.rawValue ) else { @@ -427,7 +430,7 @@ extension APNGImageRenderer { outputBuffer.clear(decoder.canvasFullRect) } else { let displayingFrame = decoder.frame(at: index - 1)! - let displayingRegion = displayingFrame.normalizedRect(fullHeight: decoder.imageHeader.height) + let displayingRegion = decoder.renderRect(displayingFrame.normalizedRect(fullHeight: decoder.imageHeader.height)) switch displayingFrame.frameControl.disposeOp { case .none: previousOutputImage = currentOutputImage @@ -436,7 +439,12 @@ extension APNGImageRenderer { previousOutputImage = outputBuffer.makeImage() case .previous: if let previousOutputImage = previousOutputImage { - if let cropped = previousOutputImage.cropping(to: displayingFrame.frameControl.cgRect) { + // `previousOutputImage` is already at render scale, so crop it in render space too. `renderRect` + // can yield a fractional rect, and `cropping(to:)` returns `nil` for a non-integral or + // out-of-bounds rectangle — so integralize it and clamp to the image bounds first. + let imageBounds = CGRect(x: 0, y: 0, width: previousOutputImage.width, height: previousOutputImage.height) + let cropRect = decoder.renderRect(displayingFrame.frameControl.cgRect).integral.intersection(imageBounds) + if let cropped = previousOutputImage.cropping(to: cropRect) { outputBuffer.clear(displayingRegion) outputBuffer.draw(cropped, in: displayingRegion) } else { @@ -449,13 +457,15 @@ extension APNGImageRenderer { } } - // Blend & Draw the new frame + // Blend & Draw the new frame. The frame's destination rectangle is scaled into render space; drawing the + // natively-decoded `nextFrameImage` into a smaller rectangle lets Core Graphics downsample it for us. + let frameRenderRect = decoder.renderRect(frame.normalizedRect(fullHeight: decoder.imageHeader.height)) switch frame.frameControl.blendOp { case .source: - outputBuffer.clear(frame.normalizedRect(fullHeight: decoder.imageHeader.height)) - outputBuffer.draw(nextFrameImage, in: frame.normalizedRect(fullHeight: decoder.imageHeader.height)) + outputBuffer.clear(frameRenderRect) + outputBuffer.draw(nextFrameImage, in: frameRenderRect) case .over: - outputBuffer.draw(nextFrameImage, in: frame.normalizedRect(fullHeight: decoder.imageHeader.height)) + outputBuffer.draw(nextFrameImage, in: frameRenderRect) } guard let nextOutputImage = outputBuffer.makeImage() else { diff --git a/Tests/APNGKitTests/APNGDecoderTests.swift b/Tests/APNGKitTests/APNGDecoderTests.swift index 652cd25..af9a4b7 100644 --- a/Tests/APNGKitTests/APNGDecoderTests.swift +++ b/Tests/APNGKitTests/APNGDecoderTests.swift @@ -75,4 +75,16 @@ class APNGDecoderTests: XCTestCase { let decoder = try APNGDecoder(fileURL: SampleTesting.sampleTestingURL(name: "maneki-neko")) XCTAssertEqual(decoder.framesCount, 3) } + + func testScaledLength() { + // A zero length stays zero rather than being clamped up to one. + XCTAssertEqual(APNGDecoder.scaledLength(0, scale: 0.5), 0) + // A positive length is clamped to a minimum of one pixel so a downsampled canvas is never degenerate. + XCTAssertEqual(APNGDecoder.scaledLength(1, scale: 0.1), 1) + // A scale of `1.0` (or greater) keeps the native length untouched. + XCTAssertEqual(APNGDecoder.scaledLength(100, scale: 1.0), 100) + // A fractional scale rounds to the nearest pixel. + XCTAssertEqual(APNGDecoder.scaledLength(100, scale: 0.5), 50) + XCTAssertEqual(APNGDecoder.scaledLength(101, scale: 0.5), 51) + } } diff --git a/Tests/APNGKitTests/APNGImageRendererTests.swift b/Tests/APNGKitTests/APNGImageRendererTests.swift index e68366b..6805914 100644 --- a/Tests/APNGKitTests/APNGImageRendererTests.swift +++ b/Tests/APNGKitTests/APNGImageRendererTests.swift @@ -206,6 +206,53 @@ class APNGImageRendererTests: XCTestCase { XCTAssertEqual(renderer1.currentIndex, 1) XCTAssertEqual(renderer2.currentIndex, 2) } + func testRenderDownsamplesToMaxSize() throws { + // Baseline: rendering without a `maxSize` keeps the native pixel size. This also guards the default path + // against regression from the downsampling change. + let nativeDecoder = try APNGDecoder(fileURL: SampleTesting.sampleTestingURL(name: "ball")) + XCTAssertEqual(nativeDecoder.renderScale, 1.0) + let nativeRenderer = try APNGImageRenderer(decoder: nativeDecoder) + let nativeFrame0 = try nativeRenderer.output!.get() + let width = nativeDecoder.imageHeader.width + let height = nativeDecoder.imageHeader.height + XCTAssertEqual(nativeFrame0.width, width) + XCTAssertEqual(nativeFrame0.height, height) + + // Downsample to half. The output canvas — and every composited frame — must come out at the scaled size. + let decoder = try APNGDecoder( + fileURL: SampleTesting.sampleTestingURL(name: "ball"), + maxSize: CGSize(width: width / 2, height: height / 2) + ) + XCTAssertEqual(decoder.renderScale, 0.5, accuracy: 0.0001) + XCTAssertEqual(decoder.renderWidth, width / 2) + XCTAssertEqual(decoder.renderHeight, height / 2) + + let renderer = try APNGImageRenderer(decoder: decoder) + let frame0 = try renderer.output!.get() + XCTAssertEqual(frame0.width, width / 2) + XCTAssertEqual(frame0.height, height / 2) + + // The scaled size must hold across the compositing pipeline for subsequent frames, not just the first. + let frame1 = try renderer.renderNextAndGetResult() + XCTAssertEqual(frame1.width, width / 2) + XCTAssertEqual(frame1.height, height / 2) + let frame2 = try renderer.renderNextAndGetResult() + XCTAssertEqual(frame2.width, width / 2) + XCTAssertEqual(frame2.height, height / 2) + } + + func testMaxSizeLargerThanNativeDoesNotUpscale() throws { + // `maxSize` is an upper bound only: an image already smaller than it is left at native size. + let decoder = try APNGDecoder( + fileURL: SampleTesting.sampleTestingURL(name: "ball"), + maxSize: CGSize(width: 10_000, height: 10_000) + ) + XCTAssertEqual(decoder.renderScale, 1.0) + let renderer = try APNGImageRenderer(decoder: decoder) + let frame0 = try renderer.output!.get() + XCTAssertEqual(frame0.width, decoder.imageHeader.width) + XCTAssertEqual(frame0.height, decoder.imageHeader.height) + } } extension APNGImageRenderer {