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
2 changes: 1 addition & 1 deletion TugboatCaptureRuntime.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TugboatCaptureRuntime'
s.version = '0.1.0'
s.version = '0.1.1'
s.summary = 'Tugboat native CPU screenshot capture for Apple platforms.'
s.description = <<-DESC
Experimental Apple capture runtime. Renders the live Flutter layer into a
Expand Down
1 change: 1 addition & 0 deletions docs/releases/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

| Adapter | Adapter version | Native runtime |
| --- | --- | --- |
| Apple `TugboatCaptureRuntime` | 0.1.1 | Rejects transparent and near-white captures and validates explicit view-hierarchy capture before encoding. |
| Flutter `tugboat` | 0.8.16 | Same hosted runtimes as 0.8.15. iOS plugin looks up `registrar.viewController` at runtime so Flutter 3.35 hosts compile. |
| Flutter `tugboat` | 0.8.15 | Android `com.gettugboat.sdk:capture-runtime:0.1.0` from Maven Central. Apple `TugboatCaptureRuntime` `0.1.0` from CocoaPods trunk. Plugin iOS floor 15. |
| Flutter `tugboat` | 0.8.14 | Android `com.gettugboat.sdk:capture-runtime:0.1.0` from Maven Central. Apple `TugboatCaptureRuntime` 0.1.x compiled from monorepo sources (unpublished CocoaPod is not required). Plugin iOS floor 12; native capture still reports unsupported below iOS 15. |
Expand Down
22 changes: 14 additions & 8 deletions platforms/apple/Sources/TugboatCaptureRuntime/CaptureRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,13 @@ public final class CaptureRuntime {
}

