diff --git a/integrations/iphone-lidar/native/RuViewLiDAR/WebSocketStreamer.swift b/integrations/iphone-lidar/native/RuViewLiDAR/WebSocketStreamer.swift new file mode 100644 index 00000000..afd2e70f --- /dev/null +++ b/integrations/iphone-lidar/native/RuViewLiDAR/WebSocketStreamer.swift @@ -0,0 +1,93 @@ +import Foundation + +actor WebSocketStreamer { + enum StreamError: Error { + case invalidURL + } + + struct WirePacket: Codable { + struct Depth: Codable { + let width: Int + let height: Int + let encoding: String + let millimetersBase64: String + let confidenceBase64: String + } + + let type: String + let intrinsics: RuViewLiDARFrame.Intrinsics + let pose: RuViewLiDARFrame.Pose + let depth: Depth + let provenance: RuViewLiDARFrame.Provenance + } + + private var task: URLSessionWebSocketTask? + private let encoder = JSONEncoder() + private var lastSentNs: UInt64 = 0 + + func connect(to endpoint: String) throws { + guard let url = URL(string: endpoint), + url.scheme == "ws" || url.scheme == "wss" else { + throw StreamError.invalidURL + } + task?.cancel(with: .goingAway, reason: nil) + let socket = URLSession.shared.webSocketTask(with: url) + socket.resume() + task = socket + } + + func disconnect() { + task?.cancel(with: .goingAway, reason: nil) + task = nil + } + + func send(_ frame: RuViewLiDARFrame, maxFPS: UInt64 = 15, sampleStep: Int = 2) async throws { + guard let task else { return } + + let timestamp = frame.provenance.timestampNs + let minDelta = 1_000_000_000 / max(1, maxFPS) + guard timestamp >= lastSentNs + minDelta else { return } + lastSentNs = timestamp + + let packet = Self.makeWirePacket(frame, sampleStep: max(1, sampleStep)) + let data = try encoder.encode(packet) + guard let string = String(data: data, encoding: .utf8) else { return } + try await task.send(.string(string)) + } + + static func makeWirePacket(_ frame: RuViewLiDARFrame, sampleStep: Int) -> WirePacket { + let step = max(1, sampleStep) + let sourceWidth = frame.depth.width + let sourceHeight = frame.depth.height + let width = (sourceWidth + step - 1) / step + let height = (sourceHeight + step - 1) / step + + var millimeters = Data(capacity: width * height * 2) + var confidence = Data(capacity: width * height) + + for y in stride(from: 0, to: sourceHeight, by: step) { + for x in stride(from: 0, to: sourceWidth, by: step) { + let index = y * sourceWidth + x + let meters = frame.depth.meters[index] + let mm = UInt16(clamping: Int((meters * 1000).rounded())) + var littleEndian = mm.littleEndian + withUnsafeBytes(of: &littleEndian) { millimeters.append(contentsOf: $0) } + confidence.append(frame.depth.confidence[index]) + } + } + + return WirePacket( + type: frame.type, + intrinsics: frame.intrinsics, + pose: frame.pose, + depth: Depth( + width: width, + height: height, + encoding: "u16le-mm+u8-confidence", + millimetersBase64: millimeters.base64EncodedString(), + confidenceBase64: confidence.base64EncodedString() + ), + provenance: frame.provenance + ) + } +}