Skip to content

Instantly share code, notes, and snippets.

@stevengoldberg
Created May 21, 2025 13:50
Show Gist options
  • Select an option

  • Save stevengoldberg/91a4deceb9e19aa08c8f4b5203b1792d to your computer and use it in GitHub Desktop.

Select an option

Save stevengoldberg/91a4deceb9e19aa08c8f4b5203b1792d to your computer and use it in GitHub Desktop.

Swift Expo Module that decodes raw images for use in react-native-skia. It's currently limited because it writes to 8-bit PNG for rn-skia compatibility. This would clip any extended dynamic range values, so I have to set extendedDynamicRangeAmount = 0 and apply some of Apple's local tone mapping to ensure a usable image.

/*
-------------------------------------------------------------
* decodeAsync → renders RAW to PNG using minimal processing
• applies BaselineExposure
• localToneMapAmount interpolated from measuredDR()
* extractPreview → extracts embedded JPEG preview, writes to tmp, returns {uri,width,height}
* CIRAW defaults: gamutMapping ON, lensCorrection ON, boostAmount 0, all noise‑reduction 0
* isPreview flag maps to CIRAWFilter.isDraftModeEnabled
*/
import CoreImage
import ImageIO
import UniformTypeIdentifiers
import ExpoModulesCore
// MARK: – JS option object ---------------------------------------------------------
struct RawOptions: Record {
@Field var scale: Double?
@Field var isPreview: Bool?
}
// MARK: – local errors -------------------------------------------------------------
enum RawErr: Error, LocalizedError {
case bad, fail(String)
var errorDescription: String? {
switch self {
case .bad: "Unreadable RAW file."
case .fail(let msg): msg
}
}
}
// MARK: – Expo module --------------------------------------------------------------
public class ExpoRawLoaderModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoRawLoader")
// 1. RAW → PNG ---------------------------------------------------------
AsyncFunction("decodeAsync") { (uri: String, opts: RawOptions?) async throws -> [String: Any] in
try await self.renderRAW(
uri: uri,
scale: opts?.scale ?? 1.0,
isPreview: opts?.isPreview ?? false
)
}
// 2. Embedded JPEG preview -------------------------------------------
AsyncFunction("extractPreview") { (uri: String) async throws -> [String: Any] in
try self.extractPreviewJPEG(from: uri)
}
}
// MARK: – 1. render RAW ----------------------------------------------------
private func renderRAW(uri: String, scale: Double, isPreview: Bool) async throws -> [String: Any] {
let url = URL(string: uri)!
guard let raw = CIRAWFilter(imageURL: url) else { throw RawErr.bad }
let ctx = CIContext()
print("scale factor: \(scale)")
// ---- default processing flags --------------------------------------
raw.isGamutMappingEnabled = true
raw.isLensCorrectionEnabled = true
raw.isDraftModeEnabled = isPreview
raw.boostAmount = 0
raw.luminanceNoiseReductionAmount = 0
raw.colorNoiseReductionAmount = 0
raw.scaleFactor = Float(scale)
raw.extendedDynamicRangeAmount = 0
print("baselineExposure: \(raw.baselineExposure)")
// ---- dynamic‑range‑based local tone‑map ----------------------------
let sceneDR = measuredDR(from: raw, ctx: ctx) // stops
let factor = min(max((sceneDR - 2.0) / 3.0, 0.0), 1.0) // 0‑1
raw.localToneMapAmount = Float(factor * 0.5) // up to 0.5
print("measuredDR =", sceneDR, "→ localToneMapAmount =", raw.localToneMapAmount)
// ---- render & write PNG --------------------------------------------
guard let ci = raw.outputImage else { throw RawErr.fail("RAW output nil") }
let pngURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".png")
try ctx.writePNGRepresentation(of: ci,
to: pngURL,
format: .RGBA8,
colorSpace: CGColorSpace(name: CGColorSpace.sRGB)!)
return [
"uri": pngURL.absoluteString,
"width": Int(ci.extent.width),
"height": Int(ci.extent.height)
]
}
// MARK: – 2. extract preview JPEG ----------------------------------------
private func extractPreviewJPEG(from uri: String) throws -> [String: Any] {
let url = URL(string: uri)!
guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { throw RawErr.bad }
// Ask Image I/O for the *embedded preview* (no upscale, no RAW decode)
let opts: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageIfAbsent: false,
kCGImageSourceCreateThumbnailWithTransform: true
]
guard let cgPreview = CGImageSourceCreateThumbnailAtIndex(src, 0, opts as CFDictionary) else {
throw RawErr.fail("No embedded JPEG preview found")
}
// Encode CGImage → JPEG data (lossless quality)
let data = NSMutableData()
guard let dest = CGImageDestinationCreateWithData(data as CFMutableData,
UTType.jpeg.identifier as CFString,
1, nil) else {
throw RawErr.fail("CGImageDestination create failed")
}
CGImageDestinationAddImage(dest, cgPreview, nil)
guard CGImageDestinationFinalize(dest) else { throw RawErr.fail("JPEG encode failed") }
let outURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".jpg")
try data.write(to: outURL, options: .atomic)
return [
"uri": outURL.absoluteString,
"width": cgPreview.width,
"height": cgPreview.height
]
}
// MARK: – helper: measure scene dynamic range ----------------------------
private func measuredDR(from raw: CIRAWFilter, ctx: CIContext, thumbMax: CGFloat = 256) -> Double {
guard let full = raw.outputImage else { return 0 }
let longEdge = max(full.extent.width, full.extent.height)
let scale = min(1.0, thumbMax / longEdge)
let thumb = full.applyingFilter("CILanczosScaleTransform", parameters: ["inputScale": scale])
let w = Int(thumb.extent.width)
let h = Int(thumb.extent.height)
var buf = [Float](repeating: 0, count: w * h * 4)
ctx.render(thumb, toBitmap: &buf, rowBytes: w * 4 * MemoryLayout<Float>.size,
bounds: CGRect(x: 0, y: 0, width: w, height: h),
format: .RGBAf, colorSpace: nil)
var lums: [Double] = []
lums.reserveCapacity(w * h)
for i in 0 ..< w * h {
let r = Double(buf[i*4+0])
let g = Double(buf[i*4+1])
let b = Double(buf[i*4+2])
lums.append(0.299 * r + 0.587 * g + 0.114 * b)
}
let good = lums.filter { $0 > 1e-6 }
guard !good.isEmpty else { return 0 }
let sorted = good.sorted()
let p05 = sorted[Int(Double(sorted.count) * 0.05)]
let p95 = sorted[Int(Double(sorted.count) * 0.95)]
return min(max(log2(p95 / max(p05, 1e-12)), 0), 20)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment