Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 63 additions & 13 deletions Source/APNGKit/APNGDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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

Expand All @@ -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<acTL>
do {
acTLResult = try reader.readUntil(type: acTL.self, skipChecksumVerify: skipChecksumVerify)
Expand Down Expand Up @@ -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 {
Expand Down
50 changes: 40 additions & 10 deletions Source/APNGKit/APNGImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -161,44 +172,57 @@ 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.
/// - Parameters:
/// - 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.
/// - Parameters:
/// - 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 {
Expand All @@ -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 {
Expand Down
28 changes: 19 additions & 9 deletions Source/APNGKit/APNGImageRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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))

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice & clean. Thank you.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙏 Thanks!

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 {
Expand Down
12 changes: 12 additions & 0 deletions Tests/APNGKitTests/APNGDecoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading