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

feat: change screenshot image format from Jpeg to WebP #273

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## Next

- feat: change screenshot encoding format from JPEG to WebP ([#273](https://github.com/PostHog/posthog-ios/pull/273))

## 3.17.0 - 2024-12-10

- feat: ability to add a custom label to autocapture elements ([#271](https://github.com/PostHog/posthog-ios/pull/271))
Expand Down
14 changes: 13 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,20 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "PostHog",
dependencies: ["libwebp"],
path: "PostHog",
resources: [
.copy("Resources/PrivacyInfo.xcprivacy"),
]
),
.target(
name: "libwebp",
path: "vendor/libwebp",
sources: ["src", "include"],
cSettings: [
.headerSearchPath("include"),
]
),
.testTarget(
name: "PostHogTests",
dependencies: [
Expand All @@ -39,7 +48,10 @@ let package = Package(
"OHHTTPStubs",
.product(name: "OHHTTPStubsSwift", package: "OHHTTPStubs"),
],
path: "PostHogTests"
path: "PostHogTests",
resources: [
.process("Resources"),
]
),
]
)
3 changes: 2 additions & 1 deletion PostHog.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ Pod::Spec.new do |s|
s.frameworks = 'Foundation'

s.source_files = [
'PostHog/**/*.{swift,h,hpp,m,mm,c,cpp}'
'PostHog/**/*.{swift,h,hpp,m,mm,c,cpp}',
'vendor/libwebp/**/*.{h,c}'
]
s.resource_bundles = { "PostHog" => "PostHog/Resources/PrivacyInfo.xcprivacy" }
end
897 changes: 748 additions & 149 deletions PostHog.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion PostHog/PostHog.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
// Created by Ben White on 10.01.23.
//


#import <Foundation/Foundation.h>

//! Project version number for PostHog.
Expand Down
25 changes: 20 additions & 5 deletions PostHog/Replay/UIImage+Util.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,29 @@

extension UIImage {
func toBase64(_ compressionQuality: CGFloat = 0.3) -> String? {
let jpegData = jpegData(compressionQuality: compressionQuality)
let base64 = jpegData?.base64EncodedString()
toWebPBase64(compressionQuality) ?? toJpegBase64(compressionQuality)
ioannisj marked this conversation as resolved.
Show resolved Hide resolved
marandaneto marked this conversation as resolved.
Show resolved Hide resolved
}

if let base64 = base64 {
return "data:image/jpeg;base64,\(base64)"
private func toWebPBase64(_ compressionQuality: CGFloat) -> String? {
marandaneto marked this conversation as resolved.
Show resolved Hide resolved
webpData(
compressionQuality: compressionQuality,
options: [
.alphaQuality(0), // lowest (smallest size)
ioannisj marked this conversation as resolved.
Show resolved Hide resolved
.filterSharpness(3), // [0 = off .. 7 = least sharp]
.filterStrength(100), // [0 = off .. 100 = strongest]
.filterType(1), // 0 = simple, 1 = strong
.method(5), // (0=fast, 6=slower-better)
.threadLevel(true), // use multi-threaded encoding
]
).map { data in
"data:image/webp;base64,\(data.base64EncodedString())"
}
}

return nil
private func toJpegBase64(_ compressionQuality: CGFloat) -> String? {
jpegData(compressionQuality: compressionQuality).map { data in
"data:image/jpeg;base64,\(data.base64EncodedString())"
}
}
}

Expand Down
277 changes: 277 additions & 0 deletions PostHog/Utils/UIImage+WebP.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
//
ioannisj marked this conversation as resolved.
Show resolved Hide resolved
// UIImage+WebP.swift
// PostHog
//
// Created by Yiannis Josephides on 09/12/2024.
//
// Adapted from: https://github.com/SDWebImage/SDWebImageWebPCoder/blob/master/SDWebImageWebPCoder/Classes/SDImageWebPCoder.m

#if os(iOS)
import Accelerate
import CoreGraphics
import Foundation
#if canImport(libwebp)
// SPM package is linked via a lib since mix-code is not yet supported
import libwebp
#endif
import UIKit

extension UIImage {
/**
Returns a data object that contains the image in WebP format.

- Parameters:
- compressionQuality: desired compression quality [0...1] (0=max/lowest quality, 1=low/high quality) - Clamped to range
- options: list of [WebPOption]
- Returns: A data object containing the JPEG data, or nil if there’s a problem generating the data.
*/
func webpData(compressionQuality: CGFloat, options: [WebPOption] = []) -> Data? {
// Early exit if image is missing
guard let cgImage = cgImage else {
return nil
}

// clamp compressionQuality to the valid range [0.0, 1.0]
let compressionQuality = min(max(compressionQuality, 0.0), 1.0)
ioannisj marked this conversation as resolved.
Show resolved Hide resolved

// validate dimensions
let width = Int(cgImage.width)
let height = Int(cgImage.height)

guard width > 0, width <= WEBP_MAX_DIMENSION, height > 0, height <= WEBP_MAX_DIMENSION else {
return nil
}
marandaneto marked this conversation as resolved.
Show resolved Hide resolved

let bitmapInfo = cgImage.bitmapInfo
let alphaInfo = CGImageAlphaInfo(rawValue: bitmapInfo.rawValue & CGBitmapInfo.alphaInfoMask.rawValue)

// Prepare destination format

let hasAlpha = !(
alphaInfo == CGImageAlphaInfo.none ||
alphaInfo == CGImageAlphaInfo.noneSkipFirst ||
alphaInfo == CGImageAlphaInfo.noneSkipLast
)

// try to use image color space if ~rgb
let colorSpace: CGColorSpace = cgImage.colorSpace?.model == .rgb
? cgImage.colorSpace! // safe from previous check
: CGColorSpace(name: CGColorSpace.linearSRGB)!
let renderingIntent = cgImage.renderingIntent

guard let destFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: hasAlpha ? 32 : 24, // RGB888/RGBA8888
colorSpace: colorSpace,
bitmapInfo: hasAlpha
? CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue | CGBitmapInfo.byteOrderDefault.rawValue)
: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue | CGBitmapInfo.byteOrderDefault.rawValue),
renderingIntent: renderingIntent
) else {
return nil
}

guard let dest = try? vImage_Buffer(cgImage: cgImage, format: destFormat, flags: .noFlags) else {
hedgeLog("Error initializing WebP image buffer")
return nil
}
defer { dest.data?.deallocate() }

guard let rgba = dest.data else { // byte array
hedgeLog("Could not get rgba byte array from destination format")
return nil
}
let bytesPerRow = dest.rowBytes

let quality = Float(compressionQuality * 100) // WebP quality is 0-100

var config = WebPConfig()
var picture = WebPPicture()
var writer = WebPMemoryWriter()

// get present...
guard WebPConfigPreset(&config, WEBP_PRESET_DEFAULT, quality) != 0, WebPPictureInit(&picture) != 0 else {
hedgeLog("Error initializing WebPPicture")
return nil
}
// ...and set options
setWebPOptions(config: &config, options: options)

withUnsafeMutablePointer(to: &writer) { writerPointer in
picture.use_argb = 1 // Lossy encoding uses YUV for internal bitstream
picture.width = Int32(width)
picture.height = Int32(height)
picture.writer = WebPMemoryWrite
picture.custom_ptr = UnsafeMutableRawPointer(writerPointer)
}

WebPMemoryWriterInit(&writer)

defer {
WebPMemoryWriterClear(&writer)
WebPPictureFree(&picture)
}

let result: Int32
if hasAlpha {
// RGBA8888 - 4 channels
result = WebPPictureImportRGBA(&picture, rgba.bindMemory(to: UInt8.self, capacity: 4), Int32(bytesPerRow))
} else {
// RGB888 - 3 channels
result = WebPPictureImportRGB(&picture, rgba.bindMemory(to: UInt8.self, capacity: 3), Int32(bytesPerRow))
}

if result == 0 {
hedgeLog("Could not read WebPPicture")
return nil
}

if WebPEncode(&config, &picture) == 0 {
hedgeLog("Could not encode WebP image")
return nil
}

let webpData = Data(bytes: writer.mem, count: writer.size)

return webpData
}

// swiftlint:disable:next cyclomatic_complexity
private func setWebPOptions(
config: UnsafeMutablePointer<WebPConfig>,
options: [WebPOption]
) {
for option in options {
switch option {
case let .targetSize(value):
config.pointee.target_size = value
case let .emulateJPEGSize(value):
config.pointee.emulate_jpeg_size = value ? 1 : 0
case let .nearLossless(value):
config.pointee.near_lossless = value
case let .lossless(value):
config.pointee.lossless = value ? 1 : 0
case let .exact(value):
config.pointee.exact = value ? 1 : 0
case let .method(value):
config.pointee.method = value
case let .targetPSNR(value):
config.pointee.target_PSNR = Float(value)
case let .segments(value):
config.pointee.segments = value
case let .snsStrength(value):
config.pointee.sns_strength = value
case let .filterStrength(value):
config.pointee.filter_strength = value
case let .filterSharpness(value):
config.pointee.filter_sharpness = value
case let .filterType(value):
config.pointee.filter_type = value
case let .autofilter(value):
config.pointee.autofilter = value ? 1 : 0
case let .alphaCompression(value):
config.pointee.alpha_compression = value
case let .alphaFiltering(value):
config.pointee.alpha_filtering = value
case let .alphaQuality(value):
config.pointee.alpha_quality = value
case let .passes(value):
config.pointee.pass = value
case let .showCompressed(value):
config.pointee.show_compressed = value ? 1 : 0
case let .preprocessing(value):
config.pointee.preprocessing = value
case let .partitions(value):
config.pointee.partitions = value
case let .partitionLimit(value):
config.pointee.partition_limit = value
case let .threadLevel(value):
config.pointee.thread_level = value ? 1 : 0
case let .lowMemory(value):
config.pointee.low_memory = value ? 1 : 0
case let .useDeltaPalette(value):
config.pointee.use_delta_palette = value ? 1 : 0
case let .useSharpYUV(value):
config.pointee.use_sharp_yuv = value ? 1 : 0
}
}
}
}

