Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Boost performance for animated images #79

Merged
merged 6 commits into from
Feb 22, 2024
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
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