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
40 changes: 32 additions & 8 deletions Sources/SnapshotTestingWebP/CompressionQuality.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,41 @@
import Foundation

/// Defines the WebP compression quality level for snapshot encoding.
///
/// Each level maps to a `CGFloat` in the range `0.0...1.0`, where `1.0` means
/// lossless (pixel-perfect) and `0.0` means maximum lossy compression.
///
/// ## Recommended precision thresholds
///
/// When using lossy compression, pixel data changes during encoding. Use the
/// `precision` and `perceptualPrecision` parameters in snapshot strategies to
/// account for this. Recommended values per quality level:
///
/// | Quality | rawValue | precision | perceptualPrecision | Notes |
/// |-------------|----------|-----------|---------------------|------------------------------|
/// | `.lossless` | 1.0 | 1.0 | 1.0 | Pixel-perfect, no tolerance |
/// | `.low` | 0.8 | 0.95 | 0.98 | Minor artifacts |
/// | `.medium` | 0.5 | 0.90 | 0.95 | Visible compression |
/// | `.high` | 0.2 | 0.85 | 0.90 | Heavy compression |
/// | `.maximum` | 0.0 | 0.80 | 0.85 | Maximum compression |
///
/// - `precision`: Fraction of bytes that must match exactly (0.0–1.0).
/// At 0.95, up to 5% of pixel bytes may differ.
/// - `perceptualPrecision`: Human-perceptual similarity threshold (0.0–1.0),
/// based on CIE Lab Delta E. At 0.98, colors within Delta E ~2 are accepted
/// (Delta E 2.3 is the "just noticeable difference").
public enum CompressionQuality: Hashable, RawRepresentable {
/// rawValue: 1.0
/// Lossless encoding (rawValue: 1.0). Pixel-perfect output.
case lossless
/// rawValue: 0.8
/// Low compression (rawValue: 0.8). Minimal quality loss.
case low
/// rawValue: 0.5
/// Medium compression (rawValue: 0.5). Good size/quality balance.
case medium
/// rawValue: 0.2
/// High compression (rawValue: 0.2). Significant size reduction.
case high
/// rawValue: 0.0
/// Maximum compression (rawValue: 0.0). Smallest file size.
case maximum
/// rawValue: Custom value
/// Custom quality value. Clamped to 0.0...1.0 range.
case custom(CGFloat)

public init?(rawValue: CGFloat) {
Expand All @@ -27,7 +51,7 @@ public enum CompressionQuality: Hashable, RawRepresentable {
case 0.0:
self = .maximum
default:
self = .custom(rawValue)
self = .custom(min(max(rawValue, 0), 1))
}
}

Expand All @@ -44,7 +68,7 @@ public enum CompressionQuality: Hashable, RawRepresentable {
case .maximum:
return 0.0
case let .custom(value):
return value
return min(max(value, 0), 1)
}
}
}
63 changes: 42 additions & 21 deletions Sources/SnapshotTestingWebP/NSImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
fromData: { NSImage(data: $0) ?? NSImage() }
) { old, new in
guard
!compareWebP(
let message = compareWebP(
old,
new,
precision: precision,
Expand All @@ -25,10 +25,6 @@
)
else { return nil }
let difference = diffNSImage(old, new)
let message =
new.size == old.size
? "Newly-taken snapshot does not match reference."
: "Newly-taken snapshot@\(new.size) does not match reference@\(old.size)."
return (
message,
[XCTAttachment(image: old), XCTAttachment(image: new), XCTAttachment(image: difference)]
Expand Down Expand Up @@ -58,43 +54,60 @@
}
}

/// Compares two NSImages for WebP snapshot testing.
///
/// Returns `nil` if the images match within the given thresholds,
/// or a descriptive error message explaining the mismatch.
private func compareWebP(
_ old: NSImage,
_ new: NSImage,
precision: Float,
perceptualPrecision: Float,
compressionQuality: CompressionQuality
) -> Bool {
guard let oldCgImage = old.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return false }
guard let newCgImage = new.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return false }
guard oldCgImage.width != 0, newCgImage.width != 0 else { return false }
guard oldCgImage.height != 0, newCgImage.height != 0 else { return false }
guard oldCgImage.width == newCgImage.width, oldCgImage.height == newCgImage.height else { return false }
) -> String? {
guard let oldCgImage = old.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
return "Reference image could not be loaded."
}
guard let newCgImage = new.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
return "Newly-taken snapshot could not be loaded."
}
guard newCgImage.width != 0, newCgImage.height != 0 else {
return "Newly-taken snapshot is empty."
}
guard oldCgImage.width == newCgImage.width, oldCgImage.height == newCgImage.height else {
return "Newly-taken snapshot@\(new.size) does not match reference@\(old.size)."
}