enum WebPOption {
/// If non-zero, set the desired target size in bytes. Takes precedence over the 'compression' parameter.
case targetSize(Int32)

/// If true, compression parameters will be remapped to better match the expected output size from JPEG compression. Generally, the output size will be similar but the degradation will be lower.
case emulateJPEGSize(Bool)

/// Near lossless encoding [0 = max loss .. 100 = off (default)].
case nearLossless(Int32)

/// Lossless encoding (0=lossy(default), 1=lossless).
case lossless(Bool)

/// If non-zero, preserve the exact RGB values under transparent area. Otherwise, discard this invisible RGB information for better compression. The default value is 0.
case exact(Bool)

/// Quality/speed trade-off (0=fast, 6=slower-better).
case method(Int32)

/// If non-zero, specifies the minimal distortion to try to achieve. Takes precedence over target_size.
case targetPSNR(CGFloat)

/// Maximum number of segments to use, in [1..4].
case segments(Int32)

/// Spatial Noise Shaping. 0=off, 100=maximum. Default is 50.
case snsStrength(Int32)

/// Range: [0 = off .. 100 = strongest]. Default is 60.
case filterStrength(Int32)

/// Range: [0 = off .. 7 = least sharp]. Default is 0.
case filterSharpness(Int32)

/// Filtering type: 0 = simple, 1 = strong (only used if filter_strength > 0 or autofilter > 0). Default is 1.
case filterType(Int32)

/// Auto adjust filter's strength [0 = off, 1 = on]. Default is 0.
case autofilter(Bool)

/// Algorithm for encoding the alpha plane (0 = none, 1 = compressed with WebP lossless). Default is 1.
case alphaCompression(Int32)

/// Predictive filtering method for alpha plane. 0: none, 1: fast, 2: best. Default is 1.
case alphaFiltering(Int32)

/// Between 0 (smallest size) and 100 (lossless). Default is 100.
case alphaQuality(Int32)

/// Number of entropy-analysis passes (in [1..10]). Default is 1.
case passes(Int32)

/// If true, export the compressed picture back. In-loop filtering is not applied.
case showCompressed(Bool)

/// Preprocessing filter (0=none, 1=segment-smooth, 2=pseudo-random dithering). Default is 0.
case preprocessing(Int32)

/// Log2(number of token partitions) in [0..3]. Default is set to 0 for easier progressive decoding.
case partitions(Int32)

/// Quality degradation allowed to fit the 512k limit on prediction modes coding (0: no degradation, 100: maximum possible degradation). Default is 0.
case partitionLimit(Int32)

/// If non-zero, try and use multi-threaded encoding.
case threadLevel(Bool)

/// If set, reduce memory usage (but increase CPU use).
case lowMemory(Bool)

/// Reserved for future lossless feature.
case useDeltaPalette(Bool)

/// If needed, use sharp (and slow) RGB->YUV conversion.
case useSharpYUV(Bool)
}
#endif
Loading
Loading