let started = DispatchTime.now()
let bitmap = drawOnMain(view: view, request: request)
let drawOutcome = drawOnMain(view: view, request: request)
let copyTimings = CaptureTimings(surfaceCopyMicros: elapsedMicros(from: started))
if let early = haltIfNeeded(request, started, copyTimings) {
return early
}
guard let bitmap else {
let status: CaptureStatus =
view.bounds.width <= 0 || view.bounds.height <= 0 ? .surfaceUnavailable : .pixelCopyFailed
guard let bitmap = drawOutcome.bitmap else {
let status: CaptureStatus = drawOutcome.hasSurface ? .pixelCopyFailed : .surfaceUnavailable
return reply(request, status, timings: copyTimings)
}

Expand All @@ -102,7 +101,7 @@ public final class CaptureRuntime {
return reply(
request,
.skippedByDHash,
coverage: coverage,
coverage: bitmap.coverage,
width: bitmap.width,
height: bitmap.height,
dHash: processed.dHash,
Expand Down Expand Up @@ -131,7 +130,7 @@ public final class CaptureRuntime {
return reply(
request,
.ok,
coverage: coverage,
coverage: bitmap.coverage,
jpeg: jpeg,
width: bitmap.width,
height: bitmap.height,
Expand All @@ -142,9 +141,16 @@ public final class CaptureRuntime {
)
}

private func drawOnMain(view: UIView, request: CaptureRequest) -> NativeBitmap? {
private func drawOnMain(
view: UIView,
request: CaptureRequest
) -> (bitmap: NativeBitmap?, hasSurface: Bool) {
var bitmap: NativeBitmap?
var hasSurface = false
let draw = {
view.layoutIfNeeded()
hasSurface = view.bounds.width > 0 && view.bounds.height > 0
guard hasSurface else { return }
bitmap = AppleViewCapture.capture(
view: view,
pixelWidth: request.pixelWidth,
Expand All @@ -157,7 +163,7 @@ public final class CaptureRuntime {
} else {
DispatchQueue.main.sync(execute: draw)
}
return bitmap
return (bitmap, hasSurface)
}

private func haltIfNeeded(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ final class NativeBitmap {
let height: Int
let stride: Int
let pixels: UnsafeMutableRawPointer
let coverage: CaptureCoverage
let incomplete: Bool
private var context: CGContext?

Expand All @@ -15,16 +16,41 @@ final class NativeBitmap {
stride: Int,
pixels: UnsafeMutableRawPointer,
context: CGContext,
coverage: CaptureCoverage,
incomplete: Bool
) {
self.width = width
self.height = height
self.stride = stride
self.pixels = pixels
self.coverage = coverage
self.context = context
self.incomplete = incomplete
}

/// Sample both coverage modes for visible, non-near-white content before
/// publishing. This rejects untouched buffers and blank Flutter Metal frames
/// with constant work at large capture sizes.
var hasCapturedContent: Bool {
let bytes = pixels.assumingMemoryBound(to: UInt8.self)
let totalPixels = width * height
let sampleLimit = 4_096
let step = max(1, (totalPixels + sampleLimit - 1) / sampleLimit)
var linearIndex = 0
while linearIndex < totalPixels {
let y = linearIndex / width
let x = linearIndex % width
let pixel = bytes.advanced(by: y * stride + x * 4)
let isVisible = pixel[3] != 0
let isNearWhite = pixel[0] >= 250 && pixel[1] >= 250 && pixel[2] >= 250
if isVisible && !isNearWhite {
return true
}
linearIndex += step
}
return false
Comment thread
Chinmay-KB marked this conversation as resolved.
}

func jpegData() -> Data? {
guard let context else { return nil }
return JpegEncoder.encode(from: context)
Expand All @@ -42,6 +68,59 @@ enum AppleViewCapture {
pixelWidth: Int,
pixelHeight: Int,
coverage: CaptureCoverage
) -> NativeBitmap? {
switch coverage {
case .engineSurface:
guard let bitmap = captureOnce(
view: view,
pixelWidth: pixelWidth,
pixelHeight: pixelHeight,
coverage: .engineSurface,
afterScreenUpdates: false
), bitmap.hasCapturedContent else { return nil }
Comment on lines +76 to +80
return bitmap
case .viewHierarchy:
return captureHierarchy(
view: view,
pixelWidth: pixelWidth,
pixelHeight: pixelHeight
)
}
}

private static func captureHierarchy(
view: UIView,
pixelWidth: Int,
pixelHeight: Int
) -> NativeBitmap? {
var incompleteCapture: NativeBitmap?
for afterScreenUpdates in [false, true] {
guard
let bitmap = captureOnce(
view: view,
pixelWidth: pixelWidth,
pixelHeight: pixelHeight,
coverage: .viewHierarchy,
afterScreenUpdates: afterScreenUpdates
),
bitmap.hasCapturedContent
else {
continue
}
if !bitmap.incomplete {
return bitmap
}
incompleteCapture = bitmap
}
return incompleteCapture
}

private static func captureOnce(
view: UIView,
pixelWidth: Int,
pixelHeight: Int,
coverage: CaptureCoverage,
afterScreenUpdates: Bool
) -> NativeBitmap? {
let bounds = view.bounds
if bounds.width <= 0 || bounds.height <= 0 {
Expand Down Expand Up @@ -75,13 +154,20 @@ enum AppleViewCapture {
x: CGFloat(pixelWidth) / bounds.width,
y: -CGFloat(pixelHeight) / bounds.height
)
let incomplete = render(view: view, bounds: bounds, context: context, coverage: coverage)
let incomplete = render(
view: view,
bounds: bounds,
context: context,
coverage: coverage,
afterScreenUpdates: afterScreenUpdates
)
return NativeBitmap(
width: pixelWidth,
height: pixelHeight,
stride: stride,
pixels: pixels,
context: context,
coverage: coverage,
incomplete: incomplete
)
}
Expand All @@ -90,19 +176,20 @@ enum AppleViewCapture {
view: UIView,
bounds: CGRect,
context: CGContext,
coverage: CaptureCoverage
coverage: CaptureCoverage,
afterScreenUpdates: Bool
) -> Bool {
switch coverage {
case .engineSurface:
// FlutterView implements CALayerDelegate. Rendering the live layer is
// the experimental CPU path. Core Graphics does not copy CAMetalLayer
// contents by itself; physical Metal coverage is a device-lab gate.
// snapshotView copies lose the engine delegate and go blank.
// the fast CPU path. Core Graphics does not copy CAMetalLayer contents
// by itself. Blank output returns pixelCopyFailed so the caller can
// choose an explicit compatibility path.
view.layer.render(in: context)
return false
case .viewHierarchy:
UIGraphicsPushContext(context)
let drawn = view.drawHierarchy(in: bounds, afterScreenUpdates: false)
let drawn = view.drawHierarchy(in: bounds, afterScreenUpdates: afterScreenUpdates)
UIGraphicsPopContext()
return !drawn
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,69 @@ final class CaptureRuntimeModeTests: XCTestCase {
}

func testHierarchyCompatibilityCaptureReportsViewHierarchyCoverage() throws {
let result = try capture(runtime: CaptureRuntime(coverage: .viewHierarchy))
let view = HierarchyDrawingView(
frame: CGRect(x: 0, y: 0, width: 40, height: 60),
fillColor: .green
)

let result = try capture(runtime: CaptureRuntime(coverage: .viewHierarchy), view: view)

XCTAssertEqual(result.coverage, .viewHierarchy)
XCTAssertFalse(result.jpeg.isEmpty)
}

func testEngineSurfaceRejectsBlankInsteadOfBroadeningCoverage() throws {
let view = HierarchyDrawingView(
frame: CGRect(x: 0, y: 0, width: 40, height: 60),
fillColor: .green
)

let result = try capture(runtime: CaptureRuntime(), view: view)

XCTAssertEqual(result.status, .pixelCopyFailed)
XCTAssertNil(result.coverage)
XCTAssertTrue(result.jpeg.isEmpty)
}

func testTransparentCaptureFailsInsteadOfPublishingBlankJpeg() throws {
let view = HierarchyDrawingView(
frame: CGRect(x: 0, y: 0, width: 40, height: 60),
fillColor: .clear
)

let result = try capture(
runtime: CaptureRuntime(coverage: .viewHierarchy),
view: view
)

XCTAssertEqual(result.status, .pixelCopyFailed)
XCTAssertNil(result.coverage)
XCTAssertTrue(result.jpeg.isEmpty)
}

func testOpaqueWhiteCaptureFailsInsteadOfPublishingBlankJpeg() throws {
let view = HierarchyDrawingView(
frame: CGRect(x: 0, y: 0, width: 40, height: 60),
fillColor: .white
)

let result = try capture(
runtime: CaptureRuntime(coverage: .viewHierarchy),
view: view
)

XCTAssertEqual(result.status, .pixelCopyFailed)
XCTAssertNil(result.coverage)
XCTAssertTrue(result.jpeg.isEmpty)
}

private func capture(runtime: CaptureRuntime) throws -> CaptureResult {
let view = UIView(frame: CGRect(x: 0, y: 0, width: 40, height: 60))
view.backgroundColor = .red
return try capture(runtime: runtime, view: view)
}

private func capture(runtime: CaptureRuntime, view: UIView) throws -> CaptureResult {
let completed = expectation(description: "capture completes")
var captured: CaptureResult?

Expand All @@ -42,3 +96,30 @@ final class CaptureRuntimeModeTests: XCTestCase {
return try XCTUnwrap(captured)
}
}

private final class NoRenderLayer: CALayer {
override func render(in context: CGContext) {}
}

private final class HierarchyDrawingView: UIView {
private let fillColor: UIColor

init(frame: CGRect, fillColor: UIColor) {
self.fillColor = fillColor
super.init(frame: frame)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override class var layerClass: AnyClass {
NoRenderLayer.self
}

override func drawHierarchy(in rect: CGRect, afterScreenUpdates: Bool) -> Bool {
fillColor.setFill()
UIRectFill(rect)
return true
}
}
Loading