this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Restore support for streaming

+901 -411
+25 -3
README.md
··· 65 65 ### Video Streaming 66 66 - **Screenshot-based Streaming**: Capture simulator video at 1-30 FPS 67 67 - **Multiple Output Formats**: MJPEG, raw JPEG, ffmpeg-compatible, BGRA 68 + - **H.264 Recording**: Use the `record-video` command to write MP4 files with hardware-friendly encoding 68 69 - **Configurable Quality**: Adjust JPEG quality and scale factor 69 70 - **Real-time Performance**: Efficient frame timing for smooth playback 70 71 ··· 209 210 axe key-sequence --keycodes 11,8,15,15,18 --udid SIMULATOR_UDID # Type "hello" 210 211 ``` 211 212 213 + ### **Video Streaming** 214 + 215 + ```bash 216 + # Stream MJPEG frames over stdout (default format) 217 + axe stream-video --udid SIMULATOR_UDID --fps 10 --format mjpeg > stream.mjpeg 218 + 219 + # Pipe JPEG frames directly into ffmpeg 220 + axe stream-video --udid SIMULATOR_UDID --fps 30 --format ffmpeg | \ 221 + ffmpeg -f image2pipe -framerate 30 -i - -c:v libx264 -preset ultrafast output.mp4 222 + 223 + # Stream raw JPEG frames with length prefixes for custom servers 224 + axe stream-video --udid SIMULATOR_UDID --fps 12 --format raw | custom-stream-consumer 225 + 226 + # Legacy BGRA stream for backward compatibility 227 + axe stream-video --udid SIMULATOR_UDID --format bgra | \ 228 + ffmpeg -f rawvideo -pixel_format bgra -video_size 393x852 -i - output.mp4 229 + 230 + # Record directly to MP4 using the dedicated recorder 231 + axe record-video --udid SIMULATOR_UDID --fps 15 --output recording.mp4 232 + ``` 233 + 212 234 ### **Video Recording** 213 235 214 236 ```bash 215 237 # Record the simulator to an MP4 file (QuickTime compatible) 216 - axe stream-video --udid SIMULATOR_UDID --fps 15 --output recording.mp4 238 + axe record-video --udid SIMULATOR_UDID --fps 15 --output recording.mp4 217 239 218 240 # Let AXe pick a timestamped filename in the current directory 219 - axe stream-video --udid SIMULATOR_UDID --fps 20 241 + axe record-video --udid SIMULATOR_UDID --fps 20 220 242 221 243 # Tweak quality/scale to reduce file size 222 - axe stream-video --udid SIMULATOR_UDID --fps 10 --quality 60 --scale 0.5 --output low-bandwidth.mp4 244 + axe record-video --udid SIMULATOR_UDID --fps 10 --quality 60 --scale 0.5 --output low-bandwidth.mp4 223 245 ``` 224 246 225 247 > [!TIP]
+215
Sources/AXe/Commands/RecordVideo.swift
··· 1 + import ArgumentParser 2 + import Foundation 3 + import FBSimulatorControl 4 + @preconcurrency import FBControlCore 5 + import AVFoundation 6 + 7 + struct RecordVideo: AsyncParsableCommand { 8 + static let configuration = CommandConfiguration( 9 + commandName: "record-video", 10 + abstract: "Record the simulator display to an MP4 file using H.264 encoding" 11 + ) 12 + 13 + @Option(name: .customLong("udid"), help: "The UDID of the simulator.") 14 + var simulatorUDID: String 15 + 16 + @Option(help: "Frames per second (1-30, default: 10)") 17 + var fps: Int = 10 18 + 19 + @Option(help: "Quality factor (1-100) controlling bitrate (default: 80)") 20 + var quality: Int = 80 21 + 22 + @Option(help: "Scale factor (0.1-1.0, default: 1.0)") 23 + var scale: Double = 1.0 24 + 25 + @Option(help: "Output MP4 file path. Defaults to axe-video-<timestamp>.mp4 in the current directory.") 26 + var output: String? 27 + 28 + func validate() throws { 29 + guard fps >= 1 && fps <= 30 else { 30 + throw ValidationError("FPS must be between 1 and 30") 31 + } 32 + 33 + guard quality >= 1 && quality <= 100 else { 34 + throw ValidationError("Quality must be between 1 and 100") 35 + } 36 + 37 + guard scale >= 0.1 && scale <= 1.0 else { 38 + throw ValidationError("Scale must be between 0.1 and 1.0") 39 + } 40 + } 41 + 42 + func run() async throws { 43 + let logger = AxeLogger() 44 + try await setup(logger: logger) 45 + try await performGlobalSetup(logger: logger) 46 + 47 + let trimmedUDID = simulatorUDID.trimmingCharacters(in: .whitespacesAndNewlines) 48 + guard !trimmedUDID.isEmpty else { 49 + throw CLIError(errorDescription: "Simulator UDID cannot be empty. Use --udid to specify a simulator.") 50 + } 51 + 52 + let simulatorSet = try await getSimulatorSet(deviceSetPath: nil, logger: logger, reporter: EmptyEventReporter.shared) 53 + guard let targetSimulator = simulatorSet.allSimulators.first(where: { $0.udid == trimmedUDID }) else { 54 + throw CLIError(errorDescription: "Simulator with UDID \(trimmedUDID) not found.") 55 + } 56 + 57 + guard targetSimulator.state == .booted else { 58 + let stateDescription = FBiOSTargetStateStringFromState(targetSimulator.state) 59 + throw CLIError(errorDescription: "Simulator \(trimmedUDID) is not booted. Current state: \(stateDescription)") 60 + } 61 + 62 + let outputURL = try prepareOutputURL() 63 + FileHandle.standardError.write(Data("Recording simulator \(targetSimulator.udid) to \(outputURL.path)\n".utf8)) 64 + FileHandle.standardError.write(Data("Press Ctrl+C to stop recording\n".utf8)) 65 + 66 + let cancellationFlag = CancellationFlag() 67 + let signalObserver = SignalObserver(signals: [SIGINT, SIGTERM]) { 68 + Task { 69 + await cancellationFlag.cancel() 70 + } 71 + } 72 + defer { signalObserver.invalidate() } 73 + 74 + do { 75 + try await recordVideo( 76 + simulator: targetSimulator, 77 + outputURL: outputURL, 78 + fps: fps, 79 + quality: quality, 80 + scale: scale, 81 + cancellationFlag: cancellationFlag 82 + ) 83 + FileHandle.standardError.write(Data("Recording saved to \(outputURL.path)\n".utf8)) 84 + print(outputURL.path) 85 + } catch { 86 + throw CLIError(errorDescription: "Failed to record video: \(error.localizedDescription)") 87 + } 88 + } 89 + 90 + private func recordVideo( 91 + simulator: FBSimulator, 92 + outputURL: URL, 93 + fps: Int, 94 + quality: Int, 95 + scale: Double, 96 + cancellationFlag: CancellationFlag 97 + ) async throws { 98 + let initialFrameData = try await VideoFrameUtilities.captureScreenshotData(from: simulator) 99 + guard let initialImage = VideoFrameUtilities.makeCGImage(from: initialFrameData) else { 100 + throw CLIError(errorDescription: "Failed to decode simulator screenshot") 101 + } 102 + 103 + let dimensions = VideoFrameUtilities.computeDimensions(for: initialImage, scale: scale) 104 + let recorder = try H264StreamRecorder( 105 + outputURL: outputURL, 106 + width: dimensions.width, 107 + height: dimensions.height, 108 + fps: fps, 109 + quality: quality 110 + ) 111 + defer { recorder.invalidate() } 112 + 113 + let frameInterval = 1.0 / Double(fps) 114 + var frameCount: Int64 = 1 115 + var lastLogFrame: Int64 = 0 116 + let startTime = Date() 117 + var lastPresentationTime = CMTime.zero 118 + 119 + try recorder.append(image: initialImage, presentationTime: .zero) 120 + let writerStartTime = Date() 121 + 122 + while true { 123 + if Task.isCancelled { 124 + break 125 + } 126 + if await cancellationFlag.isCancelled() { 127 + break 128 + } 129 + 130 + let frameStart = Date() 131 + 132 + do { 133 + let frameData = try await VideoFrameUtilities.captureScreenshotData(from: simulator) 134 + guard let cgImage = VideoFrameUtilities.makeCGImage(from: frameData) else { 135 + FileHandle.standardError.write(Data("Unable to decode screenshot frame\n".utf8)) 136 + continue 137 + } 138 + 139 + let now = Date() 140 + var presentationTime = CMTime(seconds: now.timeIntervalSince(writerStartTime), preferredTimescale: 600) 141 + if presentationTime <= lastPresentationTime { 142 + presentationTime = CMTimeAdd(lastPresentationTime, CMTime(value: 1, timescale: 600)) 143 + } 144 + 145 + try recorder.append(image: cgImage, presentationTime: presentationTime) 146 + lastPresentationTime = presentationTime 147 + frameCount += 1 148 + 149 + if frameCount - lastLogFrame >= Int64(fps) { 150 + lastLogFrame = frameCount 151 + let elapsed = Date().timeIntervalSince(startTime) 152 + let actualFPS = Double(frameCount) / max(elapsed, 0.0001) 153 + FileHandle.standardError.write(Data(String(format: "Captured %lld frames (%.1f FPS actual)\n", frameCount, actualFPS).utf8)) 154 + } 155 + } catch { 156 + FileHandle.standardError.write(Data("Error capturing frame: \(error.localizedDescription)\n".utf8)) 157 + } 158 + 159 + let elapsed = Date().timeIntervalSince(frameStart) 160 + let sleepTime = frameInterval - elapsed 161 + if sleepTime > 0 { 162 + try await Task.sleep(nanoseconds: UInt64(sleepTime * 1_000_000_000)) 163 + } 164 + } 165 + 166 + try await recorder.finish() 167 + } 168 + 169 + private func prepareOutputURL() throws -> URL { 170 + let fileManager = FileManager.default 171 + let formatter = ISO8601DateFormatter() 172 + formatter.formatOptions = [.withInternetDateTime] 173 + 174 + let providedPath = output?.trimmingCharacters(in: .whitespacesAndNewlines) 175 + let resolvedPath: String 176 + if let providedPath, !providedPath.isEmpty { 177 + resolvedPath = (providedPath as NSString).expandingTildeInPath 178 + } else { 179 + resolvedPath = "axe-video-\(formatter.string(from: Date())).mp4" 180 + } 181 + 182 + let baseURL: URL 183 + if resolvedPath.hasPrefix("/") { 184 + baseURL = URL(fileURLWithPath: resolvedPath) 185 + } else { 186 + baseURL = URL(fileURLWithPath: fileManager.currentDirectoryPath).appendingPathComponent(resolvedPath) 187 + } 188 + 189 + var isDirectory: ObjCBool = false 190 + if fileManager.fileExists(atPath: baseURL.path, isDirectory: &isDirectory), isDirectory.boolValue { 191 + let filename = "axe-video-\(formatter.string(from: Date())).mp4" 192 + let directoryURL = baseURL 193 + if !fileManager.fileExists(atPath: directoryURL.path) { 194 + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) 195 + } 196 + return directoryURL.appendingPathComponent(filename) 197 + } 198 + 199 + let directoryURL = baseURL.deletingLastPathComponent() 200 + if !fileManager.fileExists(atPath: directoryURL.path) { 201 + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) 202 + } 203 + 204 + if fileManager.fileExists(atPath: baseURL.path) { 205 + var existingIsDirectory: ObjCBool = false 206 + fileManager.fileExists(atPath: baseURL.path, isDirectory: &existingIsDirectory) 207 + if existingIsDirectory.boolValue { 208 + throw CLIError(errorDescription: "Output path \(baseURL.path) is a directory. Provide a file name or point to a different location.") 209 + } 210 + try fileManager.removeItem(at: baseURL) 211 + } 212 + 213 + return baseURL 214 + } 215 + }
+116 -307
Sources/AXe/Commands/StreamVideo.swift
··· 2 2 import Foundation 3 3 import FBSimulatorControl 4 4 @preconcurrency import FBControlCore 5 - import AVFoundation 6 - import ImageIO 7 - import Dispatch 8 - import Darwin 9 5 10 6 struct StreamVideo: AsyncParsableCommand { 7 + enum OutputFormat: String, ExpressibleByArgument { 8 + case mjpeg 9 + case raw 10 + case ffmpeg 11 + case bgra 12 + } 13 + 11 14 static let configuration = CommandConfiguration( 12 15 commandName: "stream-video", 13 - abstract: "Record the simulator display to an MP4 file using H.264 encoding" 16 + abstract: "Stream simulator frames to stdout using screenshot capture" 14 17 ) 15 18 16 19 @Option(name: .customLong("udid"), help: "The UDID of the simulator.") 17 20 var simulatorUDID: String 18 21 22 + @Option(help: "Output format: mjpeg, raw, ffmpeg, bgra (default: mjpeg)") 23 + var format: OutputFormat = .mjpeg 24 + 19 25 @Option(help: "Frames per second (1-30, default: 10)") 20 26 var fps: Int = 10 21 27 22 - @Option(help: "Quality factor (1-100) controlling bitrate (default: 80)") 28 + @Option(help: "JPEG quality (1-100, default: 80)") 23 29 var quality: Int = 80 24 30 25 31 @Option(help: "Scale factor (0.1-1.0, default: 1.0)") 26 32 var scale: Double = 1.0 27 - 28 - @Option(help: "Output MP4 file path. Defaults to axe-video-<timestamp>.mp4 in the current directory.") 29 - var output: String? 30 33 31 34 func validate() throws { 32 35 guard fps >= 1 && fps <= 30 else { ··· 62 65 throw CLIError(errorDescription: "Simulator \(trimmedUDID) is not booted. Current state: \(stateDescription)") 63 66 } 64 67 65 - let outputURL = try prepareOutputURL() 66 - FileHandle.standardError.write(Data("Recording simulator \(targetSimulator.udid) to \(outputURL.path)\n".utf8)) 67 - FileHandle.standardError.write(Data("Press Ctrl+C to stop recording\n".utf8)) 68 - 69 68 let cancellationFlag = CancellationFlag() 70 69 let signalObserver = SignalObserver(signals: [SIGINT, SIGTERM]) { 71 70 Task { ··· 74 73 } 75 74 defer { signalObserver.invalidate() } 76 75 77 - do { 78 - try await recordVideo( 79 - simulator: targetSimulator, 80 - outputURL: outputURL, 81 - fps: fps, 82 - quality: quality, 83 - scale: scale, 84 - cancellationFlag: cancellationFlag 85 - ) 86 - FileHandle.standardError.write(Data("Recording saved to \(outputURL.path)\n".utf8)) 87 - print(outputURL.path) 88 - } catch { 89 - throw CLIError(errorDescription: "Failed to record video: \(error.localizedDescription)") 76 + switch format { 77 + case .bgra: 78 + try await streamBGRA(to: targetSimulator, cancellationFlag: cancellationFlag) 79 + default: 80 + try await streamCompressedFrames(from: targetSimulator, format: format, cancellationFlag: cancellationFlag) 90 81 } 91 82 } 92 83 93 - // MARK: - Recording 84 + // MARK: - Screenshot-based streaming 94 85 95 - private func recordVideo( 96 - simulator: FBSimulator, 97 - outputURL: URL, 98 - fps: Int, 99 - quality: Int, 100 - scale: Double, 86 + private func streamCompressedFrames( 87 + from simulator: FBSimulator, 88 + format: OutputFormat, 101 89 cancellationFlag: CancellationFlag 102 90 ) async throws { 103 - let initialFrameData = try await captureScreenshotData(from: simulator) 104 - guard let initialImage = Self.makeCGImage(from: initialFrameData) else { 105 - throw CLIError(errorDescription: "Failed to decode simulator screenshot") 91 + FileHandle.standardError.write(Data("Starting screenshot-based video stream from simulator \(simulator.udid)...\n".utf8)) 92 + FileHandle.standardError.write(Data("Format: \(format.rawValue), FPS: \(fps), Quality: \(quality), Scale: \(scale)\n".utf8)) 93 + FileHandle.standardError.write(Data("Press Ctrl+C to stop streaming\n".utf8)) 94 + 95 + let frameInterval = 1.0 / Double(fps) 96 + let mjpegBoundary = "--mjpegstream" 97 + let destination = FileHandle.standardOutput 98 + 99 + if format == .mjpeg { 100 + let header = "HTTP/1.1 200 OK\r\nContent-Type: multipart/x-mixed-replace; boundary=\(mjpegBoundary)\r\n\r\n" 101 + destination.write(Data(header.utf8)) 106 102 } 107 103 108 - let dimensions = Self.computeDimensions(for: initialImage, scale: scale) 109 - let recorder = try StreamRecorder( 110 - outputURL: outputURL, 111 - width: dimensions.width, 112 - height: dimensions.height, 113 - fps: fps, 114 - quality: quality 115 - ) 116 - defer { recorder.invalidate() } 117 - 118 - let frameInterval = 1.0 / Double(fps) 119 - var frameCount: Int64 = 1 120 - var lastLogFrame: Int64 = 0 104 + var frameCount: UInt64 = 0 121 105 let startTime = Date() 122 - var lastPresentationTime = CMTime.zero 123 - 124 - try recorder.append(image: initialImage, presentationTime: .zero) 125 - let writerStartTime = Date() 126 106 127 107 while true { 128 108 if Task.isCancelled { 129 109 break 130 110 } 131 - if await cancellationFlag.value { 111 + if await cancellationFlag.isCancelled() { 132 112 break 133 113 } 134 114 135 - let frameStart = Date() 115 + let frameStartTime = Date() 136 116 137 117 do { 138 - let frameData = try await captureScreenshotData(from: simulator) 139 - guard let cgImage = Self.makeCGImage(from: frameData) else { 140 - FileHandle.standardError.write(Data("Unable to decode screenshot frame\n".utf8)) 141 - continue 142 - } 118 + let screenshotData = try await VideoFrameUtilities.captureScreenshotData(from: simulator) 119 + let processedData = try await VideoFrameUtilities.processJPEGData(screenshotData, scale: scale, quality: quality) 143 120 144 - let now = Date() 145 - var presentationTime = CMTime(seconds: now.timeIntervalSince(writerStartTime), preferredTimescale: 600) 146 - if presentationTime <= lastPresentationTime { 147 - presentationTime = CMTimeAdd(lastPresentationTime, CMTime(value: 1, timescale: 600)) 121 + switch format { 122 + case .mjpeg: 123 + let frameHeader = "\(mjpegBoundary)\r\nContent-Type: image/jpeg\r\nContent-Length: \(processedData.count)\r\n\r\n" 124 + destination.write(Data(frameHeader.utf8)) 125 + destination.write(processedData) 126 + destination.write(Data("\r\n".utf8)) 127 + case .raw: 128 + var length = UInt32(processedData.count).bigEndian 129 + destination.write(Data(bytes: &length, count: 4)) 130 + destination.write(processedData) 131 + case .ffmpeg: 132 + destination.write(processedData) 133 + case .bgra: 134 + break 148 135 } 149 136 150 - try recorder.append(image: cgImage, presentationTime: presentationTime) 151 - lastPresentationTime = presentationTime 152 137 frameCount += 1 153 138 154 - if frameCount - lastLogFrame >= Int64(fps) { 155 - lastLogFrame = frameCount 139 + if frameCount % UInt64(max(1, fps)) == 0 { 156 140 let elapsed = Date().timeIntervalSince(startTime) 157 - let actualFPS = Double(frameCount) / max(elapsed, 0.0001) 158 - FileHandle.standardError.write(Data(String(format: "Captured %lld frames (%.1f FPS actual)\n", frameCount, actualFPS).utf8)) 141 + if elapsed > 0 { 142 + let actualFPS = Double(frameCount) / elapsed 143 + FileHandle.standardError.write(Data(String(format: "Captured %llu frames (%.1f FPS actual)\n", frameCount, actualFPS).utf8)) 144 + } 159 145 } 160 146 } catch { 161 147 FileHandle.standardError.write(Data("Error capturing frame: \(error.localizedDescription)\n".utf8)) 162 148 } 163 149 164 - let elapsed = Date().timeIntervalSince(frameStart) 150 + let elapsed = Date().timeIntervalSince(frameStartTime) 165 151 let sleepTime = frameInterval - elapsed 166 152 if sleepTime > 0 { 167 - try await Task.sleep(nanoseconds: UInt64(sleepTime * 1_000_000_000)) 168 - } 169 - } 170 - 171 - try await recorder.finish() 172 - } 173 - 174 - private func captureScreenshotData(from simulator: FBSimulator) async throws -> Data { 175 - let screenshotFuture = simulator.takeScreenshot(.PNG) 176 - let nsData = try await FutureBridge.value(screenshotFuture) 177 - guard let data = nsData as Data? else { 178 - throw CLIError(errorDescription: "Screenshot returned empty data") 179 - } 180 - return data 181 - } 182 - 183 - private func prepareOutputURL() throws -> URL { 184 - let fileManager = FileManager.default 185 - let formatter = ISO8601DateFormatter() 186 - formatter.formatOptions = [.withInternetDateTime] 187 - 188 - let providedPath = output?.trimmingCharacters(in: .whitespacesAndNewlines) 189 - let resolvedPath: String 190 - if let providedPath, !providedPath.isEmpty { 191 - resolvedPath = (providedPath as NSString).expandingTildeInPath 192 - } else { 193 - resolvedPath = "axe-video-\(formatter.string(from: Date())).mp4" 194 - } 195 - 196 - let baseURL: URL 197 - if resolvedPath.hasPrefix("/") { 198 - baseURL = URL(fileURLWithPath: resolvedPath) 199 - } else { 200 - baseURL = URL(fileURLWithPath: fileManager.currentDirectoryPath).appendingPathComponent(resolvedPath) 201 - } 202 - 203 - var isDirectory: ObjCBool = false 204 - if fileManager.fileExists(atPath: baseURL.path, isDirectory: &isDirectory), isDirectory.boolValue { 205 - // Treat the provided path as a directory and generate a file name within it 206 - let filename = "axe-video-\(formatter.string(from: Date())).mp4" 207 - let directoryURL = baseURL 208 - if !fileManager.fileExists(atPath: directoryURL.path) { 209 - try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) 153 + try? await Task.sleep(nanoseconds: UInt64(sleepTime * 1_000_000_000)) 210 154 } 211 - return directoryURL.appendingPathComponent(filename) 212 155 } 213 156 214 - let directoryURL = baseURL.deletingLastPathComponent() 215 - if !fileManager.fileExists(atPath: directoryURL.path) { 216 - try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) 157 + if format == .mjpeg { 158 + destination.write(Data("\(mjpegBoundary)--\r\n".utf8)) 217 159 } 218 160 219 - if fileManager.fileExists(atPath: baseURL.path) { 220 - var existingIsDirectory: ObjCBool = false 221 - fileManager.fileExists(atPath: baseURL.path, isDirectory: &existingIsDirectory) 222 - if existingIsDirectory.boolValue { 223 - throw CLIError(errorDescription: "Output path \(baseURL.path) is a directory. Provide a file name or point to a different location.") 224 - } 225 - try fileManager.removeItem(at: baseURL) 161 + let elapsed = Date().timeIntervalSince(startTime) 162 + if frameCount > 0 && elapsed > 0 { 163 + let avgFPS = Double(frameCount) / elapsed 164 + FileHandle.standardError.write(Data(String(format: "Streamed %llu frames in %.1f seconds (%.1f FPS average)\n", frameCount, elapsed, avgFPS).utf8)) 226 165 } 227 - 228 - return baseURL 229 166 } 230 167 231 - private static func makeCGImage(from data: Data) -> CGImage? { 232 - guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { 233 - return nil 234 - } 235 - return CGImageSourceCreateImageAtIndex(source, 0, nil) 236 - } 168 + // MARK: - Legacy BGRA streaming 237 169 238 - private static func computeDimensions(for image: CGImage, scale: Double) -> (width: Int, height: Int) { 239 - let scaledWidth = max(2, Int(Double(image.width) * scale)) 240 - let scaledHeight = max(2, Int(Double(image.height) * scale)) 241 - let evenWidth = scaledWidth - (scaledWidth % 2) 242 - let evenHeight = scaledHeight - (scaledHeight % 2) 243 - return (max(evenWidth, 2), max(evenHeight, 2)) 244 - } 245 - } 246 - 247 - // MARK: - Helpers 248 - 249 - private actor CancellationFlag { 250 - private(set) var value = false 251 - func cancel() { 252 - value = true 253 - } 254 - } 255 - 256 - private final class SignalObserver { 257 - private var sources: [DispatchSourceSignal] = [] 258 - private let signals: [Int32] 259 - 260 - init(signals: [Int32], handler: @escaping @Sendable () -> Void) { 261 - self.signals = signals 262 - for signalValue in signals { 263 - signal(signalValue, SIG_IGN) 264 - let source = DispatchSource.makeSignalSource(signal: signalValue, queue: .main) 265 - source.setEventHandler(handler: handler) 266 - source.resume() 267 - sources.append(source) 268 - } 269 - } 270 - 271 - func invalidate() { 272 - sources.forEach { $0.cancel() } 273 - sources.removeAll() 274 - for signalValue in signals { 275 - signal(signalValue, SIG_DFL) 276 - } 277 - } 278 - 279 - deinit { 280 - invalidate() 281 - } 282 - } 283 - 284 - private final class StreamRecorder: @unchecked Sendable { 285 - private let writer: AVAssetWriter 286 - private let input: AVAssetWriterInput 287 - private let adaptor: AVAssetWriterInputPixelBufferAdaptor 288 - private let width: Int 289 - private let height: Int 290 - 291 - init(outputURL: URL, width: Int, height: Int, fps: Int, quality: Int) throws { 292 - self.width = width 293 - self.height = height 294 - 295 - let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) 170 + private func streamBGRA( 171 + to simulator: FBSimulator, 172 + cancellationFlag: CancellationFlag 173 + ) async throws { 174 + FileHandle.standardError.write(Data("Starting BGRA video stream from simulator \(simulator.udid)...\n".utf8)) 175 + FileHandle.standardError.write(Data("Format: bgra, Quality: \(quality), Scale: \(scale)\n".utf8)) 176 + FileHandle.standardError.write(Data("Note: This is raw pixel data. Use ffmpeg to convert:\n".utf8)) 177 + FileHandle.standardError.write(Data(" axe stream-video --format bgra --udid <UDID> | ffmpeg -f rawvideo -pixel_format bgra -video_size WIDTHxHEIGHT -i - output.mp4\n".utf8)) 178 + FileHandle.standardError.write(Data("Press Ctrl+C to stop streaming\n".utf8)) 296 179 297 - let compressionProperties: [String: Any] = [ 298 - AVVideoAverageBitRateKey: Self.estimateBitrate(width: width, height: height, fps: fps, quality: quality), 299 - AVVideoExpectedSourceFrameRateKey: fps, 300 - AVVideoMaxKeyFrameIntervalKey: fps * 2, 301 - AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel 302 - ] 180 + do { 181 + let config = FBVideoStreamConfiguration( 182 + encoding: .BGRA, 183 + framesPerSecond: nil, 184 + compressionQuality: NSNumber(value: Double(quality) / 100.0), 185 + scaleFactor: NSNumber(value: scale), 186 + avgBitrate: nil, 187 + keyFrameRate: nil 188 + ) 303 189 304 - let outputSettings: [String: Any] = [ 305 - AVVideoCodecKey: AVVideoCodecType.h264, 306 - AVVideoWidthKey: width, 307 - AVVideoHeightKey: height, 308 - AVVideoCompressionPropertiesKey: compressionProperties 309 - ] 190 + let stdoutConsumer = FBFileWriter.syncWriter(withFileDescriptor: STDOUT_FILENO, closeOnEndOfFile: false) 191 + let videoStreamFuture = simulator.createStream(with: config) 192 + let videoStream = try await FutureBridge.value(videoStreamFuture) 193 + let startFuture = videoStream.startStreaming(stdoutConsumer) 310 194 311 - let input = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings) 312 - input.expectsMediaDataInRealTime = true 195 + startFuture.onQueue(BridgeQueues.videoStreamQueue, notifyOfCompletion: { future in 196 + if let error = future.error { 197 + FileHandle.standardError.write(Data("Stream initialization error: \(error)\n".utf8)) 198 + } 199 + }) 313 200 314 - let pixelBufferAttributes: [String: Any] = [ 315 - kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA), 316 - kCVPixelBufferWidthKey as String: width, 317 - kCVPixelBufferHeightKey as String: height, 318 - kCVPixelBufferIOSurfacePropertiesKey as String: [:] 319 - ] 320 - 321 - let adaptor = AVAssetWriterInputPixelBufferAdaptor( 322 - assetWriterInput: input, 323 - sourcePixelBufferAttributes: pixelBufferAttributes 324 - ) 201 + try await Task.sleep(nanoseconds: 1_000_000_000) 202 + FileHandle.standardError.write(Data("BGRA stream is now running...\n".utf8)) 325 203 326 - guard writer.canAdd(input) else { 327 - throw CLIError(errorDescription: "Unable to configure video writer input") 328 - } 329 - writer.add(input) 330 - 331 - if !writer.startWriting() { 332 - throw CLIError(errorDescription: "Failed to start asset writer: \(writer.error?.localizedDescription ?? "Unknown error")") 333 - } 334 - writer.startSession(atSourceTime: .zero) 335 - 336 - self.writer = writer 337 - self.input = input 338 - self.adaptor = adaptor 339 - } 340 - 341 - func append(image: CGImage, presentationTime: CMTime) throws { 342 - if !input.isReadyForMoreMediaData { 343 - while !input.isReadyForMoreMediaData { 344 - Thread.sleep(forTimeInterval: 0.005) 204 + while true { 205 + if Task.isCancelled { 206 + break 207 + } 208 + if await cancellationFlag.isCancelled() { 209 + break 210 + } 211 + try? await Task.sleep(nanoseconds: 100_000_000) 345 212 } 346 - } 347 213 348 - guard let pixelBuffer = Self.makePixelBuffer(width: width, height: height, adaptor: adaptor) else { 349 - throw CLIError(errorDescription: "Failed to allocate pixel buffer") 350 - } 351 - 352 - CVPixelBufferLockBaseAddress(pixelBuffer, []) 353 - defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) } 354 - 355 - guard let context = CGContext( 356 - data: CVPixelBufferGetBaseAddress(pixelBuffer), 357 - width: width, 358 - height: height, 359 - bitsPerComponent: 8, 360 - bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer), 361 - space: CGColorSpaceCreateDeviceRGB(), 362 - bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue 363 - ) else { 364 - throw CLIError(errorDescription: "Failed to create drawing context") 365 - } 366 - 367 - context.interpolationQuality = .high 368 - context.draw(image, in: CGRect(x: 0, y: CGFloat(height), width: CGFloat(width), height: -CGFloat(height))) 369 - 370 - guard adaptor.append(pixelBuffer, withPresentationTime: presentationTime) else { 371 - throw CLIError(errorDescription: "Failed to append frame: \(writer.error?.localizedDescription ?? "Unknown error")") 372 - } 373 - } 374 - 375 - func finish() async throws { 376 - input.markAsFinished() 377 - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in 378 - writer.finishWriting { 379 - if let error = self.writer.error { 380 - continuation.resume(throwing: error) 381 - } else { 382 - continuation.resume(returning: ()) 214 + FileHandle.standardError.write(Data("\nStopping BGRA stream...\n".utf8)) 215 + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in 216 + BridgeQueues.videoStreamQueue.async { 217 + let stopFuture = videoStream.stopStreaming() 218 + stopFuture.onQueue(BridgeQueues.videoStreamQueue, notifyOfCompletion: { future in 219 + FileHandle.standardError.write(Data("BGRA stream stopped\n".utf8)) 220 + if let error = future.error { 221 + continuation.resume(throwing: error) 222 + } else { 223 + continuation.resume(returning: ()) 224 + } 225 + }) 383 226 } 384 227 } 385 - } 386 - } 387 - 388 - func invalidate() { 389 - if writer.status == .writing { 390 - input.markAsFinished() 391 - writer.cancelWriting() 392 - } 393 - } 394 - 395 - private static func makePixelBuffer(width: Int, height: Int, adaptor: AVAssetWriterInputPixelBufferAdaptor) -> CVPixelBuffer? { 396 - var pixelBuffer: CVPixelBuffer? 397 - if let pool = adaptor.pixelBufferPool { 398 - let status = CVPixelBufferPoolCreatePixelBuffer(nil, pool, &pixelBuffer) 399 - guard status == kCVReturnSuccess else { 400 - return nil 401 - } 402 - } else { 403 - let attrs: [String: Any] = [ 404 - kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA), 405 - kCVPixelBufferWidthKey as String: width, 406 - kCVPixelBufferHeightKey as String: height, 407 - kCVPixelBufferIOSurfacePropertiesKey as String: [:] 408 - ] 409 - let status = CVPixelBufferCreate(nil, width, height, kCVPixelFormatType_32BGRA, attrs as CFDictionary, &pixelBuffer) 410 - guard status == kCVReturnSuccess else { 411 - return nil 412 - } 228 + } catch { 229 + throw CLIError(errorDescription: "Failed to stream BGRA video: \(error.localizedDescription)") 413 230 } 414 - return pixelBuffer 415 - } 416 - 417 - private static func estimateBitrate(width: Int, height: Int, fps: Int, quality: Int) -> Int { 418 - let qualityFactor = max(0.1, min(Double(quality) / 100.0, 1.0)) 419 - let bitsPerPixel = 0.1 + (0.4 * qualityFactor) 420 - let bitrate = Double(width * height) * bitsPerPixel * Double(fps) 421 - return min(max(Int(bitrate), 1_000_000), 50_000_000) 422 231 } 423 232 }
+273
Sources/AXe/Commands/VideoCommandSupport.swift
··· 1 + import Foundation 2 + import FBSimulatorControl 3 + @preconcurrency import FBControlCore 4 + import AVFoundation 5 + import ImageIO 6 + #if os(macOS) 7 + import AppKit 8 + #endif 9 + 10 + actor CancellationFlag { 11 + private(set) var value = false 12 + 13 + func cancel() { 14 + value = true 15 + } 16 + 17 + func isCancelled() -> Bool { 18 + value 19 + } 20 + } 21 + 22 + final class SignalObserver { 23 + private var sources: [DispatchSourceSignal] = [] 24 + private let signals: [Int32] 25 + 26 + init(signals: [Int32], handler: @escaping @Sendable () -> Void) { 27 + self.signals = signals 28 + for signalValue in signals { 29 + signal(signalValue, SIG_IGN) 30 + let source = DispatchSource.makeSignalSource(signal: signalValue, queue: .main) 31 + source.setEventHandler(handler: handler) 32 + source.resume() 33 + sources.append(source) 34 + } 35 + } 36 + 37 + func invalidate() { 38 + sources.forEach { $0.cancel() } 39 + sources.removeAll() 40 + for signalValue in signals { 41 + signal(signalValue, SIG_DFL) 42 + } 43 + } 44 + 45 + deinit { 46 + invalidate() 47 + } 48 + } 49 + 50 + enum VideoProcessingError: Error { 51 + case emptyScreenshot 52 + case failedToDecodeImage 53 + case failedToAllocatePixelBuffer 54 + } 55 + 56 + struct VideoFrameUtilities { 57 + static func captureScreenshotData(from simulator: FBSimulator) async throws -> Data { 58 + let screenshotFuture = simulator.takeScreenshot(.PNG) 59 + let nsData = try await FutureBridge.value(screenshotFuture) 60 + guard let data = nsData as Data? else { 61 + throw VideoProcessingError.emptyScreenshot 62 + } 63 + return data 64 + } 65 + 66 + static func makeCGImage(from data: Data) -> CGImage? { 67 + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { 68 + return nil 69 + } 70 + return CGImageSourceCreateImageAtIndex(source, 0, nil) 71 + } 72 + 73 + static func processJPEGData(_ data: Data, scale: Double, quality: Int) async throws -> Data { 74 + if scale < 1.0 { 75 + return try await scaleJPEGData(data, scale: scale, quality: quality) 76 + } else if quality != 80 { 77 + return try await reencodeJPEGData(data, quality: quality) 78 + } 79 + return data 80 + } 81 + 82 + static func computeDimensions(for image: CGImage, scale: Double) -> (width: Int, height: Int) { 83 + let scaledWidth = max(2, Int(Double(image.width) * scale)) 84 + let scaledHeight = max(2, Int(Double(image.height) * scale)) 85 + let evenWidth = scaledWidth - (scaledWidth % 2) 86 + let evenHeight = scaledHeight - (scaledHeight % 2) 87 + return (max(evenWidth, 2), max(evenHeight, 2)) 88 + } 89 + 90 + private static func scaleJPEGData(_ data: Data, scale: Double, quality: Int) async throws -> Data { 91 + #if os(macOS) 92 + guard let image = NSImage(data: data) else { 93 + throw VideoProcessingError.failedToDecodeImage 94 + } 95 + 96 + let newSize = NSSize( 97 + width: image.size.width * scale, 98 + height: image.size.height * scale 99 + ) 100 + 101 + let newImage = NSImage(size: newSize) 102 + newImage.lockFocus() 103 + image.draw(in: NSRect(origin: .zero, size: newSize)) 104 + newImage.unlockFocus() 105 + 106 + guard let tiffData = newImage.tiffRepresentation, 107 + let bitmap = NSBitmapImageRep(data: tiffData), 108 + let jpegData = bitmap.representation(using: .jpeg, properties: [NSBitmapImageRep.PropertyKey.compressionFactor: Double(quality) / 100.0]) else { 109 + throw VideoProcessingError.failedToDecodeImage 110 + } 111 + 112 + return jpegData 113 + #else 114 + return data 115 + #endif 116 + } 117 + 118 + private static func reencodeJPEGData(_ data: Data, quality: Int) async throws -> Data { 119 + #if os(macOS) 120 + guard let image = NSImage(data: data), 121 + let tiffData = image.tiffRepresentation, 122 + let bitmap = NSBitmapImageRep(data: tiffData), 123 + let jpegData = bitmap.representation(using: .jpeg, properties: [NSBitmapImageRep.PropertyKey.compressionFactor: Double(quality) / 100.0]) else { 124 + throw VideoProcessingError.failedToDecodeImage 125 + } 126 + 127 + return jpegData 128 + #else 129 + return data 130 + #endif 131 + } 132 + } 133 + 134 + final class H264StreamRecorder: @unchecked Sendable { 135 + private let writer: AVAssetWriter 136 + private let input: AVAssetWriterInput 137 + private let adaptor: AVAssetWriterInputPixelBufferAdaptor 138 + private let width: Int 139 + private let height: Int 140 + 141 + init(outputURL: URL, width: Int, height: Int, fps: Int, quality: Int) throws { 142 + self.width = width 143 + self.height = height 144 + 145 + let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) 146 + 147 + let compressionProperties: [String: Any] = [ 148 + AVVideoAverageBitRateKey: Self.estimateBitrate(width: width, height: height, fps: fps, quality: quality), 149 + AVVideoExpectedSourceFrameRateKey: fps, 150 + AVVideoMaxKeyFrameIntervalKey: fps * 2, 151 + AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel 152 + ] 153 + 154 + let outputSettings: [String: Any] = [ 155 + AVVideoCodecKey: AVVideoCodecType.h264, 156 + AVVideoWidthKey: width, 157 + AVVideoHeightKey: height, 158 + AVVideoCompressionPropertiesKey: compressionProperties 159 + ] 160 + 161 + let input = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings) 162 + input.expectsMediaDataInRealTime = true 163 + 164 + let pixelBufferAttributes: [String: Any] = [ 165 + kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA), 166 + kCVPixelBufferWidthKey as String: width, 167 + kCVPixelBufferHeightKey as String: height, 168 + kCVPixelBufferIOSurfacePropertiesKey as String: [:] 169 + ] 170 + 171 + let adaptor = AVAssetWriterInputPixelBufferAdaptor( 172 + assetWriterInput: input, 173 + sourcePixelBufferAttributes: pixelBufferAttributes 174 + ) 175 + 176 + guard writer.canAdd(input) else { 177 + throw CLIError(errorDescription: "Unable to configure video writer input") 178 + } 179 + writer.add(input) 180 + 181 + if !writer.startWriting() { 182 + throw CLIError(errorDescription: "Failed to start asset writer: \(writer.error?.localizedDescription ?? "Unknown error")") 183 + } 184 + writer.startSession(atSourceTime: .zero) 185 + 186 + self.writer = writer 187 + self.input = input 188 + self.adaptor = adaptor 189 + } 190 + 191 + func append(image: CGImage, presentationTime: CMTime) throws { 192 + if !input.isReadyForMoreMediaData { 193 + while !input.isReadyForMoreMediaData { 194 + Thread.sleep(forTimeInterval: 0.005) 195 + } 196 + } 197 + 198 + guard let pixelBuffer = Self.makePixelBuffer(width: width, height: height, adaptor: adaptor) else { 199 + throw VideoProcessingError.failedToAllocatePixelBuffer 200 + } 201 + 202 + CVPixelBufferLockBaseAddress(pixelBuffer, []) 203 + defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) } 204 + 205 + guard let context = CGContext( 206 + data: CVPixelBufferGetBaseAddress(pixelBuffer), 207 + width: width, 208 + height: height, 209 + bitsPerComponent: 8, 210 + bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer), 211 + space: CGColorSpaceCreateDeviceRGB(), 212 + bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue 213 + ) else { 214 + throw CLIError(errorDescription: "Failed to create drawing context") 215 + } 216 + 217 + context.interpolationQuality = .high 218 + context.draw(image, in: CGRect(x: 0, y: CGFloat(height), width: CGFloat(width), height: -CGFloat(height))) 219 + 220 + guard adaptor.append(pixelBuffer, withPresentationTime: presentationTime) else { 221 + throw CLIError(errorDescription: "Failed to append frame: \(writer.error?.localizedDescription ?? "Unknown error")") 222 + } 223 + } 224 + 225 + func finish() async throws { 226 + input.markAsFinished() 227 + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in 228 + writer.finishWriting { 229 + if let error = self.writer.error { 230 + continuation.resume(throwing: error) 231 + } else { 232 + continuation.resume(returning: ()) 233 + } 234 + } 235 + } 236 + } 237 + 238 + func invalidate() { 239 + if writer.status == .writing { 240 + input.markAsFinished() 241 + writer.cancelWriting() 242 + } 243 + } 244 + 245 + private static func makePixelBuffer(width: Int, height: Int, adaptor: AVAssetWriterInputPixelBufferAdaptor) -> CVPixelBuffer? { 246 + var pixelBuffer: CVPixelBuffer? 247 + if let pool = adaptor.pixelBufferPool { 248 + let status = CVPixelBufferPoolCreatePixelBuffer(nil, pool, &pixelBuffer) 249 + guard status == kCVReturnSuccess else { 250 + return nil 251 + } 252 + } else { 253 + let attrs: [String: Any] = [ 254 + kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA), 255 + kCVPixelBufferWidthKey as String: width, 256 + kCVPixelBufferHeightKey as String: height, 257 + kCVPixelBufferIOSurfacePropertiesKey as String: [:] 258 + ] 259 + let status = CVPixelBufferCreate(nil, width, height, kCVPixelFormatType_32BGRA, attrs as CFDictionary, &pixelBuffer) 260 + guard status == kCVReturnSuccess else { 261 + return nil 262 + } 263 + } 264 + return pixelBuffer 265 + } 266 + 267 + private static func estimateBitrate(width: Int, height: Int, fps: Int, quality: Int) -> Int { 268 + let qualityFactor = max(0.1, min(Double(quality) / 100.0, 1.0)) 269 + let bitsPerPixel = 0.1 + (0.4 * qualityFactor) 270 + let bitrate = Double(width * height) * bitsPerPixel * Double(fps) 271 + return min(max(Int(bitrate), 1_000_000), 50_000_000) 272 + } 273 + }
+7 -7
Sources/AXe/Utilities/StandardError.swift
··· 1 1 import Foundation 2 2 3 - // Standard error output stream 4 - var standardError = FileHandle.standardError 5 - 6 - extension FileHandle: TextOutputStream { 7 - public func write(_ string: String) { 3 + // Stream that writes TextOutputStream data to stderr without extending FileHandle 4 + struct StandardErrorStream: TextOutputStream { 5 + func write(_ string: String) { 8 6 guard let data = string.data(using: .utf8) else { return } 9 - self.write(data) 7 + FileHandle.standardError.write(data) 10 8 } 11 - } 9 + } 10 + 11 + var standardError = StandardErrorStream()
+2 -1
Sources/AXe/main.swift
··· 24 24 KeySequence.self, 25 25 Touch.self, 26 26 Gesture.self, 27 - StreamVideo.self 27 + StreamVideo.self, 28 + RecordVideo.self 28 29 ] 29 30 ) 30 31 }
+146
Tests/RecordVideoTests.swift
··· 1 + import Testing 2 + import Foundation 3 + 4 + @Suite("Record Video Command Tests") 5 + struct RecordVideoTests { 6 + @Test("Record video writes an MP4 file with default options") 7 + func recordVideoDefault() async throws { 8 + let result = try await invokeRecordVideo(duration: 3.0) 9 + defer { try? FileManager.default.removeItem(at: result.outputURL) } 10 + 11 + #expect(result.exitCode == 0) 12 + #expect(result.fileSize > 150_000, "Recorded file should be non-trivial in size (got: \(result.fileSize))") 13 + #expect(result.stderr.contains("Recording simulator")) 14 + #expect(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == result.outputURL.path) 15 + } 16 + 17 + @Test("Record video honours FPS, scale, and quality settings") 18 + func recordVideoCustomOptions() async throws { 19 + let result = try await invokeRecordVideo(fps: 5, scale: 0.5, quality: 60, duration: 2.0) 20 + defer { try? FileManager.default.removeItem(at: result.outputURL) } 21 + 22 + #expect(result.exitCode == 0) 23 + #expect(result.fileSize > 50_000) 24 + #expect(result.stderr.contains("Press Ctrl+C")) 25 + } 26 + 27 + @Test("Record video uses provided directory without deleting its contents") 28 + func recordVideoOutputDirectory() async throws { 29 + let tempDir = FileManager.default.temporaryDirectory 30 + .appendingPathComponent("axe-record-output-\(UUID().uuidString)") 31 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 32 + let sentinel = tempDir.appendingPathComponent("sentinel.txt") 33 + try "sentinel".write(to: sentinel, atomically: true, encoding: .utf8) 34 + 35 + let result = try await invokeRecordVideo(duration: 1.0, outputPath: tempDir.path) 36 + 37 + #expect(FileManager.default.fileExists(atPath: sentinel.path)) 38 + #expect(result.exitCode == 0) 39 + #expect(result.fileSize > 0) 40 + #expect(result.outputURL.path.hasPrefix(tempDir.path)) 41 + #expect(FileManager.default.fileExists(atPath: result.outputURL.path)) 42 + 43 + try? FileManager.default.removeItem(at: tempDir) 44 + } 45 + 46 + @Test("Record video validates FPS input") 47 + func recordVideoInvalidFPS() async throws { 48 + guard let udid = defaultSimulatorUDID else { 49 + throw TestError.commandError("No simulator UDID specified") 50 + } 51 + let axePath = try TestHelpers.getAxePath() 52 + 53 + let process = Process() 54 + process.executableURL = URL(fileURLWithPath: axePath) 55 + process.arguments = [ 56 + "record-video", 57 + "--udid", udid, 58 + "--fps", "40" 59 + ] 60 + let errorPipe = Pipe() 61 + process.standardError = errorPipe 62 + process.standardOutput = Pipe() 63 + 64 + try process.run() 65 + process.waitUntilExit() 66 + 67 + let errorOutput = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" 68 + 69 + #expect(process.terminationStatus != 0) 70 + #expect(errorOutput.contains("FPS must be between 1 and 30")) 71 + } 72 + 73 + // MARK: - Helpers 74 + 75 + private struct RecordingResult { 76 + let outputURL: URL 77 + let stdout: String 78 + let stderr: String 79 + let fileSize: Int 80 + let exitCode: Int32 81 + } 82 + 83 + private func invokeRecordVideo( 84 + fps: Int = 10, 85 + quality: Int = 80, 86 + scale: Double = 1.0, 87 + duration: TimeInterval = 2.0, 88 + outputPath: String? = nil 89 + ) async throws -> RecordingResult { 90 + guard let udid = defaultSimulatorUDID else { 91 + throw TestError.commandError("No simulator UDID specified in SIMULATOR_UDID environment variable") 92 + } 93 + let axePath = try TestHelpers.getAxePath() 94 + 95 + let defaultOutputURL = FileManager.default.temporaryDirectory 96 + .appendingPathComponent("axe-record-test-\(UUID().uuidString).mp4") 97 + let configuredOutputPath = outputPath ?? defaultOutputURL.path 98 + 99 + let process = Process() 100 + process.executableURL = URL(fileURLWithPath: axePath) 101 + process.arguments = [ 102 + "record-video", 103 + "--udid", udid, 104 + "--fps", "\(fps)", 105 + "--quality", "\(quality)", 106 + "--scale", "\(scale)", 107 + "--output", configuredOutputPath 108 + ] 109 + 110 + let stdoutPipe = Pipe() 111 + let stderrPipe = Pipe() 112 + process.standardOutput = stdoutPipe 113 + process.standardError = stderrPipe 114 + 115 + try process.run() 116 + 117 + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) 118 + 119 + process.interrupt() 120 + process.waitUntilExit() 121 + 122 + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() 123 + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() 124 + 125 + let resolvedOutputPath = String(data: stdoutData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" 126 + let resolvedURL = resolvedOutputPath.isEmpty ? defaultOutputURL : URL(fileURLWithPath: resolvedOutputPath) 127 + 128 + var fileSize = 0 129 + if let attributes = try? FileManager.default.attributesOfItem(atPath: resolvedURL.path), 130 + let sizeNumber = attributes[.size] as? NSNumber { 131 + fileSize = sizeNumber.intValue 132 + } 133 + 134 + if outputPath == nil { 135 + try? FileManager.default.removeItem(at: resolvedURL) 136 + } 137 + 138 + return RecordingResult( 139 + outputURL: resolvedURL, 140 + stdout: String(data: stdoutData, encoding: .utf8) ?? "", 141 + stderr: String(data: stderrData, encoding: .utf8) ?? "", 142 + fileSize: fileSize, 143 + exitCode: process.terminationStatus 144 + ) 145 + } 146 + }
+1 -1
Tests/StreamVideoDebugTest.swift
··· 17 17 let process = Process() 18 18 process.executableURL = URL(fileURLWithPath: axePath) 19 19 process.arguments = [ 20 - "stream-video", 20 + "record-video", 21 21 "--udid", udid, 22 22 "--fps", "5", 23 23 "--output", tempURL.path
+111 -88
Tests/StreamVideoTests.swift
··· 3 3 4 4 @Suite("Stream Video Command Tests") 5 5 struct StreamVideoTests { 6 - @Test("Stream video records an MP4 file with default options") 7 - func streamVideoDefaultRecording() async throws { 8 - let result = try await recordVideo(duration: 3.0) 9 - defer { try? FileManager.default.removeItem(at: result.outputURL) } 6 + @Test("Stream video outputs MJPEG data with HTTP headers") 7 + func streamVideoMJPEG() async throws { 8 + let result = try await streamVideoForDuration(format: "mjpeg", duration: 3.0) 10 9 11 - #expect(result.exitCode == 0, "Command should exit successfully") 12 - #expect(result.fileSize > 150_000, "Recorded file should be non-trivial in size (got: \(result.fileSize))") 13 - #expect(result.stderr.contains("Recording simulator"), "Should log recording start") 14 - #expect(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == result.outputURL.path, "stdout should contain the output path") 10 + #expect(result.exitCode == 15 || result.exitCode == 0) 11 + #expect(!result.output.isEmpty, "Should have stderr messages") 12 + #expect(result.output.contains("Starting screenshot-based video stream")) 13 + #expect(result.output.contains("Format: mjpeg")) 15 14 } 16 15 17 - @Test("Stream video honours FPS and scale settings") 18 - func streamVideoCustomOptions() async throws { 19 - let result = try await recordVideo(fps: 5, scale: 0.5, duration: 2.0) 20 - defer { try? FileManager.default.removeItem(at: result.outputURL) } 16 + @Test("Stream video outputs raw JPEG data for ffmpeg format") 17 + func streamVideoFFmpeg() async throws { 18 + let result = try await streamVideoForDuration(format: "ffmpeg", duration: 2.0) 21 19 22 - #expect(result.exitCode == 0) 23 - #expect(result.fileSize > 50_000, "Scaled recording should still produce data") 24 - #expect(result.stderr.contains("Press Ctrl+C"), "Should log usage guidance") 20 + #expect(result.exitCode == 15 || result.exitCode == 0) 21 + #expect(result.output.contains("Format: ffmpeg")) 25 22 } 26 23 27 - @Test("Stream video uses provided directory without deleting its contents") 28 - func streamVideoOutputDirectory() async throws { 29 - let tempDir = FileManager.default.temporaryDirectory 30 - .appendingPathComponent("axe-output-dir-\(UUID().uuidString)") 31 - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 32 - let sentinel = tempDir.appendingPathComponent("sentinel.txt") 33 - try "sentinel".write(to: sentinel, atomically: true, encoding: .utf8) 24 + @Test("Stream video outputs raw JPEG with length prefix for raw format") 25 + func streamVideoRaw() async throws { 26 + let result = try await streamVideoForDuration(format: "raw", duration: 2.0) 34 27 35 - let result = try await recordVideo(duration: 1.0, outputPath: tempDir.path) 28 + #expect(result.exitCode == 15 || result.exitCode == 0) 29 + #expect(result.output.contains("Format: raw")) 30 + } 36 31 37 - #expect(FileManager.default.fileExists(atPath: sentinel.path), "Sentinel file should remain intact") 38 - #expect(result.exitCode == 0, "Recording should succeed") 39 - #expect(result.fileSize > 0, "Recording should produce a non-empty file") 40 - #expect(result.outputURL.path.hasPrefix(tempDir.path), "Output should be created inside the provided directory") 41 - #expect(FileManager.default.fileExists(atPath: result.outputURL.path), "Recorded file should exist") 32 + @Test("Stream video with custom FPS") 33 + func streamVideoWithFPS() async throws { 34 + let result = try await streamVideoForDuration(format: "mjpeg", fps: 5, duration: 2.0) 42 35 43 - try? FileManager.default.removeItem(at: tempDir) 36 + #expect(result.exitCode == 15 || result.exitCode == 0) 37 + #expect(result.output.contains("FPS: 5")) 44 38 } 45 39 46 - @Test("Stream video validates FPS input") 47 - func streamVideoInvalidFPS() async throws { 40 + @Test("Stream video with quality and scale settings") 41 + func streamVideoWithQualityAndScale() async throws { 42 + let result = try await streamVideoForDuration( 43 + format: "mjpeg", 44 + fps: 5, 45 + quality: 50, 46 + scale: 0.5, 47 + duration: 1.0 48 + ) 49 + 50 + #expect(result.exitCode == 15 || result.exitCode == 0) 51 + #expect(result.output.contains("Quality: 50")) 52 + #expect(result.output.contains("Scale: 0.5")) 53 + } 54 + 55 + @Test("Stream BGRA video outputs raw pixel data") 56 + func streamVideoBGRA() async throws { 57 + let result = try await streamVideoForDuration(format: "bgra", duration: 2.0) 58 + 59 + #expect(result.exitCode == 15 || result.exitCode == 0) 60 + #expect(!result.output.isEmpty) 61 + #expect(result.output.contains("Starting BGRA video stream")) 62 + #expect(result.output.contains("Format: bgra")) 63 + } 64 + 65 + @Test("Stream video can be cancelled gracefully") 66 + func streamVideoCancellation() async throws { 67 + let task = Task { 68 + try await streamVideoForDuration(format: "mjpeg", fps: 30, duration: 60.0) 69 + } 70 + 71 + try await Task.sleep(nanoseconds: 500_000_000) 72 + task.cancel() 73 + _ = await task.result 74 + } 75 + 76 + @Test("Stream video rejects invalid formats") 77 + func streamVideoInvalidFormat() async throws { 48 78 guard let udid = defaultSimulatorUDID else { 49 79 throw TestError.commandError("No simulator UDID specified") 50 80 } 81 + 51 82 let axePath = try TestHelpers.getAxePath() 83 + let fullCommand = "\(axePath) stream-video --format h264 --udid \(udid)" 52 84 53 85 let process = Process() 54 - process.executableURL = URL(fileURLWithPath: axePath) 55 - process.arguments = [ 56 - "stream-video", 57 - "--udid", udid, 58 - "--fps", "40" 59 - ] 86 + process.executableURL = URL(fileURLWithPath: "/bin/bash") 87 + process.arguments = ["-c", fullCommand] 88 + 60 89 let errorPipe = Pipe() 61 90 process.standardError = errorPipe 62 91 process.standardOutput = Pipe() ··· 66 95 67 96 let errorOutput = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" 68 97 69 - #expect(process.terminationStatus != 0, "Invalid FPS should fail") 70 - #expect(errorOutput.contains("FPS must be between 1 and 30"), "Should surface validation message") 98 + #expect(process.terminationStatus != 0) 99 + #expect(errorOutput.contains("format")) 71 100 } 72 101 73 - // MARK: - Helpers 74 - 75 - private struct RecordingResult { 76 - let outputURL: URL 77 - let stdout: String 78 - let stderr: String 79 - let fileSize: Int 80 - let exitCode: Int32 81 - } 82 - 83 - private func recordVideo( 102 + private func streamVideoForDuration( 103 + format: String = "mjpeg", 84 104 fps: Int = 10, 85 105 quality: Int = 80, 86 106 scale: Double = 1.0, 87 - duration: TimeInterval = 2.0, 88 - outputPath: String? = nil 89 - ) async throws -> RecordingResult { 107 + duration: TimeInterval = 2.0 108 + ) async throws -> (output: String, data: Data, dataString: String, dataSize: Int, exitCode: Int32) { 109 + var command = "stream-video" 110 + command += " --format \(format)" 111 + command += " --fps \(fps)" 112 + command += " --quality \(quality) --scale \(scale)" 113 + 90 114 guard let udid = defaultSimulatorUDID else { 91 115 throw TestError.commandError("No simulator UDID specified in SIMULATOR_UDID environment variable") 92 116 } 117 + 93 118 let axePath = try TestHelpers.getAxePath() 119 + let fullCommand = "\(axePath) \(command) --udid \(udid)" 94 120 95 - let defaultOutputURL = FileManager.default.temporaryDirectory 96 - .appendingPathComponent("axe-video-test-\(UUID().uuidString).mp4") 97 - let configuredOutputPath = outputPath ?? defaultOutputURL.path 121 + let process = Process() 122 + process.executableURL = URL(fileURLWithPath: "/bin/bash") 123 + process.arguments = ["-c", fullCommand] 98 124 99 - let process = Process() 100 - process.executableURL = URL(fileURLWithPath: axePath) 101 - process.arguments = [ 102 - "stream-video", 103 - "--udid", udid, 104 - "--fps", "\(fps)", 105 - "--quality", "\(quality)", 106 - "--scale", "\(scale)", 107 - "--output", configuredOutputPath 108 - ] 125 + let outputPipe = Pipe() 126 + let errorPipe = Pipe() 127 + process.standardOutput = outputPipe 128 + process.standardError = errorPipe 109 129 110 - let stdoutPipe = Pipe() 111 - let stderrPipe = Pipe() 112 - process.standardOutput = stdoutPipe 113 - process.standardError = stderrPipe 130 + var outputData = Data() 131 + let outputHandle = outputPipe.fileHandleForReading 132 + outputHandle.readabilityHandler = { handle in 133 + let availableData = handle.availableData 134 + if !availableData.isEmpty { 135 + outputData.append(availableData) 136 + } 137 + } 114 138 115 139 try process.run() 116 140 117 141 try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) 118 142 119 - process.interrupt() // send SIGINT to trigger graceful shutdown 143 + process.terminate() 144 + 145 + outputHandle.readabilityHandler = nil 120 146 process.waitUntilExit() 121 147 122 - let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() 123 - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() 148 + let remainingData = outputHandle.readDataToEndOfFile() 149 + if !remainingData.isEmpty { 150 + outputData.append(remainingData) 151 + } 124 152 125 - let resolvedOutputPath = String(data: stdoutData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" 126 - let resolvedURL = resolvedOutputPath.isEmpty ? defaultOutputURL : URL(fileURLWithPath: resolvedOutputPath) 127 - 128 - var fileSize = 0 129 - if let attributes = try? FileManager.default.attributesOfItem(atPath: resolvedURL.path), 130 - let sizeNumber = attributes[.size] as? NSNumber { 131 - fileSize = sizeNumber.intValue 132 - } 153 + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() 154 + let errorOutput = String(data: errorData, encoding: .utf8) ?? "" 155 + let dataString = String(data: outputData, encoding: .utf8) ?? "" 133 156 134 - if outputPath == nil { 135 - try? FileManager.default.removeItem(at: resolvedURL) 157 + if outputData.count == 0 && !errorOutput.isEmpty { 158 + print("DEBUG: No data received. Error output: \(errorOutput)") 136 159 } 137 160 138 - return RecordingResult( 139 - outputURL: resolvedURL, 140 - stdout: String(data: stdoutData, encoding: .utf8) ?? "", 141 - stderr: String(data: stderrData, encoding: .utf8) ?? "", 142 - fileSize: fileSize, 161 + return ( 162 + output: errorOutput, 163 + data: outputData, 164 + dataString: dataString, 165 + dataSize: outputData.count, 143 166 exitCode: process.terminationStatus 144 167 ) 145 168 }
+5 -4
USAGE_EXAMPLES.md
··· 210 210 211 211 ```bash 212 212 # Record to an MP4 (QuickTime compatible) in the current directory 213 - axe stream-video --udid SIMULATOR_UDID --fps 15 213 + axe record-video --udid SIMULATOR_UDID --fps 15 214 214 215 215 # Choose a custom destination 216 - axe stream-video --udid SIMULATOR_UDID --fps 20 --output recordings/run.mp4 216 + # --output automatically records using h264 217 + axe record-video --udid SIMULATOR_UDID --fps 20 --output recordings/run.mp4 217 218 218 219 # Reduce bandwidth/size by lowering quality and scale 219 - axe stream-video --udid SIMULATOR_UDID --fps 10 --quality 55 --scale 0.6 --output recordings/light.mp4 220 + axe record-video --udid SIMULATOR_UDID --fps 10 --quality 55 --scale 0.6 --output recordings/light.mp4 220 221 221 222 # Simple automation-friendly script 222 223 UDID=$(axe list-simulators | awk '/Booted/{print $NF; exit}') 223 224 OUTPUT="recording_$(date +%Y%m%d_%H%M%S).mp4" 224 225 225 - axe stream-video --udid "$UDID" --fps 25 --output "$OUTPUT" & 226 + axe record-video --udid "$UDID" --fps 25 --output "$OUTPUT" & 226 227 RECORD_PID=$! 227 228 228 229 # ...run automation commands here...