this repo has no description
0
fork

Configure Feed

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

Add support for h264

+550 -526
+10 -17
README.md
··· 209 209 axe key-sequence --keycodes 11,8,15,15,18 --udid SIMULATOR_UDID # Type "hello" 210 210 ``` 211 211 212 - ### **Video Streaming** 212 + ### **Video Recording** 213 213 214 214 ```bash 215 - # Stream video from simulator (screenshot-based) 216 - # Stream MJPEG at 10 FPS 217 - axe stream-video --udid SIMULATOR_UDID --fps 10 --format mjpeg > stream.mjpeg 215 + # Record the simulator to an MP4 file (QuickTime compatible) 216 + axe stream-video --udid SIMULATOR_UDID --fps 15 --output recording.mp4 218 217 219 - # Pipe to ffmpeg for H264 encoding 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 218 + # Let AXe pick a timestamped filename in the current directory 219 + axe stream-video --udid SIMULATOR_UDID --fps 20 222 220 223 - # View in real-time with ffplay 224 - axe stream-video --udid SIMULATOR_UDID --fps 15 --format ffmpeg | \ 225 - ffplay -f image2pipe -framerate 15 -i - 221 + # 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 223 + ``` 226 224 227 - # Stream with reduced quality and scale for bandwidth optimization 228 - axe stream-video --udid SIMULATOR_UDID --fps 10 --quality 60 --scale 0.5 --format mjpeg > stream.mjpeg 229 - 230 - # Legacy BGRA format (raw pixel data) 231 - axe stream-video --udid SIMULATOR_UDID --format bgra | \ 232 - ffmpeg -f rawvideo -pixel_format bgra -video_size 393x852 -i - output.mp4 233 - ``` 225 + > [!TIP] 226 + > Press `Ctrl+C` to stop recording. AXe finalises the MP4 before exiting and prints the file path to stdout. 234 227 235 228 ### **Accessibility & Info** 236 229
+369 -261
Sources/AXe/Commands/StreamVideo.swift
··· 2 2 import Foundation 3 3 import FBSimulatorControl 4 4 @preconcurrency import FBControlCore 5 - #if os(macOS) 6 - import AppKit 7 - #endif 5 + import AVFoundation 6 + import ImageIO 7 + import Dispatch 8 + import Darwin 8 9 9 10 struct StreamVideo: AsyncParsableCommand { 10 11 static let configuration = CommandConfiguration( 11 12 commandName: "stream-video", 12 - abstract: "Stream video from a simulator to stdout using screenshot capture", 13 - discussion: """ 14 - Captures screenshots from a simulator at a specified frame rate and outputs them as a video stream. 15 - This approach is similar to how browser-based simulator services work. 16 - 17 - Supported output formats: 18 - - mjpeg: Motion JPEG stream (recommended for browser compatibility) 19 - - raw: Raw JPEG images with boundary markers 20 - - ffmpeg: Pipe to ffmpeg for encoding (requires ffmpeg installed) 21 - - bgra: Raw BGRA pixel data (legacy format, not recommended) 22 - 23 - Examples: 24 - # Stream MJPEG at 10 FPS 25 - axe stream-video --udid <UDID> --fps 10 --format mjpeg > stream.mjpeg 26 - 27 - # Pipe to ffmpeg for H264 encoding 28 - axe stream-video --udid <UDID> --fps 30 --format ffmpeg | \\ 29 - ffmpeg -f image2pipe -framerate 30 -i - -c:v libx264 -preset ultrafast output.mp4 30 - 31 - # Stream to a WebSocket server 32 - axe stream-video --udid <UDID> --fps 15 --format raw | node mjpeg-server.js 33 - 34 - # Legacy BGRA format (for compatibility) 35 - axe stream-video --udid <UDID> --format bgra | \\ 36 - ffmpeg -f rawvideo -pixel_format bgra -video_size 393x852 -i - output.mp4 37 - """ 13 + abstract: "Record the simulator display to an MP4 file using H.264 encoding" 38 14 ) 39 - 15 + 40 16 @Option(name: .customLong("udid"), help: "The UDID of the simulator.") 41 17 var simulatorUDID: String 42 - 43 - @Option(help: "Output format: mjpeg, raw, ffmpeg, bgra (default: mjpeg)") 44 - var format: String = "mjpeg" 45 - 18 + 46 19 @Option(help: "Frames per second (1-30, default: 10)") 47 20 var fps: Int = 10 48 - 49 - @Option(help: "JPEG quality (1-100, default: 80)") 21 + 22 + @Option(help: "Quality factor (1-100) controlling bitrate (default: 80)") 50 23 var quality: Int = 80 51 - 24 + 52 25 @Option(help: "Scale factor (0.1-1.0, default: 1.0)") 53 26 var scale: Double = 1.0 54 - 27 + 28 + @Option(help: "Output MP4 file path. Defaults to axe-video-<timestamp>.mp4 in the current directory.") 29 + var output: String? 30 + 55 31 func validate() throws { 56 - // Validate format 57 - let validFormats = ["mjpeg", "raw", "ffmpeg", "bgra"] 58 - guard validFormats.contains(format.lowercased()) else { 59 - throw ValidationError("Invalid format. Must be one of: \(validFormats.joined(separator: ", "))") 60 - } 61 - 62 - // Validate FPS 63 32 guard fps >= 1 && fps <= 30 else { 64 33 throw ValidationError("FPS must be between 1 and 30") 65 34 } 66 - 67 - // Validate quality 35 + 68 36 guard quality >= 1 && quality <= 100 else { 69 37 throw ValidationError("Quality must be between 1 and 100") 70 38 } 71 - 72 - // Validate scale 39 + 73 40 guard scale >= 0.1 && scale <= 1.0 else { 74 41 throw ValidationError("Scale must be between 0.1 and 1.0") 75 42 } 76 43 } 77 - 44 + 78 45 func run() async throws { 79 46 let logger = AxeLogger() 80 47 try await setup(logger: logger) 81 48 try await performGlobalSetup(logger: logger) 82 - 83 - // Validate UDID is not empty 84 - guard !simulatorUDID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { 49 + 50 + let trimmedUDID = simulatorUDID.trimmingCharacters(in: .whitespacesAndNewlines) 51 + guard !trimmedUDID.isEmpty else { 85 52 throw CLIError(errorDescription: "Simulator UDID cannot be empty. Use --udid to specify a simulator.") 86 53 } 87 - 88 - // Get simulator set 54 + 89 55 let simulatorSet = try await getSimulatorSet(deviceSetPath: nil, logger: logger, reporter: EmptyEventReporter.shared) 90 - 91 - // Find target simulator 92 - guard let targetSimulator = simulatorSet.allSimulators.first(where: { $0.udid == simulatorUDID }) else { 93 - throw CLIError(errorDescription: "Simulator with UDID \(simulatorUDID) not found.") 56 + guard let targetSimulator = simulatorSet.allSimulators.first(where: { $0.udid == trimmedUDID }) else { 57 + throw CLIError(errorDescription: "Simulator with UDID \(trimmedUDID) not found.") 94 58 } 95 - 96 - // Ensure simulator is booted 59 + 97 60 guard targetSimulator.state == .booted else { 98 - throw CLIError(errorDescription: "Simulator \(simulatorUDID) is not booted. Current state: \(FBiOSTargetStateStringFromState(targetSimulator.state))") 61 + let stateDescription = FBiOSTargetStateStringFromState(targetSimulator.state) 62 + throw CLIError(errorDescription: "Simulator \(trimmedUDID) is not booted. Current state: \(stateDescription)") 99 63 } 100 - 101 - // Handle legacy BGRA format using the old implementation 102 - if format.lowercased() == "bgra" { 103 - try await streamBGRAFormat(targetSimulator: targetSimulator, logger: logger) 104 - return 64 + 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 + let cancellationFlag = CancellationFlag() 70 + let signalObserver = SignalObserver(signals: [SIGINT, SIGTERM]) { 71 + Task { 72 + await cancellationFlag.cancel() 73 + } 105 74 } 106 - 107 - // Log to stderr so it doesn't mix with video data on stdout 108 - FileHandle.standardError.write(Data("Starting screenshot-based video stream from simulator \(targetSimulator.udid)...\n".utf8)) 109 - FileHandle.standardError.write(Data("Format: \(format), FPS: \(fps), Quality: \(quality), Scale: \(scale)\n".utf8)) 110 - FileHandle.standardError.write(Data("Press Ctrl+C to stop streaming\n".utf8)) 111 - 112 - // Calculate frame interval 113 - let frameInterval = 1.0 / Double(fps) 114 - 115 - // MJPEG boundary for multipart stream 116 - let mjpegBoundary = "--mjpegstream" 117 - 118 - // Start capture loop 75 + defer { signalObserver.invalidate() } 76 + 119 77 do { 120 - var frameCount: UInt64 = 0 121 - let startTime = Date() 122 - 123 - // Write MJPEG header if needed 124 - if format == "mjpeg" { 125 - let header = "HTTP/1.1 200 OK\r\nContent-Type: multipart/x-mixed-replace; boundary=\(mjpegBoundary)\r\n\r\n" 126 - FileHandle.standardOutput.write(Data(header.utf8)) 127 - } 128 - 129 - // Set up cancellation handler 130 - await withTaskCancellationHandler { 131 - while !Task.isCancelled { 132 - let frameStartTime = Date() 133 - 134 - do { 135 - // Take screenshot 136 - let screenshotFuture = targetSimulator.takeScreenshot(.JPEG) 137 - let screenshotNSData = try await FutureBridge.value(screenshotFuture) 138 - let screenshotData = screenshotNSData as Data 139 - 140 - // Apply scaling if needed 141 - let processedData: Data 142 - if scale < 1.0 { 143 - processedData = try await scaleJPEGData(screenshotData, scale: scale, quality: quality) 144 - } else if quality != 80 { 145 - // Re-encode with different quality 146 - processedData = try await reencodeJPEGData(screenshotData, quality: quality) 147 - } else { 148 - processedData = screenshotData 149 - } 150 - 151 - // Output based on format 152 - switch format { 153 - case "mjpeg": 154 - // Write MJPEG frame with boundary 155 - let frameHeader = "\(mjpegBoundary)\r\nContent-Type: image/jpeg\r\nContent-Length: \(processedData.count)\r\n\r\n" 156 - FileHandle.standardOutput.write(Data(frameHeader.utf8)) 157 - FileHandle.standardOutput.write(processedData) 158 - FileHandle.standardOutput.write(Data("\r\n".utf8)) 159 - 160 - case "raw": 161 - // Write raw JPEG with 4-byte length prefix (big-endian) 162 - var length = UInt32(processedData.count).bigEndian 163 - FileHandle.standardOutput.write(Data(bytes: &length, count: 4)) 164 - FileHandle.standardOutput.write(processedData) 165 - 166 - case "ffmpeg": 167 - // Write raw JPEG data for ffmpeg's image2pipe 168 - FileHandle.standardOutput.write(processedData) 169 - 170 - default: 171 - break 172 - } 173 - 174 - frameCount += 1 175 - 176 - // Log progress every second 177 - if frameCount % UInt64(fps) == 0 { 178 - let elapsed = Date().timeIntervalSince(startTime) 179 - let actualFPS = Double(frameCount) / elapsed 180 - FileHandle.standardError.write(Data(String(format: "Captured %llu frames (%.1f FPS actual)\n", frameCount, actualFPS).utf8)) 181 - } 182 - 183 - } catch { 184 - FileHandle.standardError.write(Data("Error capturing frame: \(error.localizedDescription)\n".utf8)) 185 - } 186 - 187 - // Calculate time to next frame 188 - let frameElapsed = Date().timeIntervalSince(frameStartTime) 189 - let sleepTime = frameInterval - frameElapsed 190 - 191 - if sleepTime > 0 { 192 - try? await Task.sleep(nanoseconds: UInt64(sleepTime * 1_000_000_000)) 193 - } 194 - } 195 - } onCancel: { 196 - FileHandle.standardError.write(Data("\nStopping video stream...\n".utf8)) 197 - 198 - // Write final boundary for MJPEG 199 - if format == "mjpeg" { 200 - let footer = "\(mjpegBoundary)--\r\n" 201 - FileHandle.standardOutput.write(Data(footer.utf8)) 202 - } 203 - 204 - let elapsed = Date().timeIntervalSince(startTime) 205 - let avgFPS = Double(frameCount) / elapsed 206 - FileHandle.standardError.write(Data(String(format: "Streamed %llu frames in %.1f seconds (%.1f FPS average)\n", frameCount, elapsed, avgFPS).utf8)) 207 - } 208 - 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) 209 88 } catch { 210 - throw CLIError(errorDescription: "Failed to stream video: \(error.localizedDescription)") 89 + throw CLIError(errorDescription: "Failed to record video: \(error.localizedDescription)") 211 90 } 212 91 } 213 - 214 - // Legacy BGRA streaming implementation 215 - private func streamBGRAFormat(targetSimulator: FBSimulator, logger: AxeLogger) async throws { 216 - FileHandle.standardError.write(Data("Starting BGRA video stream from simulator \(targetSimulator.udid)...\n".utf8)) 217 - FileHandle.standardError.write(Data("Format: bgra, Quality: \(quality), Scale: \(scale)\n".utf8)) 218 - FileHandle.standardError.write(Data("Note: This is raw pixel data. Use ffmpeg to convert:\n".utf8)) 219 - 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)) 220 - FileHandle.standardError.write(Data("Press Ctrl+C to stop streaming\n".utf8)) 221 - 222 - do { 223 - let config = FBVideoStreamConfiguration( 224 - encoding: .BGRA, 225 - framesPerSecond: nil, 226 - compressionQuality: NSNumber(value: Double(quality) / 100.0), 227 - scaleFactor: NSNumber(value: scale), 228 - avgBitrate: nil, 229 - keyFrameRate: nil 230 - ) 231 - 232 - let stdoutConsumer = FBFileWriter.syncWriter(withFileDescriptor: STDOUT_FILENO, closeOnEndOfFile: false) 233 - let videoStreamFuture = targetSimulator.createStream(with: config) 234 - let videoStream = try await FutureBridge.value(videoStreamFuture) 235 - let startFuture = videoStream.startStreaming(stdoutConsumer) 236 - 237 - startFuture.onQueue(BridgeQueues.videoStreamQueue, notifyOfCompletion: { future in 238 - if let error = future.error { 239 - FileHandle.standardError.write(Data("Stream initialization error: \(error)\n".utf8)) 92 + 93 + // MARK: - Recording 94 + 95 + private func recordVideo( 96 + simulator: FBSimulator, 97 + outputURL: URL, 98 + fps: Int, 99 + quality: Int, 100 + scale: Double, 101 + cancellationFlag: CancellationFlag 102 + ) 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") 106 + } 107 + 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 121 + let startTime = Date() 122 + var lastPresentationTime = CMTime.zero 123 + 124 + try recorder.append(image: initialImage, presentationTime: .zero) 125 + let writerStartTime = Date() 126 + 127 + while true { 128 + if Task.isCancelled { 129 + break 130 + } 131 + if await cancellationFlag.value { 132 + break 133 + } 134 + 135 + let frameStart = Date() 136 + 137 + 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 240 142 } 241 - }) 242 - 243 - try await Task.sleep(nanoseconds: 1_000_000_000) 244 - FileHandle.standardError.write(Data("BGRA stream is now running...\n".utf8)) 245 - 246 - await withTaskCancellationHandler { 247 - while !Task.isCancelled { 248 - try? await Task.sleep(nanoseconds: 100_000_000) 143 + 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)) 249 148 } 250 - } onCancel: { 251 - FileHandle.standardError.write(Data("\nStopping BGRA stream...\n".utf8)) 252 - let semaphore = DispatchSemaphore(value: 0) 253 - 254 - BridgeQueues.videoStreamQueue.async { 255 - let stopFuture = videoStream.stopStreaming() 256 - stopFuture.onQueue(BridgeQueues.videoStreamQueue, notifyOfCompletion: { _ in 257 - FileHandle.standardError.write(Data("BGRA stream stopped\n".utf8)) 258 - semaphore.signal() 259 - }) 149 + 150 + try recorder.append(image: cgImage, presentationTime: presentationTime) 151 + lastPresentationTime = presentationTime 152 + frameCount += 1 153 + 154 + if frameCount - lastLogFrame >= Int64(fps) { 155 + lastLogFrame = frameCount 156 + 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)) 260 159 } 261 - 262 - _ = semaphore.wait(timeout: .now() + .seconds(5)) 160 + } catch { 161 + FileHandle.standardError.write(Data("Error capturing frame: \(error.localizedDescription)\n".utf8)) 263 162 } 264 - } catch { 265 - throw CLIError(errorDescription: "Failed to stream BGRA video: \(error.localizedDescription)") 163 + 164 + let elapsed = Date().timeIntervalSince(frameStart) 165 + let sleepTime = frameInterval - elapsed 166 + if sleepTime > 0 { 167 + try await Task.sleep(nanoseconds: UInt64(sleepTime * 1_000_000_000)) 168 + } 266 169 } 170 + 171 + try await recorder.finish() 267 172 } 268 - 269 - // Helper function to scale JPEG data 270 - private func scaleJPEGData(_ data: Data, scale: Double, quality: Int) async throws -> Data { 271 - #if os(macOS) 272 - guard let image = NSImage(data: data) else { 273 - throw CLIError(errorDescription: "Failed to decode JPEG data") 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) 210 + } 211 + return directoryURL.appendingPathComponent(filename) 212 + } 213 + 214 + let directoryURL = baseURL.deletingLastPathComponent() 215 + if !fileManager.fileExists(atPath: directoryURL.path) { 216 + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) 274 217 } 275 - 276 - let newSize = NSSize( 277 - width: image.size.width * scale, 278 - height: image.size.height * scale 218 + 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) 226 + } 227 + 228 + return baseURL 229 + } 230 + 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 + } 237 + 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) 296 + 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 + ] 303 + 304 + let outputSettings: [String: Any] = [ 305 + AVVideoCodecKey: AVVideoCodecType.h264, 306 + AVVideoWidthKey: width, 307 + AVVideoHeightKey: height, 308 + AVVideoCompressionPropertiesKey: compressionProperties 309 + ] 310 + 311 + let input = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings) 312 + input.expectsMediaDataInRealTime = true 313 + 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 279 324 ) 280 - 281 - let newImage = NSImage(size: newSize) 282 - newImage.lockFocus() 283 - image.draw(in: NSRect(origin: .zero, size: newSize)) 284 - newImage.unlockFocus() 285 - 286 - guard let tiffData = newImage.tiffRepresentation, 287 - let bitmap = NSBitmapImageRep(data: tiffData), 288 - let jpegData = bitmap.representation(using: .jpeg, properties: [NSBitmapImageRep.PropertyKey.compressionFactor: Double(quality) / 100.0]) else { 289 - throw CLIError(errorDescription: "Failed to re-encode scaled image") 325 + 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) 345 + } 346 + } 347 + 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: ()) 383 + } 384 + } 290 385 } 291 - 292 - return jpegData 293 - #else 294 - // For non-macOS platforms, return original data 295 - return data 296 - #endif 297 386 } 298 - 299 - // Helper function to re-encode JPEG with different quality 300 - private func reencodeJPEGData(_ data: Data, quality: Int) async throws -> Data { 301 - #if os(macOS) 302 - guard let image = NSImage(data: data), 303 - let tiffData = image.tiffRepresentation, 304 - let bitmap = NSBitmapImageRep(data: tiffData), 305 - let jpegData = bitmap.representation(using: .jpeg, properties: [NSBitmapImageRep.PropertyKey.compressionFactor: Double(quality) / 100.0]) else { 306 - throw CLIError(errorDescription: "Failed to re-encode image with new quality") 387 + 388 + func invalidate() { 389 + if writer.status == .writing { 390 + input.markAsFinished() 391 + writer.cancelWriting() 307 392 } 308 - 309 - return jpegData 310 - #else 311 - // For non-macOS platforms, return original data 312 - return data 313 - #endif 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 + } 413 + } 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) 314 422 } 315 - } 423 + }
+25 -26
Tests/StreamVideoDebugTest.swift
··· 3 3 4 4 @Suite("Stream Video Cancellation Tests") 5 5 struct StreamVideoDebugTests { 6 - @Test("Stream video command runs without hanging") 6 + @Test("Stream video command can be cancelled without hanging") 7 7 func streamVideoBasicExecution() async throws { 8 - // This test just verifies the command can be executed and terminated 9 - // without hanging indefinitely 10 - 11 8 guard let udid = defaultSimulatorUDID else { 12 9 throw TestError.commandError("No simulator UDID specified") 13 10 } 14 - 15 - // No need to launch app - stream-video captures the simulator screen directly 16 - 17 - // Create a task to run the command 18 - let commandTask = Task { 19 - try await TestHelpers.runAxeCommand( 20 - "stream-video --format bgra --fps 1", 21 - simulatorUDID: udid 22 - ) 23 - } 24 - 25 - // Wait a bit 11 + 12 + let axePath = try TestHelpers.getAxePath() 13 + let tempURL = FileManager.default.temporaryDirectory 14 + .appendingPathComponent("axe-video-debug-\(UUID().uuidString).mp4") 15 + defer { try? FileManager.default.removeItem(at: tempURL) } 16 + 17 + let process = Process() 18 + process.executableURL = URL(fileURLWithPath: axePath) 19 + process.arguments = [ 20 + "stream-video", 21 + "--udid", udid, 22 + "--fps", "5", 23 + "--output", tempURL.path 24 + ] 25 + process.standardOutput = Pipe() 26 + process.standardError = Pipe() 27 + 28 + try process.run() 26 29 try await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds 27 - 28 - // Cancel the task 29 - commandTask.cancel() 30 - 31 - // Give it time to clean up 32 - try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second 33 - 34 - // If we get here without hanging, the test passes 35 - #expect(commandTask.isCancelled, "Command task should be cancelled") 30 + 31 + process.interrupt() 32 + process.waitUntilExit() 33 + 34 + #expect(process.terminationStatus == 0, "Command should exit cleanly after cancellation") 36 35 } 37 - } 36 + }
+111 -173
Tests/StreamVideoTests.swift
··· 3 3 4 4 @Suite("Stream Video Command Tests") 5 5 struct StreamVideoTests { 6 - @Test("Stream video outputs MJPEG data with HTTP headers") 7 - func streamVideoMJPEG() async throws { 8 - // Act - Stream for 3 seconds to ensure we capture frames 9 - let result = try await streamVideoForDuration(format: "mjpeg", duration: 3.0) 10 - 11 - // Assert - SIGTERM (15) is expected since we're terminating the process 12 - #expect(result.exitCode == 15 || result.exitCode == 0, "Command should exit with SIGTERM or success") 13 - #expect(!result.output.isEmpty, "Should have output messages") 14 - #expect(result.output.contains("Starting screenshot-based video stream"), "Should show startup message") 15 - #expect(result.output.contains("Format: mjpeg"), "Should show format") 16 - // For now, just check that the command runs without crashing 17 - // Data capture in tests seems to have issues with streaming output 18 - } 19 - 20 - @Test("Stream video outputs raw JPEG data for ffmpeg format") 21 - func streamVideoFFmpeg() async throws { 22 - // Act 23 - let result = try await streamVideoForDuration(format: "ffmpeg", duration: 2.0) 24 - 25 - // Assert - SIGTERM (15) is expected since we're terminating the process 26 - #expect(result.exitCode == 15 || result.exitCode == 0, "Command should exit with SIGTERM or success") 27 - #expect(result.output.contains("Format: ffmpeg"), "Should show format") 28 - // Data capture validation removed due to test infrastructure limitations 29 - } 30 - 31 - @Test("Stream video outputs raw JPEG with length prefix for raw format") 32 - func streamVideoRaw() async throws { 33 - // Act 34 - let result = try await streamVideoForDuration(format: "raw", duration: 2.0) 35 - 36 - // Assert - SIGTERM (15) is expected since we're terminating the process 37 - #expect(result.exitCode == 15 || result.exitCode == 0, "Command should exit with SIGTERM or success") 38 - #expect(result.output.contains("Format: raw"), "Should show format") 39 - // Data capture validation removed due to test infrastructure limitations 40 - } 41 - 42 - @Test("Stream video with custom FPS") 43 - func streamVideoWithFPS() async throws { 44 - // Act 45 - let result = try await streamVideoForDuration(format: "mjpeg", fps: 5, duration: 2.0) 46 - 47 - // Assert - SIGTERM (15) is expected since we're terminating the process 48 - #expect(result.exitCode == 15 || result.exitCode == 0, "Command should exit with SIGTERM or success") 49 - #expect(result.output.contains("FPS: 5"), "Should show custom FPS") 50 - // Frame capture progress may appear depending on timing 51 - } 52 - 53 - @Test("Stream video with quality and scale settings") 54 - func streamVideoWithQualityAndScale() async throws { 55 - // Act 56 - let result = try await streamVideoForDuration( 57 - format: "mjpeg", 58 - fps: 5, 59 - quality: 50, 60 - scale: 0.5, 61 - duration: 1.0 62 - ) 63 - 64 - // Assert - SIGTERM (15) is expected since we're terminating the process 65 - #expect(result.exitCode == 15 || result.exitCode == 0, "Command should exit with SIGTERM or success") 66 - #expect(result.output.contains("Quality: 50"), "Should show quality setting") 67 - #expect(result.output.contains("Scale: 0.5"), "Should show scale setting") 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) } 10 + 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") 68 15 } 69 - 70 - @Test("Stream BGRA video outputs raw pixel data") 71 - func streamVideoBGRA() async throws { 72 - // Act - Stream for 1 second 73 - let result = try await streamVideoForDuration(format: "bgra", duration: 2.0) 74 - 75 - // Assert - SIGTERM (15) is expected since we're terminating the process 76 - #expect(result.exitCode == 15 || result.exitCode == 0, "Command should exit with SIGTERM or success") 77 - #expect(!result.output.isEmpty, "Should have output messages") 78 - #expect(result.output.contains("Starting BGRA video stream"), "Should show BGRA startup message") 79 - #expect(result.output.contains("Format: bgra"), "Should show format") 80 - // BGRA data capture validation removed due to FBSimulatorControl streaming issues 16 + 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) } 21 + 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") 81 25 } 82 - 83 - @Test("Stream video can be cancelled gracefully") 84 - func streamVideoCancellation() async throws { 85 - // Act - Start streaming and cancel quickly 86 - let task = Task { 87 - try await streamVideoForDuration(format: "mjpeg", fps: 30, duration: 60.0) 88 - } 89 - 90 - // Wait a bit then cancel 91 - try await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds 92 - task.cancel() 93 - 94 - // Wait for task to complete 95 - let _ = await task.result 96 - 97 - // Test passes if no crash occurs 26 + 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) 34 + 35 + let result = try await recordVideo(duration: 1.0, outputPath: tempDir.path) 36 + 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") 42 + 43 + try? FileManager.default.removeItem(at: tempDir) 98 44 } 99 - 100 - @Test("Stream video validates format parameter") 101 - func streamVideoInvalidFormat() async throws { 102 - // Build command with invalid format 45 + 46 + @Test("Stream video validates FPS input") 47 + func streamVideoInvalidFPS() async throws { 103 48 guard let udid = defaultSimulatorUDID else { 104 49 throw TestError.commandError("No simulator UDID specified") 105 50 } 106 - 107 51 let axePath = try TestHelpers.getAxePath() 108 - let fullCommand = "\(axePath) stream-video --format h264 --udid \(udid)" 109 - 52 + 110 53 let process = Process() 111 - process.executableURL = URL(fileURLWithPath: "/bin/bash") 112 - process.arguments = ["-c", fullCommand] 113 - 54 + process.executableURL = URL(fileURLWithPath: axePath) 55 + process.arguments = [ 56 + "stream-video", 57 + "--udid", udid, 58 + "--fps", "40" 59 + ] 114 60 let errorPipe = Pipe() 115 61 process.standardError = errorPipe 116 62 process.standardOutput = Pipe() 117 - 63 + 118 64 try process.run() 119 65 process.waitUntilExit() 120 - 121 - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() 122 - let errorOutput = String(data: errorData, encoding: .utf8) ?? "" 123 - 124 - // Should exit with error 125 - #expect(process.terminationStatus != 0, "Invalid format should cause error") 126 - #expect(errorOutput.contains("Invalid format"), "Should show format error") 66 + 67 + let errorOutput = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" 68 + 69 + #expect(process.terminationStatus != 0, "Invalid FPS should fail") 70 + #expect(errorOutput.contains("FPS must be between 1 and 30"), "Should surface validation message") 127 71 } 128 - 129 - // MARK: - Helper Methods 130 - 131 - private func streamVideoForDuration( 132 - format: String = "mjpeg", 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 recordVideo( 133 84 fps: Int = 10, 134 85 quality: Int = 80, 135 86 scale: Double = 1.0, 136 - duration: TimeInterval = 2.0 137 - ) async throws -> (output: String, data: Data, dataString: String, dataSize: Int, exitCode: Int32) { 138 - // Build command 139 - var command = "stream-video --format \(format)" 140 - command += " --fps \(fps)" 141 - command += " --quality \(quality) --scale \(scale)" 142 - 143 - // Run command directly with timeout since stream-video outputs to stdout 144 - // and TestHelpers.runAxeCommand doesn't separate stdout/stderr 87 + duration: TimeInterval = 2.0, 88 + outputPath: String? = nil 89 + ) async throws -> RecordingResult { 145 90 guard let udid = defaultSimulatorUDID else { 146 91 throw TestError.commandError("No simulator UDID specified in SIMULATOR_UDID environment variable") 147 92 } 148 - 149 93 let axePath = try TestHelpers.getAxePath() 150 - let fullCommand = "\(axePath) \(command) --udid \(udid)" 151 - 94 + 95 + let defaultOutputURL = FileManager.default.temporaryDirectory 96 + .appendingPathComponent("axe-video-test-\(UUID().uuidString).mp4") 97 + let configuredOutputPath = outputPath ?? defaultOutputURL.path 98 + 152 99 let process = Process() 153 - process.executableURL = URL(fileURLWithPath: "/bin/bash") 154 - process.arguments = ["-c", fullCommand] 155 - 156 - let outputPipe = Pipe() 157 - let errorPipe = Pipe() 158 - process.standardOutput = outputPipe 159 - process.standardError = errorPipe 160 - 161 - // Set up to read data continuously 162 - var outputData = Data() 163 - let outputHandle = outputPipe.fileHandleForReading 164 - outputHandle.readabilityHandler = { handle in 165 - let availableData = handle.availableData 166 - if !availableData.isEmpty { 167 - outputData.append(availableData) 168 - } 169 - } 170 - 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 + ] 109 + 110 + let stdoutPipe = Pipe() 111 + let stderrPipe = Pipe() 112 + process.standardOutput = stdoutPipe 113 + process.standardError = stderrPipe 114 + 171 115 try process.run() 172 - 173 - // Let it run for duration 116 + 174 117 try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) 175 - 176 - // Terminate the process 177 - process.terminate() 178 - 179 - // Stop reading 180 - outputHandle.readabilityHandler = nil 181 - 182 - // Wait for process to exit 118 + 119 + process.interrupt() // send SIGINT to trigger graceful shutdown 183 120 process.waitUntilExit() 184 - 185 - // Read any remaining data 186 - let remainingData = outputHandle.readDataToEndOfFile() 187 - if !remainingData.isEmpty { 188 - outputData.append(remainingData) 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 189 132 } 190 - 191 - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() 192 - let errorOutput = String(data: errorData, encoding: .utf8) ?? "" 193 - let dataString = String(data: outputData, encoding: .utf8) ?? "" 194 - 195 - // Debug output 196 - if outputData.count == 0 && !errorOutput.isEmpty { 197 - print("DEBUG: No data received. Error output: \(errorOutput)") 133 + 134 + if outputPath == nil { 135 + try? FileManager.default.removeItem(at: resolvedURL) 198 136 } 199 - 200 - return ( 201 - output: errorOutput, 202 - data: outputData, 203 - dataString: dataString, 204 - dataSize: outputData.count, 137 + 138 + return RecordingResult( 139 + outputURL: resolvedURL, 140 + stdout: String(data: stdoutData, encoding: .utf8) ?? "", 141 + stderr: String(data: stderrData, encoding: .utf8) ?? "", 142 + fileSize: fileSize, 205 143 exitCode: process.terminationStatus 206 144 ) 207 145 } 208 - } 146 + }
+17 -31
USAGE_EXAMPLES.md
··· 206 206 axe key-sequence --keycodes 43,43,40 --delay 1.0 --udid SIMULATOR_UDID # Tab navigation 207 207 ``` 208 208 209 - ### **Video Streaming** 209 + ### **Video Recording** 210 210 211 211 ```bash 212 - # Stream video from simulator using screenshot-based capture 213 - 214 - # Basic MJPEG streaming at 10 FPS 215 - axe stream-video --udid SIMULATOR_UDID --fps 10 --format mjpeg > recording.mjpeg 216 - 217 - # Real-time H264 encoding with ffmpeg (recommended) 218 - axe stream-video --udid SIMULATOR_UDID --fps 30 --format ffmpeg | \ 219 - ffmpeg -f image2pipe -framerate 30 -i - -c:v libx264 -preset ultrafast output.mp4 220 - 221 - # View stream in real-time with ffplay 222 - axe stream-video --udid SIMULATOR_UDID --fps 15 --format ffmpeg | \ 223 - ffplay -f image2pipe -framerate 15 -i - 224 - 225 - # Stream with reduced bandwidth (lower quality and resolution) 226 - axe stream-video --udid SIMULATOR_UDID --fps 10 --quality 60 --scale 0.5 --format mjpeg > low-bandwidth.mjpeg 212 + # Record to an MP4 (QuickTime compatible) in the current directory 213 + axe stream-video --udid SIMULATOR_UDID --fps 15 227 214 228 - # Raw JPEG stream for custom processing 229 - axe stream-video --udid SIMULATOR_UDID --fps 5 --format raw | custom-video-processor 215 + # Choose a custom destination 216 + axe stream-video --udid SIMULATOR_UDID --fps 20 --output recordings/run.mp4 230 217 231 - # Legacy BGRA format (raw pixel data) 232 - axe stream-video --udid SIMULATOR_UDID --format bgra | \ 233 - ffmpeg -f rawvideo -pixel_format bgra -video_size 393x852 -framerate 10 -i - output.mp4 218 + # 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 234 220 235 - # Automated recording script 236 - #!/bin/bash 237 - UDID=$(axe list-simulators | grep "Booted" | head -1 | grep -o '[A-F0-9-]\{36\}') 221 + # Simple automation-friendly script 222 + UDID=$(axe list-simulators | awk '/Booted/{print $NF; exit}') 238 223 OUTPUT="recording_$(date +%Y%m%d_%H%M%S).mp4" 239 224 240 - # Start recording 241 - axe stream-video --udid "$UDID" --fps 30 --format ffmpeg | \ 242 - ffmpeg -f image2pipe -framerate 30 -i - -c:v libx264 -preset fast "$OUTPUT" & 225 + axe stream-video --udid "$UDID" --fps 25 --output "$OUTPUT" & 243 226 RECORD_PID=$! 244 227 245 - # Run your automation 228 + # ...run automation commands here... 246 229 axe tap -x 100 -y 200 --udid "$UDID" 247 230 axe gesture scroll-down --udid "$UDID" 248 - # ... more commands ... 249 231 250 - # Stop recording 251 232 sleep 2 252 - kill $RECORD_PID 233 + kill -INT $RECORD_PID 234 + wait $RECORD_PID 235 + printf 'Saved recording to %s\n' "$OUTPUT" 253 236 ``` 237 + 238 + > [!NOTE] 239 + > `kill -INT` sends the same signal as pressing `Ctrl+C`, giving AXe time to finalise the MP4 before the process exits. 254 240 255 241 ## Shell Escaping Solutions 256 242
build_products/XCFrameworks/FBControlCore.xcframework/_CodeSignature/CodeDirectory

This is a binary file and will not be displayed.

+8 -8
build_products/XCFrameworks/FBControlCore.xcframework/_CodeSignature/CodeResources
··· 6 6 <dict> 7 7 <key>macos-arm64_x86_64/FBControlCore.framework/Versions/A/FBControlCore</key> 8 8 <data> 9 - WpwePYxOm8qOvAmmp5pwp1E3V0k= 9 + E0ZEyrjJbDVpU+Uvp5T6eS1FEAg= 10 10 </data> 11 11 <key>macos-arm64_x86_64/FBControlCore.framework/Versions/A/Headers/FBAccessibilityCommands.h</key> 12 12 <data> ··· 390 390 </data> 391 391 <key>macos-arm64_x86_64/FBControlCore.framework/Versions/A/Resources/libMaculator.dylib</key> 392 392 <data> 393 - llg0VwCCPyT/qg62hhNtnhqbdZk= 393 + 437GRWtEfZz0cA3h1tdVcREke5Q= 394 394 </data> 395 395 <key>macos-arm64_x86_64/FBControlCore.framework/Versions/A/Resources/libShimulator.dylib</key> 396 396 <data> 397 - VVO9QwR1VlY7Zrda8+GZIaOKXb4= 397 + oJrqrXzMaHxCjUb1YtkvslgXazo= 398 398 </data> 399 399 <key>macos-arm64_x86_64/FBControlCore.framework/Versions/A/_CodeSignature/CodeResources</key> 400 400 <data> 401 - 5P/hCnnGhjcCWYpX6Y7+UaU843k= 401 + UMQ428fAAG+YYBY2lbu0hcv8oa4= 402 402 </data> 403 403 </dict> 404 404 <key>files2</key> ··· 427 427 <dict> 428 428 <key>hash2</key> 429 429 <data> 430 - g5zguLGuxJ99e/XnFd1d0AaAaNrw+SKheucNDupcTUY= 430 + NSrcjkBIIig75XKTGzb8Gm1QI+/tXIIziYIaahbY8oQ= 431 431 </data> 432 432 </dict> 433 433 <key>macos-arm64_x86_64/FBControlCore.framework/Versions/A/Headers/FBAccessibilityCommands.h</key> ··· 1099 1099 <dict> 1100 1100 <key>hash2</key> 1101 1101 <data> 1102 - h9F+x6i5Ds72Rmw87Uk+1ojARvf6oBmaMHipeLiqbNQ= 1102 + MKxI2rgKEcdxst/GI0LFhWa0h2M+an5GXd98a1d5j3Q= 1103 1103 </data> 1104 1104 </dict> 1105 1105 <key>macos-arm64_x86_64/FBControlCore.framework/Versions/A/Resources/libShimulator.dylib</key> 1106 1106 <dict> 1107 1107 <key>hash2</key> 1108 1108 <data> 1109 - RsFfHfUVFsW1IiGf+LTEkdf6KRfEmMkDAAVS5Shcap0= 1109 + eLiCkvo97ye/GC0zUJ5EC8yCSdvtwilis/z8SWHnFTs= 1110 1110 </data> 1111 1111 </dict> 1112 1112 <key>macos-arm64_x86_64/FBControlCore.framework/Versions/A/_CodeSignature/CodeResources</key> 1113 1113 <dict> 1114 1114 <key>hash2</key> 1115 1115 <data> 1116 - p/hM6IyImXBL/YIXAylDZWDj6OxuhRsyS9Z8sD4jU/M= 1116 + +MpB+vBJOMERApBzX1p8VfpRYIzWjzKH8hC5IeC44Aw= 1117 1117 </data> 1118 1118 </dict> 1119 1119 <key>macos-arm64_x86_64/FBControlCore.framework/Versions/Current</key>
build_products/XCFrameworks/FBControlCore.xcframework/_CodeSignature/CodeSignature

This is a binary file and will not be displayed.

build_products/XCFrameworks/FBControlCore.xcframework/macos-arm64_x86_64/FBControlCore.framework/Versions/A/FBControlCore

This is a binary file and will not be displayed.

build_products/XCFrameworks/FBControlCore.xcframework/macos-arm64_x86_64/FBControlCore.framework/Versions/A/Resources/libMaculator.dylib

This is a binary file and will not be displayed.

build_products/XCFrameworks/FBControlCore.xcframework/macos-arm64_x86_64/FBControlCore.framework/Versions/A/Resources/libShimulator.dylib

This is a binary file and will not be displayed.

+4 -4
build_products/XCFrameworks/FBControlCore.xcframework/macos-arm64_x86_64/FBControlCore.framework/Versions/A/_CodeSignature/CodeResources
··· 10 10 </data> 11 11 <key>Resources/libMaculator.dylib</key> 12 12 <data> 13 - llg0VwCCPyT/qg62hhNtnhqbdZk= 13 + 437GRWtEfZz0cA3h1tdVcREke5Q= 14 14 </data> 15 15 <key>Resources/libShimulator.dylib</key> 16 16 <data> 17 - VVO9QwR1VlY7Zrda8+GZIaOKXb4= 17 + oJrqrXzMaHxCjUb1YtkvslgXazo= 18 18 </data> 19 19 </dict> 20 20 <key>files2</key> ··· 688 688 <dict> 689 689 <key>hash2</key> 690 690 <data> 691 - h9F+x6i5Ds72Rmw87Uk+1ojARvf6oBmaMHipeLiqbNQ= 691 + MKxI2rgKEcdxst/GI0LFhWa0h2M+an5GXd98a1d5j3Q= 692 692 </data> 693 693 </dict> 694 694 <key>Resources/libShimulator.dylib</key> 695 695 <dict> 696 696 <key>hash2</key> 697 697 <data> 698 - RsFfHfUVFsW1IiGf+LTEkdf6KRfEmMkDAAVS5Shcap0= 698 + eLiCkvo97ye/GC0zUJ5EC8yCSdvtwilis/z8SWHnFTs= 699 699 </data> 700 700 </dict> 701 701 </dict>
build_products/XCFrameworks/FBDeviceControl.xcframework/_CodeSignature/CodeDirectory

This is a binary file and will not be displayed.

+2 -2
build_products/XCFrameworks/FBDeviceControl.xcframework/_CodeSignature/CodeResources
··· 6 6 <dict> 7 7 <key>macos-arm64_x86_64/FBDeviceControl.framework/Versions/A/FBDeviceControl</key> 8 8 <data> 9 - fYt518cqdqR+AGLmBa+NtAx5Ui8= 9 + Ktyi4b05qZ6FvkIPXueqc+FKiaI= 10 10 </data> 11 11 <key>macos-arm64_x86_64/FBDeviceControl.framework/Versions/A/Headers/FBAFCConnection.h</key> 12 12 <data> ··· 189 189 <dict> 190 190 <key>hash2</key> 191 191 <data> 192 - 86OvfBUZLRYu2MoxOPQ49oj0DoaSWHqIRxVoJNwdUDQ= 192 + UaKE1Upd2X9fOsqaz3ykxGub+bKCpTK8pSglwpN6mu0= 193 193 </data> 194 194 </dict> 195 195 <key>macos-arm64_x86_64/FBDeviceControl.framework/Versions/A/Headers/FBAFCConnection.h</key>
build_products/XCFrameworks/FBDeviceControl.xcframework/_CodeSignature/CodeSignature

This is a binary file and will not be displayed.

build_products/XCFrameworks/FBDeviceControl.xcframework/macos-arm64_x86_64/FBDeviceControl.framework/Versions/A/FBDeviceControl

This is a binary file and will not be displayed.

build_products/XCFrameworks/FBSimulatorControl.xcframework/_CodeSignature/CodeDirectory

This is a binary file and will not be displayed.

+2 -2
build_products/XCFrameworks/FBSimulatorControl.xcframework/_CodeSignature/CodeResources
··· 6 6 <dict> 7 7 <key>macos-arm64_x86_64/FBSimulatorControl.framework/Versions/A/FBSimulatorControl</key> 8 8 <data> 9 - +hrfaAD4+qBChCdsXBxEzfU4Oj0= 9 + 3trNSkQHz4h98CQv2ImN71xqcSc= 10 10 </data> 11 11 <key>macos-arm64_x86_64/FBSimulatorControl.framework/Versions/A/Headers/FBFramebuffer.h</key> 12 12 <data> ··· 157 157 <dict> 158 158 <key>hash2</key> 159 159 <data> 160 - 7Jt8mK4W2wSRL+VDuPjsgTZGQMFj5gndWAntyeAB+t0= 160 + /G+34rzvNClAtzU9wP9REeswXADXVXnZ+UWIQGRRwvE= 161 161 </data> 162 162 </dict> 163 163 <key>macos-arm64_x86_64/FBSimulatorControl.framework/Versions/A/Headers/FBFramebuffer.h</key>
build_products/XCFrameworks/FBSimulatorControl.xcframework/_CodeSignature/CodeSignature

This is a binary file and will not be displayed.

build_products/XCFrameworks/FBSimulatorControl.xcframework/macos-arm64_x86_64/FBSimulatorControl.framework/Versions/A/FBSimulatorControl

This is a binary file and will not be displayed.

build_products/XCFrameworks/XCTestBootstrap.xcframework/_CodeSignature/CodeDirectory

This is a binary file and will not be displayed.

+2 -2
build_products/XCFrameworks/XCTestBootstrap.xcframework/_CodeSignature/CodeResources
··· 130 130 </data> 131 131 <key>macos-arm64_x86_64/XCTestBootstrap.framework/Versions/A/XCTestBootstrap</key> 132 132 <data> 133 - 1YJyzvUQLFN7UYfH/uf+1U+zdbY= 133 + hPGAUxsbOk6asmdpESwMtfTLpH0= 134 134 </data> 135 135 <key>macos-arm64_x86_64/XCTestBootstrap.framework/Versions/A/_CodeSignature/CodeResources</key> 136 136 <data> ··· 385 385 <dict> 386 386 <key>hash2</key> 387 387 <data> 388 - h5IuteyX7nAhIo85hhvesmn1paCsHc+63JmGhboCf60= 388 + 5F4PK0odKkktfrtX+pXJ5H7h/72DLsp6L28/GLgXgkw= 389 389 </data> 390 390 </dict> 391 391 <key>macos-arm64_x86_64/XCTestBootstrap.framework/Versions/A/_CodeSignature/CodeResources</key>
build_products/XCFrameworks/XCTestBootstrap.xcframework/_CodeSignature/CodeSignature

This is a binary file and will not be displayed.

build_products/XCFrameworks/XCTestBootstrap.xcframework/macos-arm64_x86_64/XCTestBootstrap.framework/Versions/A/XCTestBootstrap

This is a binary file and will not be displayed.