Skip to content

Commit

Permalink
Merge pull request #79 from yeatse/animated-frame-optimize
Browse files Browse the repository at this point in the history
Boost performance for animated images
  • Loading branch information
yeatse authored Feb 22, 2024
2 parents c03497a + c495864 commit f55b0ab
Show file tree
Hide file tree
Showing 8 changed files with 128 additions and 26 deletions.
2 changes: 1 addition & 1 deletion KingfisherWebP.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@ KingfisherWebP is an extension of the popular library [Kingfisher](https://githu
'USER_HEADER_SEARCH_PATHS' => '$(inherited) $(SRCROOT)/libwebp/src'
}

s.dependency 'Kingfisher', '~> 7.9'
s.dependency 'Kingfisher', '~> 7.11'
s.dependency 'libwebp', '>= 1.1.0'
end
4 changes: 2 additions & 2 deletions KingfisherWebP.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 53;
objectVersion = 54;
objects = {

/* Begin PBXBuildFile section */
Expand Down Expand Up @@ -587,7 +587,7 @@
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 7.8.0;
minimumVersion = 7.11.0;
};
};
38E23F732591BBB000EBE21D /* XCRemoteSwiftPackageReference "libwebp-Xcode" */ = {
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ let package = Package(
.library(name: "KingfisherWebP", targets: ["KingfisherWebP"])
],
dependencies: [
.package(url: "https://github.com/onevcat/Kingfisher.git", from: "7.8.1"),
.package(url: "https://github.com/onevcat/Kingfisher.git", from: "7.11.0"),
.package(url: "https://github.com/SDWebImage/libwebp-Xcode", from: "1.1.0")
],
targets: [
Expand Down
39 changes: 30 additions & 9 deletions Sources/Image+WebP.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import Foundation
import KingfisherWebP_ObjC
#endif

#if canImport(AppKit)
import AppKit
#endif

// MARK: - Image Representation
extension KingfisherWrapper where Base: KFCrossPlatformImage {
/// isLossy (0=lossy , 1=lossless (default)).
Expand All @@ -37,16 +41,27 @@ extension KingfisherWrapper where Base: KFCrossPlatformImage {
/// isLossy (0=lossy , 1=lossless (default)).
/// Note that the default values are isLossy= false and quality=75.0f
private func animatedWebPRepresentation(isLossy: Bool = false, quality: Float = 75.0) -> Data? {
#if os(macOS)
return nil
#else
guard let images = base.images?.compactMap({ $0.cgImage }) else {
let imageInfo: [CFString: Any]
if let frameSource = frameSource {
let frameCount = frameSource.frameCount
imageInfo = [
kWebPAnimatedImageFrames: (0..<frameCount).map({ frameSource.frame(at: $0) }),
kWebPAnimatedImageFrameDurations: (0..<frameCount).map({ frameSource.duration(at: $0) }),
]
} else {
#if os(macOS)
return nil
#else
guard let images = base.images?.compactMap({ $0.cgImage }) else {
return nil
}
imageInfo = [
kWebPAnimatedImageFrames: images,
kWebPAnimatedImageDuration: base.duration
]
#endif
}
let imageInfo = [ kWebPAnimatedImageFrames: images,
kWebPAnimatedImageDuration: NSNumber(value: base.duration) ] as [CFString : Any]
return WebPDataCreateWithAnimatedImageInfo(imageInfo as CFDictionary, isLossy, quality) as Data?
#endif
}
}

Expand Down Expand Up @@ -103,6 +118,7 @@ class WebPFrameSource: ImageFrameSource {
let data: Data?
private let decoder: WebPDecoderRef
private var decoderLock: UnsafeMutablePointer<os_unfair_lock>
private var frameCache = NSCache<NSNumber, CGImage>()

var frameCount: Int {
get {
Expand All @@ -115,9 +131,14 @@ class WebPFrameSource: ImageFrameSource {
defer {
os_unfair_lock_unlock(decoderLock)
}
guard let image = WebPDecoderCopyImageAtIndex(decoder, Int32(index)) else {
return nil
var image = frameCache.object(forKey: index as NSNumber)
if image == nil {
image = WebPDecoderCopyImageAtIndex(decoder, Int32(index))
if image != nil {
frameCache.setObject(image!, forKey: index as NSNumber)
}
}
guard let image = image else { return nil }
if let maxSize = maxSize, maxSize != .zero, CGFloat(image.width) > maxSize.width || CGFloat(image.height) > maxSize.height {
let scale = min(maxSize.width / CGFloat(image.width), maxSize.height / CGFloat(image.height))
let destWidth = Int(CGFloat(image.width) * scale)
Expand Down
25 changes: 20 additions & 5 deletions Sources/KingfisherWebP-ObjC/CGImage+WebP.m
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ CFDataRef WebPDataCreateWithImage(CGImageRef image, bool isLossy, float quality)
const CFStringRef kWebPAnimatedImageDuration = CFSTR("kWebPAnimatedImageDuration");
const CFStringRef kWebPAnimatedImageLoopCount = CFSTR("kWebPAnimatedImageLoopCount");
const CFStringRef kWebPAnimatedImageFrames = CFSTR("kWebPAnimatedImageFrames");
const CFStringRef kWebPAnimatedImageFrameDurations = CFSTR("kWebPAnimatedImageFrameDurations");

uint32_t WebPImageFrameCountGetFromData(CFDataRef webpData) {
WebPData webp_data;
Expand Down Expand Up @@ -412,11 +413,16 @@ CFDataRef WebPDataCreateWithAnimatedImageInfo(CFDictionaryRef imageInfo, bool is
CFNumberRef loopCount = CFDictionaryGetValue(imageInfo, kWebPAnimatedImageLoopCount);
CFNumberRef durationRef = CFDictionaryGetValue(imageInfo, kWebPAnimatedImageDuration);
CFArrayRef imageFrames = CFDictionaryGetValue(imageInfo, kWebPAnimatedImageFrames);
CFArrayRef frameDurations = CFDictionaryGetValue(imageInfo, kWebPAnimatedImageFrameDurations);

if (!imageFrames || CFArrayGetCount(imageFrames) < 1) {
return NULL;
}

if (frameDurations && CFArrayGetCount(frameDurations) != CFArrayGetCount(imageFrames)) {
return NULL;
}

WebPAnimEncoderOptions enc_options;
WebPAnimEncoderOptionsInit(&enc_options);
if (loopCount) {
Expand All @@ -429,13 +435,14 @@ CFDataRef WebPDataCreateWithAnimatedImageInfo(CFDictionaryRef imageInfo, bool is
return NULL;
}

int frameDurationInMilliSec = 100;
if (durationRef) {
int defaultDurationInMilliSec = 100;
if (durationRef && !frameDurations) {
double totalDurationInSec;
CFNumberGetValue(durationRef, kCFNumberDoubleType, &totalDurationInSec);
frameDurationInMilliSec = (int)(totalDurationInSec * 1000 / CFArrayGetCount(imageFrames));
defaultDurationInMilliSec = (int)(totalDurationInSec * 1000 / CFArrayGetCount(imageFrames));
}

int timestamp = 0;
for (CFIndex i = 0; i < CFArrayGetCount(imageFrames); i ++) {
WebPPicture frame;
WebPPictureInit(&frame);
Expand All @@ -447,11 +454,19 @@ CFDataRef WebPDataCreateWithAnimatedImageInfo(CFDictionaryRef imageInfo, bool is
} else {
WebPConfigLosslessPreset(&config, 0);
}
WebPAnimEncoderAdd(enc, &frame, (int)(frameDurationInMilliSec * i), &config);
WebPAnimEncoderAdd(enc, &frame, timestamp, &config);
if (frameDurations) {
CFNumberRef frameDuration = CFArrayGetValueAtIndex(frameDurations, i);
double durationInSec = 0.1;
CFNumberGetValue(frameDuration, kCFNumberDoubleType, &durationInSec);
timestamp += (int)(durationInSec * 1000);
} else {
timestamp += defaultDurationInMilliSec;
}
}
WebPPictureFree(&frame);
}
WebPAnimEncoderAdd(enc, NULL, (int)(frameDurationInMilliSec * CFArrayGetCount(imageFrames)), NULL);
WebPAnimEncoderAdd(enc, NULL, timestamp, NULL);

WebPData webp_data;
WebPDataInit(&webp_data);
Expand Down
1 change: 1 addition & 0 deletions Sources/KingfisherWebP-ObjC/include/CGImage+WebP.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ CFDataRef __nullable WebPDataCreateWithImage(CGImageRef image, bool isLossy, flo
CG_EXTERN const CFStringRef kWebPAnimatedImageDuration;
CG_EXTERN const CFStringRef kWebPAnimatedImageLoopCount;
CG_EXTERN const CFStringRef kWebPAnimatedImageFrames; // CFArrayRef of CGImageRef
CG_EXTERN const CFStringRef kWebPAnimatedImageFrameDurations; // CFArrayRef of CFNumberRef

uint32_t WebPImageFrameCountGetFromData(CFDataRef webpData);
CFDictionaryRef __nullable WebPAnimatedImageInfoCreateWithData(CFDataRef webpData);
Expand Down
17 changes: 11 additions & 6 deletions Sources/WebPSerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,19 @@ public struct WebPSerializer: CacheSerializer {
private init() {}

public func data(with image: KFCrossPlatformImage, original: Data?) -> Data? {
if let original = original, originalDataUsed {
return original
} else if let original = original, !original.isWebPFormat {
if originalDataUsed {
if let original = original {
return original
}
if let frameData = image.kf.frameSource?.data {
return frameData
}
}
if let original = original, !original.isWebPFormat {
return DefaultCacheSerializer.default.data(with: image, original: original)
} else {
let qualityInWebp = min(max(0, compressionQuality), 1) * 100
return image.kf.normalized.kf.webpRepresentation(isLossy: isLossy, quality: Float(qualityInWebp))
}
let qualityInWebp = min(max(0, compressionQuality), 1) * 100
return image.kf.normalized.kf.webpRepresentation(isLossy: isLossy, quality: Float(qualityInWebp))
}

public func image(with data: Data, options: KingfisherParsedOptionsInfo) -> KFCrossPlatformImage? {
Expand Down
64 changes: 62 additions & 2 deletions Tests/KingfisherWebPTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ class KingfisherWebPTests: XCTestCase {

#if os(macOS)
func testMultipleFrameEncoding() {
let s = WebPSerializer.default
var s = WebPSerializer.default
s.originalDataUsed = false

animationFileNames.forEach { fileName in
let originalData = Data(fileName: fileName)
Expand All @@ -149,7 +150,8 @@ class KingfisherWebPTests: XCTestCase {
}
#else
func testMultipleFrameEncoding() {
let s = WebPSerializer.default
var s = WebPSerializer.default
s.originalDataUsed = false

animationFileNames.forEach { fileName in
let originalData = Data(fileName: fileName)
Expand All @@ -171,6 +173,64 @@ class KingfisherWebPTests: XCTestCase {
}
#endif

func testVariableFrameEncoding() {
var s = WebPSerializer.default
s.originalDataUsed = false

animationFileNames.forEach { fileName in
let originalData = Data(fileName: fileName)
let originalImage = KingfisherWrapper.animatedImage(data: originalData, options: .init())!

let webpData = s.data(with: originalImage, original: nil)
XCTAssertNotNil(webpData, fileName)

let imageFromWebPData = s.image(with: webpData!, options: .init(nil))
XCTAssertNotNil(imageFromWebPData, fileName)

let originalFrameSource = originalImage.kf.frameSource!
let encodedFrameSource = imageFromWebPData!.kf.frameSource!
XCTAssertEqual(originalFrameSource.frameCount, encodedFrameSource.frameCount)

(0..<originalFrameSource.frameCount).forEach { index in
#if os(macOS)
let frame1 = KFCrossPlatformImage(cgImage: originalFrameSource.frame(at: index)!, size: .zero)
let frame2 = KFCrossPlatformImage(cgImage: encodedFrameSource.frame(at: index)!, size: .zero)
#else
let frame1 = KFCrossPlatformImage(cgImage: originalFrameSource.frame(at: index)!)
let frame2 = KFCrossPlatformImage(cgImage: encodedFrameSource.frame(at: index)!)
#endif
XCTAssertTrue(frame1.renderEqual(to: frame2), "Frame \(index) of \(fileName) should be equal")

let duration1 = originalFrameSource.duration(at: index)
let duration2 = encodedFrameSource.duration(at: index)
XCTAssertEqual(duration1, duration2, "Duration in frame \(index) of \(fileName) should be equal")
}
}
}

func testOriginalDataIsUsed() {
let s = WebPSerializer.default
XCTAssertTrue(s.originalDataUsed)

let randomData = Data((0..<10).map { _ in UInt8.random(in: 0...255) })
let encoded = s.data(with: KFCrossPlatformImage(), original: randomData)
XCTAssertEqual(encoded, randomData, "Original data should be used")

struct RandomFrameSource: ImageFrameSource {
let data: Data? = Data((0..<10).map { _ in UInt8.random(in: 0...255) })
let frameCount: Int = 10
func duration(at index: Int) -> TimeInterval { return 0 }
func frame(at index: Int, maxSize: CGSize?) -> CGImage? {
KFCrossPlatformImage(data: .init(fileName: "cover.png"))?.kfCGImage
}
}

let source = RandomFrameSource()
let image = KingfisherWrapper.animatedImage(source: source, options: .init())!
let encoded2 = s.data(with: image, original: nil)
XCTAssertEqual(encoded2, source.data, "Original data should be used")
}

func testEncodingPerformance() {
let s = WebPSerializer.default
let images = fileNames.compactMap { fileName -> KFCrossPlatformImage? in
Expand Down

0 comments on commit f55b0ab

Please sign in to comment.