iOS Implementation
Source: ios/HybridThumbnail.swift ·
ios/ThumbnailEncoder.swift
Decoder: AVAssetImageGenerator
Minimum: iOS 13 (async image(at:) used on iOS 16+, copyCGImage fallback below)
The pipeline
HybridThumbnail conforms to the nitrogen-generated HybridThumbnailSpec
protocol. Its single create method returns a Promise.async { … } so all the
work runs off the JS thread.
Opening the asset
makeAsset(_:headers:) turns the url string into an AVURLAsset and reports
whether it’s remote (so errors can be mapped correctly):
if raw.hasPrefix("http://") || raw.hasPrefix("https://") {
guard let u = URL(string: raw) else { throw err("INVALID_URL", …) }
var options: [String: Any] = [:]
if let headers, !headers.isEmpty {
options["AVURLAssetHTTPHeaderFieldsKey"] = headers // ← custom headers
}
return (AVURLAsset(url: u, options: options), true)
}- Remote (
http(s)://): anAVURLAssetis created withAVURLAssetHTTPHeaderFieldsKeyset to yourheaders. AVFoundation then streams and decodes directly — there is no separate download step, and the whole file is never buffered to disk just to grab one frame. - Local (
file://or an absolute/…path): existence is checked up front withFileManager.fileExists; a miss throwsFILE_NOT_FOUNDbefore any decoder work. - Anything else throws
INVALID_URL.
Extracting the frame
let gen = AVAssetImageGenerator(asset: asset)
gen.appliesPreferredTrackTransform = true // honor rotation metadata
gen.maximumSize = CGSize(width: maxWidth, height: maxHeight)
gen.requestedTimeToleranceBefore = tol // = timeToleranceMs
gen.requestedTimeToleranceAfter = tol
let time = CMTime(value: timeStamp, timescale: 1000) // ms → CMTimeThree details matter here:
appliesPreferredTrackTransform = true— videos carry rotation metadata (e.g. portrait clips recorded by phones). This makes the extracted frame come out upright instead of sideways.maximumSize— AVFoundation scales during decode, so we never materialize a full-resolution frame just to shrink it. This is the iOS equivalent of Android’sgetScaledFrameAtTime. Aspect ratio is preserved and the frame is never upscaled.requestedTimeTolerance*— both set totimeToleranceMs. Zero tolerance forces an exact (slower) seek; the default 2 s tolerance lets the decoder snap to a nearby frame for speed.
iOS 16+ async vs. the legacy fallback
if #available(iOS 16.0, *) {
cg = try await gen.image(at: time).image // modern async API
} else {
cg = try gen.copyCGImage(at: time, actualTime: nil) // iOS 13–15
}On iOS 16+ the library uses the structured-concurrency image(at:) API, which
integrates cleanly with the Promise.async block and is the path Apple
recommends going forward. On iOS 13–15 it falls back to the synchronous
copyCGImage(at:) — same result, older API.
Error mapping
If extraction throws, the error is inspected to pick the right code:
let ns = error as NSError
if isRemote && ns.domain == NSURLErrorDomain {
throw err("REMOTE_FETCH_FAILED", …) // network problem on a remote asset
}
throw err("DECODE_FAILED", …) // everything elseA networking failure on a remote asset surfaces as REMOTE_FETCH_FAILED;
anything else (corrupt media, unsupported codec, timestamp past the end) becomes
DECODE_FAILED. See error handling for how the [CODE]
prefix reaches JS.
Encoding (ImageIO)
ThumbnailEncoder.encode is a pure function — it takes a CGImage and
returns Data?, with no filesystem or AVFoundation dependency, so it’s unit
tested in isolation (ios/Tests).
let uti = isPng ? UTType.png.identifier : UTType.jpeg.identifier
let dest = CGImageDestinationCreateWithData(data, uti as CFString, 1, nil)
let props = isPng ? [:] : [kCGImageDestinationLossyCompressionQuality: quality]
CGImageDestinationAddImage(dest, image, props as CFDictionary)
return CGImageDestinationFinalize(dest) ? data : nil- JPEG honors
quality(0..1) viakCGImageDestinationLossyCompressionQuality. - PNG is lossless —
qualityis ignored. - An unrecognized format returns
nil, which the caller maps toUNSUPPORTED_FORMAT(defense in depth — TS already validated it).
Writing & eviction
try data.write(to: outURL, options: .atomic) // WRITE_FAILED on throw
enforceLimit(dir: …, capBytes: dirSize * 1024 * 1024)- The write is atomic — readers never see a half-written file.
outputURLresolves to…/Caches/thumbnails/<name>.jpg, creating the folder if needed.<name>is yourcacheNameorthumb-<uuid>.enforceLimitlists the directory, gathers(size, modificationDate)for each file, and delegates the which-to-delete decision to the pureThumbnailEncoder.filesToEvict. See caching.
The returned width/height are taken from the decoded CGImage, i.e. the
actual scaled output dimensions.
Building & testing
- The pod is declared in
NitroThumbnail.podspec;ios/Tests/**is excluded from the shipped pod. - Pure-Swift helpers (
ThumbnailEncoder) are covered by XCTest underios/Tests. - End-to-end verification runs through the
example/app on a simulator/device.
See internals for the full build pipeline.