Skip to content

Commit

Permalink
support HEIC
Browse files Browse the repository at this point in the history
  • Loading branch information
Nemocdz committed Feb 13, 2021
1 parent 63793ab commit 1693093
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 92 deletions.
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import PackageDescription

let package = Package(
name: "ImageCompress",
platforms: [.iOS(.v11), .macOS(.v10_13), .tvOS(.v11), .watchOS(.v4)],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
Expand Down
209 changes: 132 additions & 77 deletions Sources/ImageCompress/ImageCompress.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,20 @@ public extension ImageCompress {
public extension ImageCompress {
enum CompressError: Error {
case imageIOError(ImageIOError)
case illegalFormat
case illegalColorConfig
case illegalLimitLongWidth(width: CGFloat)
case unsupportedFormat
case unsupportedColorConfig
case illegalLongWidth(width: CGFloat)
case illegalQuality(quality: CGFloat)
}
}

public extension ImageCompress.CompressError {
enum ImageIOError {
case cgImageCreateFail
case thumbnailCreateFail(index: Int)
case sourceCreateFail
case destinationCreateFail
case destinationFinishFail
case cgImageMissing(index: Int)
case thumbnailMissing(index: Int)
case sourceMissing
case destinationMissing
case destinationFinalizeFail
}
}

Expand All @@ -49,22 +50,22 @@ public extension ImageCompress {
/// - config: 色彩配置
/// - Returns: 处理后数据
static func changeColor(of rawData: Data, config: ColorConfig) throws -> Data {
guard rawData.imageFormat != .unknown else {
throw CompressError.illegalFormat
guard [ImageFormat.jpeg, ImageFormat.heic, ImageFormat.png].contains(rawData.imageFormat) else {
throw CompressError.unsupportedFormat
}

guard let imageConfig = config.imageConfig else {
throw CompressError.illegalFormat
throw CompressError.unsupportedColorConfig
}

guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
let writeData = CFDataCreateMutable(nil, 0),
let imageType = CGImageSourceGetType(imageSource) else {
throw CompressError.imageIOError(.sourceCreateFail)
throw CompressError.imageIOError(.sourceMissing)
}

guard let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, 1, nil) else {
throw CompressError.imageIOError(.destinationCreateFail)
throw CompressError.imageIOError(.destinationMissing)
}

guard let rawDataProvider = CGDataProvider(data: rawData as CFData),
Expand All @@ -80,13 +81,13 @@ public extension ImageCompress {
shouldInterpolate: true,
intent: .defaultIntent)
else {
throw CompressError.imageIOError(.cgImageCreateFail)
throw CompressError.imageIOError(.cgImageMissing(index: 0))
}

CGImageDestinationAddImage(imageDestination, imageFrame, nil)

guard CGImageDestinationFinalize(imageDestination) else {
throw CompressError.imageIOError(.destinationFinishFail)
throw CompressError.imageIOError(.destinationFinalizeFail)
}
return writeData as Data
}
Expand All @@ -113,11 +114,11 @@ public extension ImageCompress {
/// - Returns: 处理后数据
static func compressImageData(_ rawData: Data, limitLongWidth: CGFloat) throws -> Data {
guard rawData.imageFormat != .unknown else {
throw CompressError.illegalFormat
throw CompressError.unsupportedFormat
}

guard limitLongWidth > 0 else {
throw CompressError.illegalLimitLongWidth(width: limitLongWidth)
throw CompressError.illegalLongWidth(width: limitLongWidth)
}

guard max(rawData.imageSize.height, rawData.imageSize.width) > limitLongWidth else {
Expand All @@ -127,13 +128,13 @@ public extension ImageCompress {
guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
let writeData = CFDataCreateMutable(nil, 0),
let imageType = CGImageSourceGetType(imageSource) else {
throw CompressError.imageIOError(.sourceCreateFail)
throw CompressError.imageIOError(.sourceMissing)
}

let frameCount = CGImageSourceGetCount(imageSource)

guard let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, frameCount, nil) else {
throw CompressError.imageIOError(.destinationCreateFail)
throw CompressError.imageIOError(.destinationMissing)
}

// 设置缩略图参数,kCGImageSourceThumbnailMaxPixelSize 为生成缩略图的大小。当设置为 800,如果图片本身大于 800*600,则生成后图片大小为 800*600,如果源图片为 700*500,则生成图片为 800*500
Expand All @@ -154,14 +155,14 @@ public extension ImageCompress {
}
} else {
guard let resizedImageFrame = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options) else {
throw CompressError.imageIOError(.thumbnailCreateFail(index: 0))
throw CompressError.imageIOError(.thumbnailMissing(index: 0))
}

CGImageDestinationAddImage(imageDestination, resizedImageFrame, nil)
}

guard CGImageDestinationFinalize(imageDestination) else {
throw CompressError.imageIOError(.destinationFinishFail)
throw CompressError.imageIOError(.destinationFinalizeFail)
}

return writeData as Data
Expand All @@ -175,7 +176,7 @@ public extension ImageCompress {
/// - Returns: 处理后数据
static func compressImageData(_ rawData: Data, limitDataSize: Int) throws -> Data {
guard rawData.imageFormat != .unknown else {
throw CompressError.illegalFormat
throw CompressError.unsupportedFormat
}

guard rawData.count > limitDataSize else {
Expand All @@ -184,18 +185,18 @@ public extension ImageCompress {

var resultData = rawData

// 若是 JPG,先用压缩系数压缩 6 次,二分法
if resultData.imageFormat == .jpg {
var compression: Double = 1
var maxCompression: Double = 1
var minCompression: Double = 0
// 若是 JPEG/HEIC,先用压缩系数压缩 6 次,二分法
if isSupportQualityCompression(of: rawData) {
var quality: CGFloat = 1
var maxQuality: CGFloat = 1
var minQuality: CGFloat = 0
for _ in 0 ..< 6 {
compression = (maxCompression + minCompression) / 2
resultData = try compressImageData(resultData, compression: compression)
quality = (maxQuality + minQuality) / 2
resultData = try compressImageData(resultData, quality: quality)
if resultData.count < Int(CGFloat(limitDataSize) * 0.9) {
minCompression = compression
minQuality = quality
} else if resultData.count > limitDataSize {
maxCompression = compression
maxQuality = quality
} else {
break
}
Expand Down Expand Up @@ -232,26 +233,37 @@ public extension ImageCompress {
/// - Returns: 处理后数据
static func compressImageData(_ rawData: Data, sampleCount: Int) throws -> Data {
guard rawData.imageFormat == .gif else {
throw CompressError.illegalFormat
throw CompressError.unsupportedFormat
}

guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
let writeData = CFDataCreateMutable(nil, 0),
let imageType = CGImageSourceGetType(imageSource) else {
throw CompressError.imageIOError(.sourceCreateFail)
throw CompressError.imageIOError(.sourceMissing)
}

// 计算帧的间隔
let frameDurations = imageSource.frameDurations

// 合并帧的时间,最长不可高于 200ms
let mergeFrameDurations = (0 ..< frameDurations.count).filter { $0 % sampleCount == 0 }.map { min(frameDurations[$0 ..< min($0 + sampleCount, frameDurations.count)].reduce(0.0) { $0 + $1 }, 0.2) }

// 抽取帧 每 n 帧使用 1 帧
let sampleImageFrames = (0 ..< frameDurations.count).filter { $0 % sampleCount == 0 }.compactMap { CGImageSourceCreateImageAtIndex(imageSource, $0, nil) }
let mergeFrameDurations = (0 ..< frameDurations.count)
.filter { $0 % sampleCount == 0 }
.map { min(frameDurations[$0 ..< min($0 + sampleCount, frameDurations.count)]
.reduce(0.0) { $0 + $1 }, 0.2) }

// 抽取帧,每 n 帧使用 1 帧
let sampleImageFrames: [CGImage] = try (0 ..< frameDurations.count)
.filter { $0 % sampleCount == 0 }
.enumerated()
.map {
guard let imageFrame = CGImageSourceCreateImageAtIndex(imageSource, $0.element, nil) else {
throw CompressError.imageIOError(.cgImageMissing(index: $0.offset))
}
return imageFrame
}

guard let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, sampleImageFrames.count, nil) else {
throw CompressError.imageIOError(.destinationCreateFail)
throw CompressError.imageIOError(.destinationFinalizeFail)
}

// 每一帧图片都进行重新编码
Expand All @@ -262,51 +274,72 @@ public extension ImageCompress {
}

guard CGImageDestinationFinalize(imageDestination) else {
throw CompressError.imageIOError(.destinationFinishFail)
throw CompressError.imageIOError(.destinationFinalizeFail)
}

return writeData as Data
}

/// 同步压缩图片到指定压缩系数,仅支持 JPG
/// 同步压缩图片到指定压缩系数,仅支持 JPEG/HEIC
///
/// - Parameters:
/// - rawData: 原始图片数据
/// - compression: 压缩系数
/// - quality: 压缩系数
/// - Returns: 处理后数据
static func compressImageData(_ rawData: Data, compression: Double) throws -> Data {
guard rawData.imageFormat == .jpg else {
return rawData
static func compressImageData(_ rawData: Data, quality: CGFloat) throws -> Data {
guard isSupportQualityCompression(of: rawData) else {
throw CompressError.unsupportedFormat
}

guard quality >= 0 && quality <= 1.0 else {
throw CompressError.illegalQuality(quality: quality)
}

guard let imageSource = CGImageSourceCreateWithData(rawData as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
let writeData = CFDataCreateMutable(nil, 0),
let imageType = CGImageSourceGetType(imageSource) else {
throw CompressError.imageIOError(.sourceCreateFail)
throw CompressError.imageIOError(.sourceMissing)
}

guard let imageDestination = CGImageDestinationCreateWithData(writeData, imageType, 1, nil) else {
throw CompressError.imageIOError(.destinationCreateFail)
throw CompressError.imageIOError(.destinationMissing)
}

let frameProperties = [kCGImageDestinationLossyCompressionQuality: compression] as CFDictionary
let frameProperties = [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary
CGImageDestinationAddImageFromSource(imageDestination, imageSource, 0, frameProperties)

guard CGImageDestinationFinalize(imageDestination) else {
throw CompressError.imageIOError(.destinationFinishFail)
throw CompressError.imageIOError(.destinationFinalizeFail)
}

return writeData as Data
}

static var isHeicSupported: Bool {
guard let ids = CGImageDestinationCopyTypeIdentifiers() as? [String] else {
return false
}
return ids.contains("public.heic")
}
}

extension ImageCompress {
static func isSupportQualityCompression(of data: Data) -> Bool {
var supportedFormats: [ImageFormat] = [.jpeg]
if isHeicSupported {
supportedFormats.append(.heic)
}
return supportedFormats.contains(data.imageFormat)
}
}

extension ImageCompress {
enum ImageFormat {
case jpg
enum ImageFormat: CaseIterable {
case unknown
case jpeg
case png
case gif
case unknown
case heic
}
}

Expand All @@ -320,50 +353,72 @@ extension Data {
}

var fitSampleCount: Int {
var sampleCount = 1
switch frameCount {
case 2 ..< 8:
sampleCount = 2
return 2
case 8 ..< 20:
sampleCount = 3
return 3
case 20 ..< 30:
sampleCount = 4
return 4
case 30 ..< 40:
sampleCount = 5
return 5
case 40 ..< Int.max:
sampleCount = 6
default: break
return 6
default:
return 1
}

return sampleCount
}

var imageSize: CGSize {
guard let imageSource = CGImageSourceCreateWithData(self as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [AnyHashable: Any],
let imageHeight = properties[kCGImagePropertyPixelHeight] as? CGFloat,
let imageWidth = properties[kCGImagePropertyPixelWidth] as? CGFloat
else {
let imageWidth = properties[kCGImagePropertyPixelWidth] as? CGFloat else {
return .zero
}
return CGSize(width: imageWidth, height: imageHeight)
}

var imageFormat: ImageCompress.ImageFormat {
var headerData = [UInt8](repeating: 0, count: 3)
copyBytes(to: &headerData, from: 0 ..< 3)
let hexString = headerData.reduce("") { $0 + String($1 & 0xFF, radix: 16) }.uppercased()
var imageFormat = ImageCompress.ImageFormat.unknown
switch hexString {
case "FFD8FF":
imageFormat = .jpg
case "89504E":
imageFormat = .png
case "474946":
imageFormat = .gif
default: break
}
return imageFormat
guard count >= 8 else {
return .unknown
}

var headerData = [UInt8](repeating: 0, count: 8)
copyBytes(to: &headerData, from: 0 ..< 8)

if headerData.hasPrefix([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
return .png
} else if headerData.hasPrefix([0xFF, 0xD8, 0xFF]) {
return .jpeg
} else if headerData.hasPrefix([0x47, 0x49, 0x46]) {
return .gif
} else if isHeicFormat {
return .heic
}

return .unknown
}

var isHeicFormat: Bool {
guard count >= 12 else {
return false
}

guard let testString = String(data: subdata(in: 4 ..< 12), encoding: .ascii) else {
return false
}
guard ["ftypheic", "ftypheix", "ftyphevc", "ftyphevx"].contains(testString.lowercased()) else {
return false
}
return true
}
}

extension Array where Element == UInt8 {
func hasPrefix(_ prefix: [UInt8]) -> Bool {
guard prefix.count <= count else { return false }
return prefix.enumerated().allSatisfy { self[$0.offset] == $0.element }
}
}

Expand Down
Loading

0 comments on commit 1693093

Please sign in to comment.