guard let oldContext = context(for: oldCgImage),
let newContext = context(for: newCgImage),
let oldData = oldContext.data,
let newData = newContext.data
else { return false }
else {
return "Reference image's data could not be loaded."
}

let byteCount = oldContext.height * oldContext.bytesPerRow

// Fast path: exact match
if memcmp(oldData, newData, byteCount) == 0 { return true }
if memcmp(oldData, newData, byteCount) == 0 { return nil }

// Re-encode and compare to account for WebP codec differences
guard let webpData = new.webpData(compressionQuality: compressionQuality.rawValue),
let reencoded = NSImage(data: webpData),
let reencodedCgImage = reencoded.cgImage(forProposedRect: nil, context: nil, hints: nil),
let reencodedContext = context(for: reencodedCgImage),
let reencodedData = reencodedContext.data
else { return false }
else {
return "Newly-taken snapshot's data could not be loaded."
}

if memcmp(oldData, reencodedData, byteCount) == 0 { return true }
if memcmp(oldData, reencodedData, byteCount) == 0 { return nil }

let compareContext = reencodedContext

if precision >= 1, perceptualPrecision >= 1 { return false }
if precision >= 1, perceptualPrecision >= 1 {
return "Newly-taken snapshot does not match reference."
}

// Perceptual comparison using CILabDeltaE
if perceptualPrecision < 1 {
Expand Down Expand Up @@ -125,9 +138,12 @@
colorSpace: nil
)
let averageDeltaE = pixel[0]
// DeltaE of 2.3 is considered just noticeable difference
let maxAcceptableDeltaE = (1 - perceptualPrecision) * 100
if averageDeltaE <= maxAcceptableDeltaE { return true }
if averageDeltaE <= maxAcceptableDeltaE { return nil }

let actualPerceptualPrecision = 1 - averageDeltaE / 100
return
"Actual perceptual precision \(actualPerceptualPrecision) is less than required \(perceptualPrecision)"
}
}
}
Expand All @@ -138,7 +154,9 @@
let compareCgImage = compareContext.makeImage()!
let newRep = NSBitmapImageRep(cgImage: compareCgImage)

guard let p1 = oldRep.bitmapData, let p2 = newRep.bitmapData else { return false }
guard let p1 = oldRep.bitmapData, let p2 = newRep.bitmapData else {
return "Image bitmap data could not be accessed."
}

let pixelCount = oldRep.pixelsWide * oldRep.pixelsHigh
let totalBytes = pixelCount * 4
Expand All @@ -148,11 +166,14 @@
for offset in 0 ..< totalBytes {
if p1[offset] != p2[offset] {
differentByteCount += 1
if differentByteCount > threshold { return false }
if differentByteCount > threshold {
let actualPrecision = 1 - Float(differentByteCount) / Float(totalBytes)
return "Actual image precision \(actualPrecision) is less than required \(precision)"
}
}
}

return true
return nil
}

private func context(for cgImage: CGImage) -> CGContext? {
Expand Down
21 changes: 20 additions & 1 deletion Sources/SnapshotTestingWebP/NSView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
let initialSize = view.frame.size
if let size = size { view.frame.size = size }
guard view.frame.width > 0, view.frame.height > 0 else {
fatalError("View not renderable to image at size \(view.frame.size)")
return Async { callback in
callback(errorImage("View not renderable to image at size \(view.frame.size)"))
}
}
return view.snapshot
?? Async { callback in
Expand Down Expand Up @@ -58,4 +60,21 @@
}
}
}

