From 370af92ddaef4e8ee277b9b02052084fa7cbd630 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Feb 2026 19:29:02 +0000 Subject: [PATCH] Refactor: extract shared WebPEncoder, unify error handling, improve API - Extract shared WebP encoding logic into WebPEncoder.swift, eliminating ~200 lines of duplicated code between UIImage+WebP and NSImage+WebP - Add WebPEncodingStatistics API for tracking compression metrics (original size, encoded size, compression ratio, encoding duration) - Unify NSImage diffing to return descriptive String? error messages matching UIImage behavior (was returning uninformative Bool) - Replace fatalError in NSView/NSViewController with graceful error images when view size is zero, preventing test runner crashes - Add value clamping (0.0...1.0) for CompressionQuality.custom() in both init(rawValue:) and rawValue getter - Document recommended precision thresholds per compression quality level with CIE Lab Delta E explanation https://claude.ai/code/session_01C2v2vuCbmKoHnXZyAAPtRg --- .../CompressionQuality.swift | 40 +++- Sources/SnapshotTestingWebP/NSImage.swift | 63 ++++--- Sources/SnapshotTestingWebP/NSView.swift | 21 ++- .../NSViewController.swift | 4 +- .../WebP/NSImage+WebP.swift | 113 +----------- .../WebP/UIImage+WebP.swift | 114 +----------- .../WebP/WebPEncoder.swift | 172 ++++++++++++++++++ 7 files changed, 271 insertions(+), 256 deletions(-) create mode 100644 Sources/SnapshotTestingWebP/WebP/WebPEncoder.swift diff --git a/Sources/SnapshotTestingWebP/CompressionQuality.swift b/Sources/SnapshotTestingWebP/CompressionQuality.swift index 0f1b6f2..0179308 100644 --- a/Sources/SnapshotTestingWebP/CompressionQuality.swift +++ b/Sources/SnapshotTestingWebP/CompressionQuality.swift @@ -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) { @@ -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)) } } @@ -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) } } } diff --git a/Sources/SnapshotTestingWebP/NSImage.swift b/Sources/SnapshotTestingWebP/NSImage.swift index b8f4c88..e5c76cf 100644 --- a/Sources/SnapshotTestingWebP/NSImage.swift +++ b/Sources/SnapshotTestingWebP/NSImage.swift @@ -16,7 +16,7 @@ fromData: { NSImage(data: $0) ?? NSImage() } ) { old, new in guard - !compareWebP( + let message = compareWebP( old, new, precision: precision, @@ -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)] @@ -58,29 +54,42 @@ } } + /// 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), @@ -88,13 +97,17 @@ 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 { @@ -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)" } } } @@ -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 @@ -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? { diff --git a/Sources/SnapshotTestingWebP/NSView.swift b/Sources/SnapshotTestingWebP/NSView.swift index 11653a1..76ee31b 100644 --- a/Sources/SnapshotTestingWebP/NSView.swift +++ b/Sources/SnapshotTestingWebP/NSView.swift @@ -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 @@ -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 diff --git a/Sources/SnapshotTestingWebP/NSViewController.swift b/Sources/SnapshotTestingWebP/NSViewController.swift index 2da4f7a..a05e26b 100644 --- a/Sources/SnapshotTestingWebP/NSViewController.swift +++ b/Sources/SnapshotTestingWebP/NSViewController.swift @@ -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 diff --git a/Sources/SnapshotTestingWebP/WebP/NSImage+WebP.swift b/Sources/SnapshotTestingWebP/WebP/NSImage+WebP.swift index 43f7dbe..c064a28 100644 --- a/Sources/SnapshotTestingWebP/WebP/NSImage+WebP.swift +++ b/Sources/SnapshotTestingWebP/WebP/NSImage+WebP.swift @@ -1,6 +1,5 @@ #if os(macOS) import AppKit - import libwebp extension NSImage { func webpData(compressionQuality: CGFloat) -> Data? { @@ -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? { - let width = cgImage.width - let height = cgImage.height - let bytesPerPixel = 4 - let bytesPerRow = bytesPerPixel * width - let pixelDataSize = height * bytesPerRow - - let pixelData = UnsafeMutablePointer.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, - 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) } } #endif diff --git a/Sources/SnapshotTestingWebP/WebP/UIImage+WebP.swift b/Sources/SnapshotTestingWebP/WebP/UIImage+WebP.swift index 42cd086..7402850 100644 --- a/Sources/SnapshotTestingWebP/WebP/UIImage+WebP.swift +++ b/Sources/SnapshotTestingWebP/WebP/UIImage+WebP.swift @@ -1,122 +1,10 @@ #if os(iOS) || os(tvOS) import UIKit - import libwebp extension UIImage { func webpData(compressionQuality: CGFloat) -> Data? { guard let cgImage = 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? { - let width = cgImage.width - let height = cgImage.height - let bytesPerPixel = 4 - let bytesPerRow = bytesPerPixel * width - let pixelDataSize = height * bytesPerRow - - let pixelData = UnsafeMutablePointer.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, - 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) } } #endif diff --git a/Sources/SnapshotTestingWebP/WebP/WebPEncoder.swift b/Sources/SnapshotTestingWebP/WebP/WebPEncoder.swift new file mode 100644 index 0000000..8d39a94 --- /dev/null +++ b/Sources/SnapshotTestingWebP/WebP/WebPEncoder.swift @@ -0,0 +1,172 @@ +#if os(iOS) || os(tvOS) || os(macOS) + import CoreGraphics + import Foundation + import libwebp + + /// Statistics collected during a WebP encoding operation. + public struct WebPEncodingStatistics: Sendable { + /// Raw pixel data size in bytes (width * height * 4). + public let originalSize: Int + /// Encoded WebP data size in bytes. + public let encodedSize: Int + /// Time spent encoding, in seconds. + public let encodingDuration: TimeInterval + /// Compression ratio (originalSize / encodedSize). Higher means more compression. + public var compressionRatio: Double { + guard encodedSize > 0 else { return 0 } + return Double(originalSize) / Double(encodedSize) + } + /// Space savings as a percentage (0.0–1.0). E.g., 0.82 means 82% smaller. + public var spaceSavings: Double { + guard originalSize > 0 else { return 0 } + return 1.0 - Double(encodedSize) / Double(originalSize) + } + } + + enum WebPEncoder { + /// The statistics from the most recent encoding operation. + /// Thread-safe via `NSLock`. + static var lastStatistics: WebPEncodingStatistics? { + get { lock.lock(); defer { lock.unlock() }; return _lastStatistics } + set { lock.lock(); defer { lock.unlock() }; _lastStatistics = newValue } + } + + private static var _lastStatistics: WebPEncodingStatistics? + private static let lock = NSLock() + + /// Encodes a `CGImage` to WebP data at the given compression quality. + /// + /// - Parameters: + /// - cgImage: The source image. + /// - compressionQuality: Value in 0.0...1.0 (clamped). 1.0 = lossless. + /// - Returns: Encoded WebP `Data`, or `nil` on failure. + static func encode(_ cgImage: CGImage, compressionQuality: CGFloat) -> Data? { + 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)) + let originalSize = width * height * bytesPerPixel + let start = CFAbsoluteTimeGetCurrent() + + guard let data = encodeWebP( + pixelData: pixelData, + width: width, + height: height, + bytesPerRow: bytesPerRow, + quality: qualityValue + ) else { + return nil + } + + let duration = CFAbsoluteTimeGetCurrent() - start + lastStatistics = WebPEncodingStatistics( + originalSize: originalSize, + encodedSize: data.count, + encodingDuration: duration + ) + + return data + } + + // MARK: - Private + + private static func extractPixelData(from cgImage: CGImage) -> UnsafeMutablePointer? { + let width = cgImage.width + let height = cgImage.height + let bytesPerPixel = 4 + let bytesPerRow = bytesPerPixel * width + let pixelDataSize = height * bytesPerRow + + let pixelData = UnsafeMutablePointer.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 static func encodeWebP( + pixelData: UnsafeMutablePointer, + 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) + } + } +#endif