/// Creates an NSImage with an error message rendered on a red background.
func errorImage(_ message: String) -> NSImage {
let size = NSSize(width: 400, height: 80)
let image = NSImage(size: size)
image.lockFocus()
NSColor.red.setFill()
NSBezierPath.fill(NSRect(origin: .zero, size: size))
let attributes: [NSAttributedString.Key: Any] = [
.foregroundColor: NSColor.white,
.font: NSFont.systemFont(ofSize: 12),
]
let text = "Error: \(message)\nPlease set an explicit size in the test." as NSString
text.draw(in: NSRect(x: 8, y: 8, width: size.width - 16, height: size.height - 16), withAttributes: attributes)
image.unlockFocus()
return image
}
#endif
4 changes: 3 additions & 1 deletion Sources/SnapshotTestingWebP/NSViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
let initialSize = view.frame.size
if let size = size { view.frame.size = size }
guard view.frame.width > 0, view.frame.height > 0 else {
fatalError("View not renderable to image at size \(view.frame.size)")
return Async { callback in
callback(errorImage("View not renderable to image at size \(view.frame.size)"))
}
}
return view.snapshot
?? Async { callback in
Expand Down
113 changes: 1 addition & 112 deletions Sources/SnapshotTestingWebP/WebP/NSImage+WebP.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#if os(macOS)
import AppKit
import libwebp

extension NSImage {
func webpData(compressionQuality: CGFloat) -> Data? {
Expand All @@ -9,117 +8,7 @@
let cgImage = bitmapImageRep.cgImage
else { return nil }

let width = cgImage.width
let height = cgImage.height
let bytesPerPixel = 4
let bytesPerRow = bytesPerPixel * width

guard let pixelData = extractPixelData(from: cgImage) else {
return nil
}
defer { pixelData.deallocate() }

let qualityValue = Float(min(max(compressionQuality, 0), 1))

return encodeWebP(
pixelData: pixelData,
width: width,
height: height,
bytesPerRow: bytesPerRow,
quality: qualityValue
)
}

private func extractPixelData(from cgImage: CGImage) -> UnsafeMutablePointer<UInt8>? {
let width = cgImage.width
let height = cgImage.height
let bytesPerPixel = 4
let bytesPerRow = bytesPerPixel * width
let pixelDataSize = height * bytesPerRow

let pixelData = UnsafeMutablePointer<UInt8>.allocate(capacity: pixelDataSize)

guard
let context = CGContext(
data: pixelData,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)
else {
pixelData.deallocate()
return nil
}

context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
return pixelData
}

private func encodeWebP(
pixelData: UnsafeMutablePointer<UInt8>,
width: Int,
height: Int,
bytesPerRow: Int,
quality: Float
) -> Data? {
var config = WebPConfig()

if quality >= 1.0 {
if WebPConfigPreset(&config, WEBP_PRESET_DEFAULT, 100) == 0 {
return nil
}
config.lossless = 1
config.exact = 1
config.method = 0
config.thread_level = 1
} else {
if WebPConfigPreset(&config, WEBP_PRESET_PICTURE, quality * 100) == 0 {
return nil
}
config.method = 0
config.thread_level = 1
config.alpha_compression = 1
config.alpha_filtering = 1
config.alpha_quality = Int32(quality * 100)
config.pass = 1
config.preprocessing = 0
config.exact = 1
}

if WebPValidateConfig(&config) == 0 {
return nil
}

var picture = WebPPicture()
if WebPPictureInit(&picture) == 0 {
return nil
}
defer { WebPPictureFree(&picture) }

picture.width = Int32(width)
picture.height = Int32(height)
picture.use_argb = 1

if WebPPictureImportRGBA(&picture, pixelData, Int32(bytesPerRow)) == 0 {
return nil
}

var writer = WebPMemoryWriter()
WebPMemoryWriterInit(&writer)
defer { WebPMemoryWriterClear(&writer) }

picture.writer = WebPMemoryWrite
picture.custom_ptr = withUnsafeMutablePointer(to: &writer) { UnsafeMutableRawPointer($0) }

let success = WebPEncode(&config, &picture)
guard success != 0 else {
return nil
}

return Data(bytes: writer.mem, count: writer.size)
return WebPEncoder.encode(cgImage, compressionQuality: compressionQuality)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Following the suggested change in WebPEncoder.swift to make it stateless, this call site needs to be updated to handle the new return type (a tuple (data: Data, statistics: WebPEncodingStatistics)?). Since this function only needs the data, you can access the .data property of the tuple.

Suggested change
return WebPEncoder.encode(cgImage, compressionQuality: compressionQuality)
return WebPEncoder.encode(cgImage, compressionQuality: compressionQuality)?.data

}
}
#endif
Loading