From cbc2d1db13a77e31f4ceb9829309d7775782a823 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Jul 2026 10:29:44 +0900 Subject: [PATCH 1/2] refactor: extract platform-neutral video plumbing into SimUseVideo target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The H.264 Annex B parser, passthrough muxer, AVAssetWriter encoder, frame utilities, and output-path resolution were platform-neutral but lived in iOSSimBackend, forcing the Android record-video orchestration into the SimUse executable target (the only place with both backends in its dep cone). Move them into a new SimUseVideo target (SimUseCore + system frameworks only, FB*-free), and sink the generic process-control helpers (CancellationFlag, OnceFlag, FirstErrorBox, cancellableSleep, SignalObserver) into SimUseCore. The single FB*-tied piece — VideoFrameUtilities.captureScreenshotData — stays in iOSSimBackend as an extension. Pure restructuring, no behavior change; groundwork for hosting Android video verbs in AndroidBackend (#78). Co-Authored-By: Claude Fable 5 Signed-off-by: onevcat --- AGENTS.md | 9 +- CHANGELOG.md | 1 + Package.swift | 19 ++- Sources/SimUse/Commands/RecordVideo.swift | 3 +- Sources/SimUseCore/ProcessControl.swift | 113 +++++++++++++++++ .../AnnexBStreamParser.swift | 0 .../H264MuxingPipeline.swift | 0 .../H264PassthroughRecorder.swift | 0 .../VideoCommandSupport.swift | 119 ------------------ Sources/SimUseVideo/VideoOutputFile.swift | 57 +++++++++ .../Util/VideoFrameUtilities+Simulator.swift | 19 +++ .../Verbs/IOSSimRecordVideoCommand.swift | 53 +------- .../Verbs/IOSSimScreenshotCommand.swift | 1 + .../Verbs/IOSSimStreamVideoCommand.swift | 1 + Tests/AnnexBStreamParserTests.swift | 2 +- Tests/FirstErrorBoxTests.swift | 2 +- Tests/H264MuxingPipelineTests.swift | 2 +- Tests/H264PassthroughRecorderTests.swift | 2 +- Tests/RecordingFinishWatchdogTests.swift | 2 +- Tests/VideoWriterStallTimeoutTests.swift | 2 +- 20 files changed, 226 insertions(+), 181 deletions(-) create mode 100644 Sources/SimUseCore/ProcessControl.swift rename Sources/{iOSSimBackend/Util => SimUseVideo}/AnnexBStreamParser.swift (100%) rename Sources/{iOSSimBackend/Util => SimUseVideo}/H264MuxingPipeline.swift (100%) rename Sources/{iOSSimBackend/Util => SimUseVideo}/H264PassthroughRecorder.swift (100%) rename Sources/{iOSSimBackend/Util => SimUseVideo}/VideoCommandSupport.swift (75%) create mode 100644 Sources/SimUseVideo/VideoOutputFile.swift create mode 100644 Sources/iOSSimBackend/Util/VideoFrameUtilities+Simulator.swift diff --git a/AGENTS.md b/AGENTS.md index 7200a451..95a61b4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,14 +68,17 @@ After any non-trivial change, at minimum: ## Module layout -Four SwiftPM targets; dependency graph flows in one direction. +Five SwiftPM targets; dependency graph flows in one direction. | Target | Path | Depends on | |---|---|---| | `SimUseCore` | `Sources/SimUseCore/` | Foundation + ArgumentParser | -| `iOSSimBackend` | `Sources/iOSSimBackend/` | SimUseCore + FB* XCFrameworks + AVFoundation | +| `SimUseVideo` | `Sources/SimUseVideo/` | SimUseCore + AVFoundation/ImageIO | +| `iOSSimBackend` | `Sources/iOSSimBackend/` | SimUseCore + SimUseVideo + FB* XCFrameworks + AVFoundation | | `AndroidBackend` | `Sources/AndroidBackend/` | SimUseCore + ArgumentParser | -| `SimUse` (executable) | `Sources/SimUse/` | SimUseCore + iOSSimBackend + AndroidBackend + FB* | +| `SimUse` (executable) | `Sources/SimUse/` | SimUseCore + SimUseVideo + iOSSimBackend + AndroidBackend + FB* | + +`SimUseVideo` holds the platform-neutral host-side video plumbing (H.264 Annex B parsing, passthrough muxing, `AVAssetWriter` encoding, frame utilities) shared by the iOS and Android recording/streaming paths. It must stay FB*-free — anything that needs FBSimulatorControl belongs in `iOSSimBackend` (e.g. the `VideoFrameUtilities.captureScreenshotData` extension), anything adb-shaped in `AndroidBackend`. ### Verb dispatch diff --git a/CHANGELOG.md b/CHANGELOG.md index d69eaf59..4a2040d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Internal: the platform-neutral video plumbing (H.264 Annex B parsing, passthrough muxing, `AVAssetWriter` encoding, frame/output-path utilities) moved out of `iOSSimBackend` into a new `SimUseVideo` target, and the generic process-control helpers (`CancellationFlag`, `SignalObserver`, …) into `SimUseCore`. Pure restructuring — no CLI behavior change; unblocks hosting Android video verbs inside `AndroidBackend` (#78 groundwork). - `gesture --screen-width/--screen-height` on iOS: for **single-finger presets** they now describe a visual-space canvas and default to the auto-detected screen size of the current orientation (previously always 390×844, iPhone 15 portrait — wrong for iPads and every landscape state); 390×844 survives only when the screen size cannot be probed. **Pinch/rotate presets** keep the raw device-native portrait canvas with the fixed 390×844 default. ### Added diff --git a/Package.swift b/Package.swift index 30fdb9ec..3c20ffe2 100644 --- a/Package.swift +++ b/Package.swift @@ -75,6 +75,10 @@ let package = Package( name: "SimUseCore", targets: ["SimUseCore"] ), + .library( + name: "SimUseVideo", + targets: ["SimUseVideo"] + ), .library( name: "AndroidBackend", targets: ["AndroidBackend"] @@ -100,10 +104,22 @@ let package = Package( // of higher targets. plugins: ["VersionPlugin"] ), + // Platform-neutral host-side video plumbing (H.264 parsing/muxing/ + // encoding, frame utilities) shared by both backends. Must stay + // FB*-free: anything that needs FBSimulatorControl belongs in + // iOSSimBackend, anything adb-shaped in AndroidBackend. + .target( + name: "SimUseVideo", + dependencies: [ + "SimUseCore", + ], + path: "Sources/SimUseVideo" + ), .target( name: "iOSSimBackend", dependencies: [ "SimUseCore", + "SimUseVideo", "FBSimulatorControl", "FBControlCore", "XCTestBootstrap", @@ -138,6 +154,7 @@ let package = Package( dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), "SimUseCore", + "SimUseVideo", "AndroidBackend", "iOSSimBackend", "FBSimulatorControl", @@ -169,7 +186,7 @@ let package = Package( ), .testTarget( name: "SimUseTests", - dependencies: ["SimUse", "iOSSimBackend", "SimUseCore"], + dependencies: ["SimUse", "iOSSimBackend", "SimUseCore", "SimUseVideo"], path: "Tests", // `Tests/` is the umbrella path; the sub-target test // directories below sit under it as separate testTargets. diff --git a/Sources/SimUse/Commands/RecordVideo.swift b/Sources/SimUse/Commands/RecordVideo.swift index a94d9b4c..043819b1 100644 --- a/Sources/SimUse/Commands/RecordVideo.swift +++ b/Sources/SimUse/Commands/RecordVideo.swift @@ -5,6 +5,7 @@ import FBSimulatorControl @preconcurrency import FBControlCore import AVFoundation import SimUseCore +import SimUseVideo import AndroidBackend import iOSSimBackend @@ -121,7 +122,7 @@ struct RecordVideo: SimUseExecutableCommand { let serial = device.resolved try assertAdbDeviceOnline(adb: adb, serial: serial) - let outputURL = try IOSSimRecordVideoCommand.prepareOutputURL(output: output) + let outputURL = try VideoOutputFile.prepareOutputURL(output: output) FileHandle.standardError.write(Data("Recording Android device \(serial) to \(outputURL.path)\n".utf8)) FileHandle.standardError.write(Data("Press Ctrl+C to stop recording\n".utf8)) diff --git a/Sources/SimUseCore/ProcessControl.swift b/Sources/SimUseCore/ProcessControl.swift new file mode 100644 index 00000000..d012692f --- /dev/null +++ b/Sources/SimUseCore/ProcessControl.swift @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import os + +/// Synchronous cancellation flag used by the streaming/recording commands. +/// +/// The signal-to-finalise path is latency-critical: a process supervisor +/// that follows SIGTERM with a short-grace SIGKILL must reach +/// `recorder.finish()` before the kill lands, otherwise the mp4 trailer +/// (moov atom) is never written. An actor-backed flag adds ~10-100 ms of +/// scheduler jitter on every cancel/check; `OSAllocatedUnfairLock` keeps the +/// handler and loop pickup synchronous. +public final class CancellationFlag: Sendable { + private let value = OSAllocatedUnfairLock(initialState: false) + + public init() {} + + public func cancel() { + value.withLock { $0 = true } + } + + public func isCancelled() -> Bool { + value.withLock { $0 } + } +} + +/// A latch that transitions to "set" exactly once. Guarantees a +/// `CheckedContinuation` is resumed a single time when two callbacks race +/// (a completion handler versus a timeout). +public final class OnceFlag: Sendable { + private let fired = OSAllocatedUnfairLock(initialState: false) + + public init() {} + + /// Returns true the first time it is called, false thereafter. + public func trySet() -> Bool { + fired.withLock { fired in + guard !fired else { return false } + fired = true + return true + } + } +} + +/// Thread-safe, set-once error capture for callback-based APIs (e.g. +/// FBFuture completion callbacks). +/// +/// The BGRA stream reports failures through completion callbacks on a +/// background queue with no continuation to resume — the stream runs +/// until a signal arrives. The command loop polls this box instead, so +/// the first failure terminates streaming and surfaces as a thrown +/// error rather than being lost to stderr. +public final class FirstErrorBox: Sendable { + private let stored = OSAllocatedUnfairLock(initialState: nil) + + public init() {} + + /// Records `error` unless one is already recorded; later calls are no-ops. + public func set(_ error: Error) { + stored.withLock { if $0 == nil { $0 = error } } + } + + /// The first error recorded, or nil if none has been. + public var first: Error? { + stored.withLock { $0 } + } +} + +/// Sleep that wakes early when `flag` is cancelled. +/// +/// `Task.sleep` is not interruptible from a signal handler, so a vanilla +/// frame-pacing sleep adds up to one frame interval of latency to the +/// signal-to-finish path. Polling in short chunks bounds that latency. +public func cancellableSleep(seconds: TimeInterval, flag: CancellationFlag) async throws { + guard seconds > 0 else { return } + let chunkNanos: UInt64 = 5_000_000 // 5 ms + let totalNanos = UInt64(seconds * 1_000_000_000) + var elapsed: UInt64 = 0 + while elapsed < totalNanos { + if flag.isCancelled() { return } + let step = min(chunkNanos, totalNanos - elapsed) + try await Task.sleep(nanoseconds: step) + elapsed += step + } +} + +public final class SignalObserver { + private var sources: [DispatchSourceSignal] = [] + private let signals: [Int32] + + public init(signals: [Int32], handler: @escaping @Sendable () -> Void) { + self.signals = signals + for signalValue in signals { + signal(signalValue, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: signalValue, queue: .main) + source.setEventHandler(handler: handler) + source.resume() + sources.append(source) + } + } + + public func invalidate() { + sources.forEach { $0.cancel() } + sources.removeAll() + for signalValue in signals { + signal(signalValue, SIG_DFL) + } + } + + deinit { + invalidate() + } +} diff --git a/Sources/iOSSimBackend/Util/AnnexBStreamParser.swift b/Sources/SimUseVideo/AnnexBStreamParser.swift similarity index 100% rename from Sources/iOSSimBackend/Util/AnnexBStreamParser.swift rename to Sources/SimUseVideo/AnnexBStreamParser.swift diff --git a/Sources/iOSSimBackend/Util/H264MuxingPipeline.swift b/Sources/SimUseVideo/H264MuxingPipeline.swift similarity index 100% rename from Sources/iOSSimBackend/Util/H264MuxingPipeline.swift rename to Sources/SimUseVideo/H264MuxingPipeline.swift diff --git a/Sources/iOSSimBackend/Util/H264PassthroughRecorder.swift b/Sources/SimUseVideo/H264PassthroughRecorder.swift similarity index 100% rename from Sources/iOSSimBackend/Util/H264PassthroughRecorder.swift rename to Sources/SimUseVideo/H264PassthroughRecorder.swift diff --git a/Sources/iOSSimBackend/Util/VideoCommandSupport.swift b/Sources/SimUseVideo/VideoCommandSupport.swift similarity index 75% rename from Sources/iOSSimBackend/Util/VideoCommandSupport.swift rename to Sources/SimUseVideo/VideoCommandSupport.swift index 7ff36149..1f30daba 100644 --- a/Sources/iOSSimBackend/Util/VideoCommandSupport.swift +++ b/Sources/SimUseVideo/VideoCommandSupport.swift @@ -1,7 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import FBSimulatorControl -@preconcurrency import FBControlCore import AVFoundation import ImageIO import os @@ -10,87 +8,6 @@ import AppKit import SimUseCore #endif -/// Synchronous cancellation flag used by the video commands. -/// -/// The signal-to-finalise path is latency-critical: a process supervisor -/// that follows SIGTERM with a short-grace SIGKILL must reach -/// `recorder.finish()` before the kill lands, otherwise the mp4 trailer -/// (moov atom) is never written. An actor-backed flag adds ~10-100 ms of -/// scheduler jitter on every cancel/check; `OSAllocatedUnfairLock` keeps the -/// handler and loop pickup synchronous. -public final class CancellationFlag: Sendable { - private let value = OSAllocatedUnfairLock(initialState: false) - - public init() {} - - public func cancel() { - value.withLock { $0 = true } - } - - public func isCancelled() -> Bool { - value.withLock { $0 } - } -} - -/// A latch that transitions to "set" exactly once. Guarantees a -/// `CheckedContinuation` is resumed a single time when two callbacks race -/// (a completion handler versus a timeout). -public final class OnceFlag: Sendable { - private let fired = OSAllocatedUnfairLock(initialState: false) - - public init() {} - - /// Returns true the first time it is called, false thereafter. - public func trySet() -> Bool { - fired.withLock { fired in - guard !fired else { return false } - fired = true - return true - } - } -} - -/// Thread-safe, set-once error capture for callback-based FBFutures. -/// -/// The BGRA stream reports failures through `FBFuture` completion -/// callbacks on a background queue with no continuation to resume — -/// the stream runs until a signal arrives. The command loop polls this -/// box instead, so the first failure terminates streaming and surfaces -/// as a thrown error rather than being lost to stderr. -public final class FirstErrorBox: Sendable { - private let stored = OSAllocatedUnfairLock(initialState: nil) - - public init() {} - - /// Records `error` unless one is already recorded; later calls are no-ops. - public func set(_ error: Error) { - stored.withLock { if $0 == nil { $0 = error } } - } - - /// The first error recorded, or nil if none has been. - public var first: Error? { - stored.withLock { $0 } - } -} - -/// Sleep that wakes early when `flag` is cancelled. -/// -/// `Task.sleep` is not interruptible from a signal handler, so a vanilla -/// frame-pacing sleep adds up to one frame interval of latency to the -/// signal-to-finish path. Polling in short chunks bounds that latency. -public func cancellableSleep(seconds: TimeInterval, flag: CancellationFlag) async throws { - guard seconds > 0 else { return } - let chunkNanos: UInt64 = 5_000_000 // 5 ms - let totalNanos = UInt64(seconds * 1_000_000_000) - var elapsed: UInt64 = 0 - while elapsed < totalNanos { - if flag.isCancelled() { return } - let step = min(chunkNanos, totalNanos - elapsed) - try await Task.sleep(nanoseconds: step) - elapsed += step - } -} - /// Stop-path watchdog for the `record-video` command. /// /// After a stop signal arrives, the frame loop breaks and @@ -124,34 +41,6 @@ public enum RecordingFinishWatchdog { } } -public final class SignalObserver { - private var sources: [DispatchSourceSignal] = [] - private let signals: [Int32] - - public init(signals: [Int32], handler: @escaping @Sendable () -> Void) { - self.signals = signals - for signalValue in signals { - signal(signalValue, SIG_IGN) - let source = DispatchSource.makeSignalSource(signal: signalValue, queue: .main) - source.setEventHandler(handler: handler) - source.resume() - sources.append(source) - } - } - - public func invalidate() { - sources.forEach { $0.cancel() } - sources.removeAll() - for signalValue in signals { - signal(signalValue, SIG_DFL) - } - } - - deinit { - invalidate() - } -} - public enum VideoProcessingError: Error { case emptyScreenshot case failedToDecodeImage @@ -175,14 +64,6 @@ public struct VideoWriterStallError: Error, LocalizedError, Equatable { } public struct VideoFrameUtilities { - public static func captureScreenshotData(from simulator: FBSimulator) async throws -> Data { - let data = try await simulator.takeScreenshot(format: .png) - guard !data.isEmpty else { - throw VideoProcessingError.emptyScreenshot - } - return data - } - public static func makeCGImage(from data: Data) -> CGImage? { guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil diff --git a/Sources/SimUseVideo/VideoOutputFile.swift b/Sources/SimUseVideo/VideoOutputFile.swift new file mode 100644 index 00000000..24c4cbe1 --- /dev/null +++ b/Sources/SimUseVideo/VideoOutputFile.swift @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import SimUseCore + +/// Output-path resolution shared by every video-producing verb, +/// regardless of platform backend. +public enum VideoOutputFile { + /// Resolve the user-supplied `--output` argument into a concrete + /// MP4 file URL. Both the iOS backend and the cross-platform + /// forwarder's Android branch use the same path semantics. + public static func prepareOutputURL(output: String?) throws -> URL { + let fileManager = FileManager.default + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + + let providedPath = output?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedPath: String + if let providedPath, !providedPath.isEmpty { + resolvedPath = (providedPath as NSString).expandingTildeInPath + } else { + resolvedPath = "sim-use-video-\(formatter.string(from: Date())).mp4" + } + + let baseURL: URL + if resolvedPath.hasPrefix("/") { + baseURL = URL(fileURLWithPath: resolvedPath) + } else { + baseURL = URL(fileURLWithPath: fileManager.currentDirectoryPath).appendingPathComponent(resolvedPath) + } + + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: baseURL.path, isDirectory: &isDirectory), isDirectory.boolValue { + let filename = "sim-use-video-\(formatter.string(from: Date())).mp4" + let directoryURL = baseURL + if !fileManager.fileExists(atPath: directoryURL.path) { + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) + } + return directoryURL.appendingPathComponent(filename) + } + + let directoryURL = baseURL.deletingLastPathComponent() + if !fileManager.fileExists(atPath: directoryURL.path) { + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) + } + + if fileManager.fileExists(atPath: baseURL.path) { + var existingIsDirectory: ObjCBool = false + fileManager.fileExists(atPath: baseURL.path, isDirectory: &existingIsDirectory) + if existingIsDirectory.boolValue { + throw CLIError(errorDescription: "Output path \(baseURL.path) is a directory. Provide a file name or point to a different location.") + } + try fileManager.removeItem(at: baseURL) + } + + return baseURL + } +} diff --git a/Sources/iOSSimBackend/Util/VideoFrameUtilities+Simulator.swift b/Sources/iOSSimBackend/Util/VideoFrameUtilities+Simulator.swift new file mode 100644 index 00000000..67c9ef20 --- /dev/null +++ b/Sources/iOSSimBackend/Util/VideoFrameUtilities+Simulator.swift @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import FBSimulatorControl +@preconcurrency import FBControlCore +import SimUseVideo + +// The FB*-tied capture entry point for the shared frame utilities. +// Everything else in `VideoFrameUtilities` is platform-neutral and lives +// in SimUseVideo; this extension is the one piece that must stay inside +// iOSSimBackend's FB* dep cone. +extension VideoFrameUtilities { + public static func captureScreenshotData(from simulator: FBSimulator) async throws -> Data { + let data = try await simulator.takeScreenshot(format: .png) + guard !data.isEmpty else { + throw VideoProcessingError.emptyScreenshot + } + return data + } +} diff --git a/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift b/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift index fa528ff7..6a7aa717 100644 --- a/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift +++ b/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift @@ -6,6 +6,7 @@ import FBSimulatorControl @preconcurrency import FBControlCore import AVFoundation import SimUseCore +import SimUseVideo /// iOS Simulator backend for the `record-video` verb. Recording uses idb's /// native in-process file recorder (`FBSimulator.startRecording(toFile: @@ -108,7 +109,7 @@ public struct IOSSimRecordVideoCommand: SimUseExecutableCommand { throw CLIError(errorDescription: "Simulator \(trimmedUDID) is not booted. Current state: \(stateDescription)") } - let outputURL = try Self.prepareOutputURL(output: output) + let outputURL = try VideoOutputFile.prepareOutputURL(output: output) FileHandle.standardError.write(Data("Recording simulator \(targetSimulator.udid) to \(outputURL.path)\n".utf8)) FileHandle.standardError.write(Data("Press Ctrl+C to stop recording\n".utf8)) @@ -262,54 +263,4 @@ public struct IOSSimRecordVideoCommand: SimUseExecutableCommand { try await recorder.finish() } - - /// Resolve the user-supplied `--output` argument into a concrete - /// MP4 file URL. Public so the cross-platform forwarder's Android - /// branch can reuse the same path semantics. - public static func prepareOutputURL(output: String?) throws -> URL { - let fileManager = FileManager.default - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - - let providedPath = output?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedPath: String - if let providedPath, !providedPath.isEmpty { - resolvedPath = (providedPath as NSString).expandingTildeInPath - } else { - resolvedPath = "sim-use-video-\(formatter.string(from: Date())).mp4" - } - - let baseURL: URL - if resolvedPath.hasPrefix("/") { - baseURL = URL(fileURLWithPath: resolvedPath) - } else { - baseURL = URL(fileURLWithPath: fileManager.currentDirectoryPath).appendingPathComponent(resolvedPath) - } - - var isDirectory: ObjCBool = false - if fileManager.fileExists(atPath: baseURL.path, isDirectory: &isDirectory), isDirectory.boolValue { - let filename = "sim-use-video-\(formatter.string(from: Date())).mp4" - let directoryURL = baseURL - if !fileManager.fileExists(atPath: directoryURL.path) { - try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) - } - return directoryURL.appendingPathComponent(filename) - } - - let directoryURL = baseURL.deletingLastPathComponent() - if !fileManager.fileExists(atPath: directoryURL.path) { - try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) - } - - if fileManager.fileExists(atPath: baseURL.path) { - var existingIsDirectory: ObjCBool = false - fileManager.fileExists(atPath: baseURL.path, isDirectory: &existingIsDirectory) - if existingIsDirectory.boolValue { - throw CLIError(errorDescription: "Output path \(baseURL.path) is a directory. Provide a file name or point to a different location.") - } - try fileManager.removeItem(at: baseURL) - } - - return baseURL - } } diff --git a/Sources/iOSSimBackend/Verbs/IOSSimScreenshotCommand.swift b/Sources/iOSSimBackend/Verbs/IOSSimScreenshotCommand.swift index 37ef48bb..2ae943a4 100644 --- a/Sources/iOSSimBackend/Verbs/IOSSimScreenshotCommand.swift +++ b/Sources/iOSSimBackend/Verbs/IOSSimScreenshotCommand.swift @@ -5,6 +5,7 @@ import CompanionUtilities import FBSimulatorControl @preconcurrency import FBControlCore import SimUseCore +import SimUseVideo /// iOS Simulator backend for the `screenshot` verb. Mirrors the flag /// surface of top-level `Screenshot` and is also reachable directly diff --git a/Sources/iOSSimBackend/Verbs/IOSSimStreamVideoCommand.swift b/Sources/iOSSimBackend/Verbs/IOSSimStreamVideoCommand.swift index a6346d83..43109090 100644 --- a/Sources/iOSSimBackend/Verbs/IOSSimStreamVideoCommand.swift +++ b/Sources/iOSSimBackend/Verbs/IOSSimStreamVideoCommand.swift @@ -5,6 +5,7 @@ import CompanionUtilities import FBSimulatorControl @preconcurrency import FBControlCore import SimUseCore +import SimUseVideo /// iOS Simulator backend for the `stream-video` verb. iOS-only — no /// Android peer. The Android path used to fail-fast with a redirect diff --git a/Tests/AnnexBStreamParserTests.swift b/Tests/AnnexBStreamParserTests.swift index 9976fe00..7549ddf4 100644 --- a/Tests/AnnexBStreamParserTests.swift +++ b/Tests/AnnexBStreamParserTests.swift @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import Testing import Foundation -@testable import iOSSimBackend +@testable import SimUseVideo @Suite("AnnexBStreamParser NAL splitting and access-unit assembly") struct AnnexBStreamParserTests { diff --git a/Tests/FirstErrorBoxTests.swift b/Tests/FirstErrorBoxTests.swift index 67949500..77ef5a8e 100644 --- a/Tests/FirstErrorBoxTests.swift +++ b/Tests/FirstErrorBoxTests.swift @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation import Testing -@testable import iOSSimBackend +@testable import SimUseCore /// Pins the semantics `streamBGRA` relies on: the box is empty until an /// error is set, the first error wins (later sets are no-ops), and diff --git a/Tests/H264MuxingPipelineTests.swift b/Tests/H264MuxingPipelineTests.swift index fa191fd5..e93599ad 100644 --- a/Tests/H264MuxingPipelineTests.swift +++ b/Tests/H264MuxingPipelineTests.swift @@ -3,7 +3,7 @@ import Testing import Foundation import AVFoundation import os -@testable import iOSSimBackend +@testable import SimUseVideo @Suite("H264MuxingPipeline chunked ingest") struct H264MuxingPipelineTests { diff --git a/Tests/H264PassthroughRecorderTests.swift b/Tests/H264PassthroughRecorderTests.swift index 1a394a57..19ef8335 100644 --- a/Tests/H264PassthroughRecorderTests.swift +++ b/Tests/H264PassthroughRecorderTests.swift @@ -3,7 +3,7 @@ import Testing import Foundation import AVFoundation import CoreMedia -@testable import iOSSimBackend +@testable import SimUseVideo @Suite("H264PassthroughRecorder muxing") struct H264PassthroughRecorderTests { diff --git a/Tests/RecordingFinishWatchdogTests.swift b/Tests/RecordingFinishWatchdogTests.swift index 162fb0f7..25207719 100644 --- a/Tests/RecordingFinishWatchdogTests.swift +++ b/Tests/RecordingFinishWatchdogTests.swift @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -@testable import iOSSimBackend +@testable import SimUseVideo import Foundation import Testing diff --git a/Tests/VideoWriterStallTimeoutTests.swift b/Tests/VideoWriterStallTimeoutTests.swift index 8c8e591d..19291f99 100644 --- a/Tests/VideoWriterStallTimeoutTests.swift +++ b/Tests/VideoWriterStallTimeoutTests.swift @@ -2,7 +2,7 @@ import Testing import Foundation import AVFoundation -@testable import iOSSimBackend +@testable import SimUseVideo @Suite("H264StreamRecorder writer-readiness timeout policy") struct VideoWriterStallTimeoutTests { From 661818674170a59f05fc9fafb7030a77bb8b0b5d Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 29 Jul 2026 10:37:33 +0900 Subject: [PATCH 2/2] refactor: move Android record-video orchestration into AndroidBackend With the video plumbing extracted to SimUseVideo, the Android recording engine no longer needs to live inline in the SimUse executable target. Move it into AndroidRecordVideoCommand and register it, giving record-video the same three-surface layout as every other cross-platform verb (top-level forwarder + `ios` + `android`); the top-level Android branch now forwards to AndroidRecordVideoCommand.record(), symmetric to AndroidScreenshotCommand.performScreenshot. Flag validation is shared across all three surfaces via VideoRecordingOptions in SimUseVideo so the contract cannot drift. Flags and behavior are unchanged. Verified live against emulator-5554: both `sim-use android record-video` and top-level `sim-use record-video` produce a valid H.264 MP4 (ffprobe) with clean SIGTERM finalization. Co-Authored-By: Claude Fable 5 Signed-off-by: onevcat --- CHANGELOG.md | 1 + Package.swift | 2 + .../AndroidBackend/Verbs/AndroidCommand.swift | 1 + .../Verbs/AndroidRecordVideoCommand.swift | 461 ++++++++++++++++++ Sources/SimUse/Commands/RecordVideo.swift | 397 +-------------- .../SimUseVideo/VideoRecordingOptions.swift | 22 + .../Verbs/IOSSimRecordVideoCommand.swift | 16 +- .../AndroidRecordVideoArgumentTests.swift | 20 +- 8 files changed, 509 insertions(+), 411 deletions(-) create mode 100644 Sources/AndroidBackend/Verbs/AndroidRecordVideoCommand.swift create mode 100644 Sources/SimUseVideo/VideoRecordingOptions.swift rename Tests/{ => AndroidBackendTests}/AndroidRecordVideoArgumentTests.swift (70%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a2040d1..00c15180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `sim-use android record-video`: the Android recording engine moved out of the top-level forwarder into `AndroidBackend`, giving `record-video` the same three-surface layout as every other cross-platform verb (top-level + `ios` + `android`). Flags and behavior are unchanged; `sim-use record-video` with an Android serial routes to the same implementation. - `make e2e-matrix` (`scripts/e2e-matrix.sh`): iOS E2E across the supported host environments — Xcode 26.x / Xcode 27.x × Device Hub closed at boot (the classic Simulator.app workflow, legacy `indigo` HID) / Device Hub open at boot (CoreDevice `dtuhid`). One leg runs the full suite (default `x27-hub`, the primary workflow once Xcode 27 ships; `--full |all|none` to change), the rest run the new smoke tier (`test-runner.sh --smoke`: describe-ui, tap, type, scroll); legs whose Xcode is not installed are skipped. The package builds once on the xcode-select toolchain (`build_products/` is toolchain-locked); each leg swaps only the *runtime* Xcode — `SIM_USE_TEST_DEVELOPER_DIR`, which the test harness injects as `DEVELOPER_DIR` into every process the suites spawn — and boots a device whose iOS runtime matches the leg's Xcode generation (runtimes are system-wide, so "newest available" would cross-contaminate). Every leg is double-gated against silently green-running the wrong combination: dtuhidd process-tree checks before and after the suites, plus a `SIM_USE_DEBUG` probe that must report the matching HID transport-selection predicate. Per-leg logs and a combination-evidence file land in `.build/e2e-matrix//`. `test-runner.sh` now also accepts arbitrary repeatable `swift test --filter` patterns (previously one suite from a fixed whitelist) and resolves its default simulator by exact device name on the newest *available* runtime (previously a substring match that could pick an unavailable device or an "… Pro Max" sibling). - `swipe` / `touch --coordinate-space native|ui` (top-level and `ios` subcommands): explicit coordinates keep their device-native portrait default, and `ui` opts them into the visual space `describe-ui` prints — endpoints are orientation-calibrated per command (per batch for batch steps), so outline coordinates stay correct on a rotated device (#66). `touch` accepts `ui` in the atomic `--down --up` form only: a split touch could straddle a rotation and land its two halves in different spaces. A failed calibration degrades to native dispatch and says so via the `advisory` envelope key. On Android the flag is accepted and ignored — on the top-level commands and the `android swipe` / `android touch` direct commands alike (display coordinates already rotate with the UI). - The agent-eval suite can now pin exactly which sim-use binary a run evaluates: `make eval ARGS="-b "` / `scripts/eval.sh --sim-use ` / `run.py --sim-use ` (default remains whatever `sim-use` resolves to on PATH). The wrapper and runner print `sim-use under test: ()` up front and the report header records it, so a run can never silently exercise the wrong binary — and development builds under `.build/` can be evaluated directly. New repo skill `.claude/skills/run-evals/` orchestrates the whole flow for agents and contributors: environment prep (Device Hub closed, fixtures installed), binary selection, cost confirmation, and verdict triage; `e2e/agent-evals/README.md` documents the new prereqs and flags. diff --git a/Package.swift b/Package.swift index 3c20ffe2..24158db7 100644 --- a/Package.swift +++ b/Package.swift @@ -112,6 +112,7 @@ let package = Package( name: "SimUseVideo", dependencies: [ "SimUseCore", + .product(name: "ArgumentParser", package: "swift-argument-parser"), ], path: "Sources/SimUseVideo" ), @@ -136,6 +137,7 @@ let package = Package( name: "AndroidBackend", dependencies: [ "SimUseCore", + "SimUseVideo", .product(name: "ArgumentParser", package: "swift-argument-parser"), ], path: "Sources/AndroidBackend", diff --git a/Sources/AndroidBackend/Verbs/AndroidCommand.swift b/Sources/AndroidBackend/Verbs/AndroidCommand.swift index ed835b5f..e95d4040 100644 --- a/Sources/AndroidBackend/Verbs/AndroidCommand.swift +++ b/Sources/AndroidBackend/Verbs/AndroidCommand.swift @@ -28,6 +28,7 @@ public struct AndroidCommand: ParsableCommand { AndroidScrollCommand.self, AndroidButtonCommand.self, AndroidScreenshotCommand.self, + AndroidRecordVideoCommand.self, AndroidTypeCommand.self, ] ) diff --git a/Sources/AndroidBackend/Verbs/AndroidRecordVideoCommand.swift b/Sources/AndroidBackend/Verbs/AndroidRecordVideoCommand.swift new file mode 100644 index 00000000..1e19d07a --- /dev/null +++ b/Sources/AndroidBackend/Verbs/AndroidRecordVideoCommand.swift @@ -0,0 +1,461 @@ +// SPDX-License-Identifier: Apache-2.0 +import ArgumentParser +import Foundation +import AVFoundation +import CoreMedia +import SimUseCore +import SimUseVideo + +/// `sim-use android record-video` — record the device display to an MP4. +/// +/// Native capture pipes `adb exec-out screenrecord --output-format=h264 -` +/// into the shared H.264 → MP4 passthrough muxer (`H264MuxingPipeline`) +/// for variable-frame-rate recording, falling back to a legacy +/// `screencap`-per-frame loop (≈7–8 FPS) only if screenrecord cannot +/// start. +/// +/// The bridge `/screenshot` path is NOT used: it goes through +/// `AccessibilityService.takeScreenshot`, which the Android framework +/// rate-limits to ~2 FPS — unusable for video. +public struct AndroidRecordVideoCommand: SimUseExecutableCommand { + public static let configuration = CommandConfiguration( + commandName: "record-video", + abstract: "Record the Android device display to an MP4 file using H.264 encoding" + ) + + public struct ExecutionResult: Codable { + public let path: String + public init(path: String) { + self.path = path + } + } + + @OptionGroup public var device: AndroidDeviceOptions + + @Option(help: "Frames per second (1-60). Ignored by native screenrecord capture (records at the device's variable frame rate); paces the screencap fallback (default: 10).") + public var fps: Int? + + @Option(help: "Quality factor (1-100) controlling bitrate (default: 80)") + public var quality: Int = 80 + + @Option(help: "Scale factor (0.1-1.0, default: 1.0)") + public var scale: Double = 1.0 + + @Option(help: "Output MP4 file path. Defaults to sim-use-video-.mp4 in the current directory.") + public var output: String? + + @Flag(name: .customLong("json"), help: "Emit the unified `{ok, data: {path}}` envelope on success. Mirrors the cross-platform `record-video --json` shape.") + public var jsonOutput: Bool = false + + public init() {} + + public mutating func resolveDeferredArguments() throws { + try device.resolve() + } + + public var simulatorUDIDForDaemon: String? { device.resolved } + + /// Long-running, signal-driven, and resolves `--output` against the + /// caller's cwd — none of which survive the daemon hop. Mirrors the + /// top-level `RecordVideo` posture. + public var daemonBypass: Bool { true } + + public func validate() throws { + try VideoRecordingOptions.validate(fps: fps, quality: quality, scale: scale) + } + + public func format(_ result: ExecutionResult) -> CommandOutput { + CommandOutput( + stdout: result.path + "\n", + stderr: "Recording saved to \(result.path)\n" + ) + } + + public func execute() async throws -> ExecutionResult { + let outputURL = try await Self.record( + serial: device.resolved, + output: output, + fps: fps, + quality: quality, + scale: scale + ) + return ExecutionResult(path: outputURL.path) + } + + // MARK: - Shared orchestration + + /// Raised only when `adb screenrecord` cannot produce an H.264 stream + /// (unsupported args, encoder unavailable). Triggers the legacy + /// screencap-frame fallback; mid-recording failures propagate as-is. + private struct ScreenrecordUnavailableError: Error { + let underlying: String + } + + /// Reusable Android recording entry point: runs until SIGINT/SIGTERM, + /// then finalizes the MP4 and returns its URL. The top-level + /// cross-platform `RecordVideo` forwards here for Android UDIDs so + /// both `sim-use android record-video` and `sim-use record-video` go + /// through one body — symmetric to + /// `AndroidScreenshotCommand.performScreenshot`. + public static func record( + serial: String, + output: String?, + fps: Int?, + quality: Int, + scale: Double + ) async throws -> URL { + let adb = Adb() + try assertAdbDeviceOnline(adb: adb, serial: serial) + + let outputURL = try VideoOutputFile.prepareOutputURL(output: output) + FileHandle.standardError.write(Data("Recording Android device \(serial) to \(outputURL.path)\n".utf8)) + FileHandle.standardError.write(Data("Press Ctrl+C to stop recording\n".utf8)) + + let cancellationFlag = CancellationFlag() + let recordingFinished = CancellationFlag() + let signalObserver = SignalObserver(signals: [SIGINT, SIGTERM]) { + cancellationFlag.cancel() + RecordingFinishWatchdog.arm(recordingFinished: recordingFinished) + } + defer { signalObserver.invalidate() } + + do { + try await recordVideoAndroidStream( + adb: adb, + serial: serial, + outputURL: outputURL, + fps: fps, + quality: quality, + scale: scale, + cancellationFlag: cancellationFlag + ) + recordingFinished.cancel() + return outputURL + } catch let unavailable as ScreenrecordUnavailableError { + FileHandle.standardError.write(Data("warning: screenrecord unavailable (\(unavailable.underlying)); falling back to screencap frames\n".utf8)) + do { + try await recordVideoAndroidScreencapLegacy( + adb: adb, + serial: serial, + outputURL: outputURL, + fps: fps ?? 10, + quality: quality, + scale: scale, + cancellationFlag: cancellationFlag + ) + recordingFinished.cancel() + return outputURL + } catch { + recordingFinished.cancel() + throw CLIError(errorDescription: "Failed to record video: \(error.localizedDescription)") + } + } catch { + recordingFinished.cancel() + throw CLIError(errorDescription: "Failed to record video: \(error.localizedDescription)") + } + } + + private static func assertAdbDeviceOnline(adb: Adb, serial: String) throws { + let devices: [Adb.Device] + do { + devices = try adb.devices() + } catch { + throw CLIError(errorDescription: "Failed to query adb devices: \(error.localizedDescription)") + } + guard let match = devices.first(where: { $0.serial == serial }) else { + throw CLIError(errorDescription: "Android device \(serial) not found. Run `adb devices` to verify it is attached.") + } + guard match.isOnline else { + throw CLIError(errorDescription: "Android device \(serial) is \(match.state), not 'device'. Check authorization / emulator state.") + } + } + + /// Native capture: `adb exec-out screenrecord --output-format=h264 -` + /// streamed into the shared muxer. On API < 34 `screenrecord` self-limits + /// to 180 s per invocation, so we restart it in a loop and keep feeding + /// the same muxer — the single host clock keeps PTS continuous across the + /// ~100–300 ms restart gap. + private static func recordVideoAndroidStream( + adb: Adb, + serial: String, + outputURL: URL, + fps: Int?, + quality: Int, + scale: Double, + cancellationFlag: CancellationFlag + ) async throws { + if fps != nil { + FileHandle.standardError.write(Data("note: --fps is ignored on Android (screenrecord records at native variable frame rate)\n".utf8)) + } + + let sdk = detectSDK(adb: adb, serial: serial) + // Detect the display size unconditionally so --quality maps to a + // bitrate even at the default scale — only the --size *argument* is + // scale-gated below. If `wm size` is unparseable, bitrate is omitted + // (screenrecord's own 20 Mbps default) rather than failing the recording. + let baseSize = detectSize(adb: adb, serial: serial) + let recordingSize = scale < 1.0 ? baseSize.map { scaledSize($0, scale: scale) } : nil + let bitrateSize = recordingSize ?? baseSize + let bitrate = bitrateSize.map { H264StreamRecorder.estimateBitrate(width: $0.width, height: $0.height, fps: 30, quality: quality) } + let arguments = screenrecordArguments(serial: serial, sdk: sdk, bitrate: bitrate, size: recordingSize) + + let recorder = try H264PassthroughRecorder(outputURL: outputURL) + var recorderFinalized = false + defer { if !recorderFinalized { recorder.invalidate() } } + + let fatalBox = FirstErrorBox() + let pipeline = H264MuxingPipeline(recorder: recorder, onFatalError: { error in + fatalBox.set(error) + cancellationFlag.cancel() + }) + + var firstSegment = true + var disconnected = false + + segmentLoop: while true { + if Task.isCancelled || cancellationFlag.isCancelled() || fatalBox.first != nil { break } + + pipeline.resetParserForNewSegment() + let process = AdbStreamingProcess( + adbPath: adb.binaryPath, + arguments: arguments, + onStdout: { pipeline.ingest($0) } + ) + do { + try process.start() + } catch { + if firstSegment { + throw ScreenrecordUnavailableError(underlying: error.localizedDescription) + } + throw error + } + firstSegment = false + + let segmentStartBytes = process.stdoutByteCount + while process.isRunning { + if Task.isCancelled || cancellationFlag.isCancelled() || fatalBox.first != nil { break } + try? await cancellableSleep(seconds: 0.05, flag: cancellationFlag) + } + + let stopping = Task.isCancelled || cancellationFlag.isCancelled() || fatalBox.first != nil + if stopping { + process.interrupt() + process.waitForExit(timeout: 2) + break + } + + // The process exited on its own — either the API-level time limit + // was reached (restart to continue) or the device stopped feeding. + let exitCode = process.waitForExit(timeout: 2) + let bytesThisSegment = process.stdoutByteCount - segmentStartBytes + if bytesThisSegment == 0 { + if !pipeline.firstFrameReceived { + let exitDescription = exitCode.map(String.init) ?? "timeout" + throw ScreenrecordUnavailableError( + underlying: "screenrecord produced no output (exit \(exitDescription)): \(process.collectedStderr.trimmingCharacters(in: .whitespacesAndNewlines))" + ) + } + disconnected = true + break segmentLoop + } + FileHandle.standardError.write(Data("screenrecord segment ended (Android time limit); restarting (~100-300ms gap)\n".utf8)) + } + + pipeline.finishIngest() + do { + try await recorder.finish(stopHostTime: ProcessInfo.processInfo.systemUptime) + recorderFinalized = true + } catch { + if let fatal = fatalBox.first { throw fatal } + throw error + } + + if let fatal = fatalBox.first { throw fatal } + if disconnected { + throw CLIError(errorDescription: "Android device stopped producing frames during recording; partial recording saved to \(outputURL.path)") + } + } + + private static func detectSDK(adb: Adb, serial: String) -> Int { + guard let result = try? adb.shell(serial: serial, args: ["getprop", "ro.build.version.sdk"]) else { + return 30 + } + return Int(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 30 + } + + private static func detectSize(adb: Adb, serial: String) -> (width: Int, height: Int)? { + guard let result = try? adb.shell(serial: serial, args: ["wm", "size"]) else { + return nil + } + return parseWMSize(result.stdout) + } + + /// Scale a detected display size, rounding down to even dimensions + /// (required by most H.264 encoders). + static func scaledSize(_ size: (width: Int, height: Int), scale: Double) -> (width: Int, height: Int) { + let width = max(2, Int(Double(size.width) * scale)) + let height = max(2, Int(Double(size.height) * scale)) + return (width - (width % 2), height - (height % 2)) + } + + /// Parse `adb shell wm size` output. Prefers the `Override size:` line + /// (an active resolution override) over `Physical size:`. + static func parseWMSize(_ output: String) -> (width: Int, height: Int)? { + func size(from line: Substring) -> (Int, Int)? { + guard let colon = line.lastIndex(of: ":") else { return nil } + let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) + let parts = value.split(separator: "x") + guard parts.count == 2, let w = Int(parts[0]), let h = Int(parts[1]) else { return nil } + return (w, h) + } + let lines = output.split(separator: "\n") + if let override = lines.first(where: { $0.contains("Override size:") }), let parsed = size(from: override) { + return parsed + } + if let physical = lines.first(where: { $0.contains("Physical size:") }), let parsed = size(from: physical) { + return parsed + } + return nil + } + + /// Build the `adb screenrecord` argument vector. `--time-limit 0` + /// (unlimited) is only valid on API ≥ 34; older devices hard-cap at 180 s, + /// which the segment loop handles by restarting. + static func screenrecordArguments(serial: String, sdk: Int, bitrate: Int?, size: (width: Int, height: Int)?) -> [String] { + var arguments = ["-s", serial, "exec-out", "screenrecord", "--output-format=h264"] + if sdk >= 34 { + arguments.append(contentsOf: ["--time-limit", "0"]) + } + if let bitrate { + arguments.append(contentsOf: ["--bit-rate", "\(bitrate)"]) + } + if let size { + arguments.append(contentsOf: ["--size", "\(size.width)x\(size.height)"]) + } + arguments.append("-") + return arguments + } + + /// Legacy screencap-per-frame recorder, retained as an automatic fallback + /// for when `screenrecord --output-format=h264` is unavailable. Caps + /// around 7–8 FPS on a typical emulator (PNG transfer dominates). + private static func recordVideoAndroidScreencapLegacy( + adb: Adb, + serial: String, + outputURL: URL, + fps: Int, + quality: Int, + scale: Double, + cancellationFlag: CancellationFlag + ) async throws { + let adbPath = adb.binaryPath + + let initialFrameData = try captureAndroidScreencap(adbPath: adbPath, serial: serial) + guard let initialImage = VideoFrameUtilities.makeCGImage(from: initialFrameData) else { + throw CLIError(errorDescription: "Failed to decode initial Android screencap PNG") + } + + let dimensions = VideoFrameUtilities.computeDimensions(for: initialImage, scale: scale) + let recorder = try H264StreamRecorder( + outputURL: outputURL, + width: dimensions.width, + height: dimensions.height, + fps: fps, + quality: quality + ) + defer { recorder.invalidate() } + + let frameInterval = 1.0 / Double(fps) + var frameCount: Int64 = 1 + var lastLogFrame: Int64 = 0 + let startTime = Date() + var lastPresentationTime = CMTime.zero + + try recorder.append(image: initialImage, presentationTime: .zero) + let writerStartTime = Date() + + while true { + if Task.isCancelled || cancellationFlag.isCancelled() { + break + } + + let frameStart = Date() + + do { + let frameData = try captureAndroidScreencap(adbPath: adbPath, serial: serial) + guard let cgImage = VideoFrameUtilities.makeCGImage(from: frameData) else { + FileHandle.standardError.write(Data("Unable to decode screencap frame\n".utf8)) + continue + } + + let now = Date() + var presentationTime = CMTime(seconds: now.timeIntervalSince(writerStartTime), preferredTimescale: 600) + if presentationTime <= lastPresentationTime { + presentationTime = CMTimeAdd(lastPresentationTime, CMTime(value: 1, timescale: 600)) + } + + try recorder.append(image: cgImage, presentationTime: presentationTime) + lastPresentationTime = presentationTime + frameCount += 1 + + if frameCount - lastLogFrame >= Int64(fps) { + lastLogFrame = frameCount + let elapsed = Date().timeIntervalSince(startTime) + let actualFPS = Double(frameCount) / max(elapsed, 0.0001) + FileHandle.standardError.write(Data(String(format: "Captured %lld frames (%.1f FPS actual)\n", frameCount, actualFPS).utf8)) + } + } catch let error as VideoWriterStallError { + // A stalled writer does not recover; abort the recording + // instead of re-logging the stall once per timeout forever. + throw error + } catch { + FileHandle.standardError.write(Data("Error capturing frame: \(error.localizedDescription)\n".utf8)) + } + + let elapsed = Date().timeIntervalSince(frameStart) + let sleepTime = frameInterval - elapsed + if sleepTime > 0 { + try await cancellableSleep(seconds: sleepTime, flag: cancellationFlag) + } + } + + try await recorder.finish() + } + + /// `adb -s exec-out screencap -p` → PNG bytes. Uses a fresh + /// `Process` per frame; the fork cost (~10 ms) is dwarfed by screencap + /// itself (~120 ms median on a typical emulator) so a daemon-style + /// persistent shell is unnecessary at this stage. Binary-safe: we read + /// the pipe as raw `Data`, not via the `String`-typed `Adb.run()`. + /// + /// TODO(persistent-screencap-pipe): if frame budget tightens (e.g. + /// a higher-FPS recording mode), replace this fork-per-frame with a + /// single long-lived `adb shell` that pipes `screencap -p` repeatedly + /// — amortises the ~10 ms fork across every frame. Out of scope + /// while the screencap itself is the dominant cost; raising this + /// TODO is the cheaper performance lever to reach for first when + /// the frame loop becomes the bottleneck. + private static func captureAndroidScreencap(adbPath: String, serial: String) throws -> Data { + let process = Process() + process.executableURL = URL(fileURLWithPath: adbPath) + process.arguments = ["-s", serial, "exec-out", "screencap", "-p"] + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + try process.run() + let pngData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let errMessage = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "unknown error" + throw CLIError(errorDescription: "adb screencap exited \(process.terminationStatus): \(errMessage)") + } + guard !pngData.isEmpty else { + throw CLIError(errorDescription: "adb screencap returned empty output") + } + return pngData + } +} diff --git a/Sources/SimUse/Commands/RecordVideo.swift b/Sources/SimUse/Commands/RecordVideo.swift index 043819b1..5aa79f4e 100644 --- a/Sources/SimUse/Commands/RecordVideo.swift +++ b/Sources/SimUse/Commands/RecordVideo.swift @@ -1,9 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 import ArgumentParser import Foundation -import FBSimulatorControl -@preconcurrency import FBControlCore -import AVFoundation import SimUseCore import SimUseVideo import AndroidBackend @@ -14,17 +11,9 @@ import iOSSimBackend /// /// * `IOSSimRecordVideoCommand.execute()` for iOS Simulator UDIDs /// (which drives `FBSimulatorVideoStream` eager H.264 at `--fps`). -/// * an inline Android orchestrator that streams `adb exec-out -/// screenrecord --output-format=h264` into the shared H.264 → -/// MP4 passthrough muxer (`H264MuxingPipeline`). -/// -/// The Android branch lives inline (rather than in an -/// `AndroidRecordVideoCommand` peer) because it cross-cuts -/// AndroidBackend (for `Adb` / `AdbStreamingProcess`) and iOSSimBackend -/// (for the AVFoundation muxer). Only SimUse — the executable target — -/// depends on both modules, so this is the only place where the -/// orchestration can live without dragging iOSSimBackend into -/// AndroidBackend's dep cone. +/// * `AndroidRecordVideoCommand.record()` for adb serials (which +/// streams `adb exec-out screenrecord --output-format=h264` into +/// the shared H.264 → MP4 passthrough muxer). struct RecordVideo: SimUseExecutableCommand { typealias ExecutionResult = IOSSimRecordVideoCommand.ExecutionResult @@ -67,7 +56,7 @@ struct RecordVideo: SimUseExecutableCommand { } func validate() throws { - try IOSSimRecordVideoCommand.validateOptions(fps: fps, quality: quality, scale: scale) + try VideoRecordingOptions.validate(fps: fps, quality: quality, scale: scale) } func execute() async throws -> ExecutionResult { @@ -99,378 +88,14 @@ struct RecordVideo: SimUseExecutableCommand { return sub } - // MARK: - Android - - /// Raised only when `adb screenrecord` cannot produce an H.264 stream - /// (unsupported args, encoder unavailable). Triggers the legacy - /// screencap-frame fallback; mid-recording failures propagate as-is. - private struct ScreenrecordUnavailableError: Error { - let underlying: String - } - - /// Android dispatch: pipes `adb exec-out screenrecord - /// --output-format=h264 -` into the shared H.264 muxer for native, - /// variable-frame-rate capture. Falls back to the legacy - /// `screencap`-per-frame loop (≈7–8 FPS) only if screenrecord cannot - /// start. - /// - /// The bridge `/screenshot` path is NOT used: it goes through - /// `AccessibilityService.takeScreenshot`, which the Android framework - /// rate-limits to ~2 FPS — unusable for video. private func executeAndroid() async throws -> ExecutionResult { - let adb = Adb() - let serial = device.resolved - try assertAdbDeviceOnline(adb: adb, serial: serial) - - let outputURL = try VideoOutputFile.prepareOutputURL(output: output) - FileHandle.standardError.write(Data("Recording Android device \(serial) to \(outputURL.path)\n".utf8)) - FileHandle.standardError.write(Data("Press Ctrl+C to stop recording\n".utf8)) - - let cancellationFlag = CancellationFlag() - let recordingFinished = CancellationFlag() - let signalObserver = SignalObserver(signals: [SIGINT, SIGTERM]) { - cancellationFlag.cancel() - RecordingFinishWatchdog.arm(recordingFinished: recordingFinished) - } - defer { signalObserver.invalidate() } - - do { - try await recordVideoAndroidStream( - adb: adb, - serial: serial, - outputURL: outputURL, - fps: fps, - quality: quality, - scale: scale, - cancellationFlag: cancellationFlag - ) - recordingFinished.cancel() - return ExecutionResult(path: outputURL.path) - } catch let unavailable as ScreenrecordUnavailableError { - FileHandle.standardError.write(Data("warning: screenrecord unavailable (\(unavailable.underlying)); falling back to screencap frames\n".utf8)) - do { - try await recordVideoAndroidScreencapLegacy( - adb: adb, - serial: serial, - outputURL: outputURL, - fps: fps ?? 10, - quality: quality, - scale: scale, - cancellationFlag: cancellationFlag - ) - recordingFinished.cancel() - return ExecutionResult(path: outputURL.path) - } catch { - recordingFinished.cancel() - throw CLIError(errorDescription: "Failed to record video: \(error.localizedDescription)") - } - } catch { - recordingFinished.cancel() - throw CLIError(errorDescription: "Failed to record video: \(error.localizedDescription)") - } - } - - private func assertAdbDeviceOnline(adb: Adb, serial: String) throws { - let devices: [Adb.Device] - do { - devices = try adb.devices() - } catch { - throw CLIError(errorDescription: "Failed to query adb devices: \(error.localizedDescription)") - } - guard let match = devices.first(where: { $0.serial == serial }) else { - throw CLIError(errorDescription: "Android device \(serial) not found. Run `adb devices` to verify it is attached.") - } - guard match.isOnline else { - throw CLIError(errorDescription: "Android device \(serial) is \(match.state), not 'device'. Check authorization / emulator state.") - } - } - - /// Native capture: `adb exec-out screenrecord --output-format=h264 -` - /// streamed into the shared muxer. On API < 34 `screenrecord` self-limits - /// to 180 s per invocation, so we restart it in a loop and keep feeding - /// the same muxer — the single host clock keeps PTS continuous across the - /// ~100–300 ms restart gap. - private func recordVideoAndroidStream( - adb: Adb, - serial: String, - outputURL: URL, - fps: Int?, - quality: Int, - scale: Double, - cancellationFlag: CancellationFlag - ) async throws { - if fps != nil { - FileHandle.standardError.write(Data("note: --fps is ignored on Android (screenrecord records at native variable frame rate)\n".utf8)) - } - - let sdk = Self.detectSDK(adb: adb, serial: serial) - // Detect the display size unconditionally so --quality maps to a - // bitrate even at the default scale — only the --size *argument* is - // scale-gated below. If `wm size` is unparseable, bitrate is omitted - // (screenrecord's own 20 Mbps default) rather than failing the recording. - let baseSize = Self.detectSize(adb: adb, serial: serial) - let recordingSize = scale < 1.0 ? baseSize.map { Self.scaledSize($0, scale: scale) } : nil - let bitrateSize = recordingSize ?? baseSize - let bitrate = bitrateSize.map { H264StreamRecorder.estimateBitrate(width: $0.width, height: $0.height, fps: 30, quality: quality) } - let arguments = Self.screenrecordArguments(serial: serial, sdk: sdk, bitrate: bitrate, size: recordingSize) - - let recorder = try H264PassthroughRecorder(outputURL: outputURL) - var recorderFinalized = false - defer { if !recorderFinalized { recorder.invalidate() } } - - let fatalBox = FirstErrorBox() - let pipeline = H264MuxingPipeline(recorder: recorder, onFatalError: { error in - fatalBox.set(error) - cancellationFlag.cancel() - }) - - var firstSegment = true - var disconnected = false - - segmentLoop: while true { - if Task.isCancelled || cancellationFlag.isCancelled() || fatalBox.first != nil { break } - - pipeline.resetParserForNewSegment() - let process = AdbStreamingProcess( - adbPath: adb.binaryPath, - arguments: arguments, - onStdout: { pipeline.ingest($0) } - ) - do { - try process.start() - } catch { - if firstSegment { - throw ScreenrecordUnavailableError(underlying: error.localizedDescription) - } - throw error - } - firstSegment = false - - let segmentStartBytes = process.stdoutByteCount - while process.isRunning { - if Task.isCancelled || cancellationFlag.isCancelled() || fatalBox.first != nil { break } - try? await cancellableSleep(seconds: 0.05, flag: cancellationFlag) - } - - let stopping = Task.isCancelled || cancellationFlag.isCancelled() || fatalBox.first != nil - if stopping { - process.interrupt() - process.waitForExit(timeout: 2) - break - } - - // The process exited on its own — either the API-level time limit - // was reached (restart to continue) or the device stopped feeding. - let exitCode = process.waitForExit(timeout: 2) - let bytesThisSegment = process.stdoutByteCount - segmentStartBytes - if bytesThisSegment == 0 { - if !pipeline.firstFrameReceived { - let exitDescription = exitCode.map(String.init) ?? "timeout" - throw ScreenrecordUnavailableError( - underlying: "screenrecord produced no output (exit \(exitDescription)): \(process.collectedStderr.trimmingCharacters(in: .whitespacesAndNewlines))" - ) - } - disconnected = true - break segmentLoop - } - FileHandle.standardError.write(Data("screenrecord segment ended (Android time limit); restarting (~100-300ms gap)\n".utf8)) - } - - pipeline.finishIngest() - do { - try await recorder.finish(stopHostTime: ProcessInfo.processInfo.systemUptime) - recorderFinalized = true - } catch { - if let fatal = fatalBox.first { throw fatal } - throw error - } - - if let fatal = fatalBox.first { throw fatal } - if disconnected { - throw CLIError(errorDescription: "Android device stopped producing frames during recording; partial recording saved to \(outputURL.path)") - } - } - - private static func detectSDK(adb: Adb, serial: String) -> Int { - guard let result = try? adb.shell(serial: serial, args: ["getprop", "ro.build.version.sdk"]) else { - return 30 - } - return Int(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 30 - } - - private static func detectSize(adb: Adb, serial: String) -> (width: Int, height: Int)? { - guard let result = try? adb.shell(serial: serial, args: ["wm", "size"]) else { - return nil - } - return parseWMSize(result.stdout) - } - - /// Scale a detected display size, rounding down to even dimensions - /// (required by most H.264 encoders). - static func scaledSize(_ size: (width: Int, height: Int), scale: Double) -> (width: Int, height: Int) { - let width = max(2, Int(Double(size.width) * scale)) - let height = max(2, Int(Double(size.height) * scale)) - return (width - (width % 2), height - (height % 2)) - } - - /// Parse `adb shell wm size` output. Prefers the `Override size:` line - /// (an active resolution override) over `Physical size:`. - static func parseWMSize(_ output: String) -> (width: Int, height: Int)? { - func size(from line: Substring) -> (Int, Int)? { - guard let colon = line.lastIndex(of: ":") else { return nil } - let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) - let parts = value.split(separator: "x") - guard parts.count == 2, let w = Int(parts[0]), let h = Int(parts[1]) else { return nil } - return (w, h) - } - let lines = output.split(separator: "\n") - if let override = lines.first(where: { $0.contains("Override size:") }), let parsed = size(from: override) { - return parsed - } - if let physical = lines.first(where: { $0.contains("Physical size:") }), let parsed = size(from: physical) { - return parsed - } - return nil - } - - /// Build the `adb screenrecord` argument vector. `--time-limit 0` - /// (unlimited) is only valid on API ≥ 34; older devices hard-cap at 180 s, - /// which the segment loop handles by restarting. - static func screenrecordArguments(serial: String, sdk: Int, bitrate: Int?, size: (width: Int, height: Int)?) -> [String] { - var arguments = ["-s", serial, "exec-out", "screenrecord", "--output-format=h264"] - if sdk >= 34 { - arguments.append(contentsOf: ["--time-limit", "0"]) - } - if let bitrate { - arguments.append(contentsOf: ["--bit-rate", "\(bitrate)"]) - } - if let size { - arguments.append(contentsOf: ["--size", "\(size.width)x\(size.height)"]) - } - arguments.append("-") - return arguments - } - - /// Legacy screencap-per-frame recorder, retained as an automatic fallback - /// for when `screenrecord --output-format=h264` is unavailable. Caps - /// around 7–8 FPS on a typical emulator (PNG transfer dominates). - private func recordVideoAndroidScreencapLegacy( - adb: Adb, - serial: String, - outputURL: URL, - fps: Int, - quality: Int, - scale: Double, - cancellationFlag: CancellationFlag - ) async throws { - let adbPath = adb.binaryPath - - let initialFrameData = try captureAndroidScreencap(adbPath: adbPath, serial: serial) - guard let initialImage = VideoFrameUtilities.makeCGImage(from: initialFrameData) else { - throw CLIError(errorDescription: "Failed to decode initial Android screencap PNG") - } - - let dimensions = VideoFrameUtilities.computeDimensions(for: initialImage, scale: scale) - let recorder = try H264StreamRecorder( - outputURL: outputURL, - width: dimensions.width, - height: dimensions.height, + let outputURL = try await AndroidRecordVideoCommand.record( + serial: device.resolved, + output: output, fps: fps, - quality: quality + quality: quality, + scale: scale ) - defer { recorder.invalidate() } - - let frameInterval = 1.0 / Double(fps) - var frameCount: Int64 = 1 - var lastLogFrame: Int64 = 0 - let startTime = Date() - var lastPresentationTime = CMTime.zero - - try recorder.append(image: initialImage, presentationTime: .zero) - let writerStartTime = Date() - - while true { - if Task.isCancelled || cancellationFlag.isCancelled() { - break - } - - let frameStart = Date() - - do { - let frameData = try captureAndroidScreencap(adbPath: adbPath, serial: serial) - guard let cgImage = VideoFrameUtilities.makeCGImage(from: frameData) else { - FileHandle.standardError.write(Data("Unable to decode screencap frame\n".utf8)) - continue - } - - let now = Date() - var presentationTime = CMTime(seconds: now.timeIntervalSince(writerStartTime), preferredTimescale: 600) - if presentationTime <= lastPresentationTime { - presentationTime = CMTimeAdd(lastPresentationTime, CMTime(value: 1, timescale: 600)) - } - - try recorder.append(image: cgImage, presentationTime: presentationTime) - lastPresentationTime = presentationTime - frameCount += 1 - - if frameCount - lastLogFrame >= Int64(fps) { - lastLogFrame = frameCount - let elapsed = Date().timeIntervalSince(startTime) - let actualFPS = Double(frameCount) / max(elapsed, 0.0001) - FileHandle.standardError.write(Data(String(format: "Captured %lld frames (%.1f FPS actual)\n", frameCount, actualFPS).utf8)) - } - } catch let error as VideoWriterStallError { - // A stalled writer does not recover; abort the recording - // instead of re-logging the stall once per timeout forever. - throw error - } catch { - FileHandle.standardError.write(Data("Error capturing frame: \(error.localizedDescription)\n".utf8)) - } - - let elapsed = Date().timeIntervalSince(frameStart) - let sleepTime = frameInterval - elapsed - if sleepTime > 0 { - try await cancellableSleep(seconds: sleepTime, flag: cancellationFlag) - } - } - - try await recorder.finish() - } - - /// `adb -s exec-out screencap -p` → PNG bytes. Uses a fresh - /// `Process` per frame; the fork cost (~10 ms) is dwarfed by screencap - /// itself (~120 ms median on a typical emulator) so a daemon-style - /// persistent shell is unnecessary at this stage. Binary-safe: we read - /// the pipe as raw `Data`, not via the `String`-typed `Adb.run()`. - /// - /// TODO(persistent-screencap-pipe): if frame budget tightens (e.g. - /// a higher-FPS recording mode), replace this fork-per-frame with a - /// single long-lived `adb shell` that pipes `screencap -p` repeatedly - /// — amortises the ~10 ms fork across every frame. Out of scope - /// while the screencap itself is the dominant cost; raising this - /// TODO is the cheaper performance lever to reach for first when - /// the frame loop becomes the bottleneck. - private func captureAndroidScreencap(adbPath: String, serial: String) throws -> Data { - let process = Process() - process.executableURL = URL(fileURLWithPath: adbPath) - process.arguments = ["-s", serial, "exec-out", "screencap", "-p"] - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - try process.run() - let pngData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - - guard process.terminationStatus == 0 else { - let errMessage = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "unknown error" - throw CLIError(errorDescription: "adb screencap exited \(process.terminationStatus): \(errMessage)") - } - guard !pngData.isEmpty else { - throw CLIError(errorDescription: "adb screencap returned empty output") - } - return pngData + return ExecutionResult(path: outputURL.path) } -} \ No newline at end of file +} diff --git a/Sources/SimUseVideo/VideoRecordingOptions.swift b/Sources/SimUseVideo/VideoRecordingOptions.swift new file mode 100644 index 00000000..eda48995 --- /dev/null +++ b/Sources/SimUseVideo/VideoRecordingOptions.swift @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +import ArgumentParser + +/// Flag validation shared by every surface of the cross-platform +/// `record-video` verb (top-level forwarder, `ios record-video`, +/// `android record-video`) so the contract cannot drift between +/// platforms. +public enum VideoRecordingOptions { + public static func validate(fps: Int?, quality: Int, scale: Double) throws { + if let fps { + guard fps >= 1 && fps <= 60 else { + throw ValidationError("FPS must be between 1 and 60") + } + } + guard quality >= 1 && quality <= 100 else { + throw ValidationError("Quality must be between 1 and 100") + } + guard scale >= 0.1 && scale <= 1.0 else { + throw ValidationError("Scale must be between 0.1 and 1.0") + } + } +} diff --git a/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift b/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift index 6a7aa717..207c35b5 100644 --- a/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift +++ b/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift @@ -72,21 +72,7 @@ public struct IOSSimRecordVideoCommand: SimUseExecutableCommand { } public func validate() throws { - try Self.validateOptions(fps: fps, quality: quality, scale: scale) - } - - public static func validateOptions(fps: Int?, quality: Int, scale: Double) throws { - if let fps { - guard fps >= 1 && fps <= 60 else { - throw ValidationError("FPS must be between 1 and 60") - } - } - guard quality >= 1 && quality <= 100 else { - throw ValidationError("Quality must be between 1 and 100") - } - guard scale >= 0.1 && scale <= 1.0 else { - throw ValidationError("Scale must be between 0.1 and 1.0") - } + try VideoRecordingOptions.validate(fps: fps, quality: quality, scale: scale) } public func execute() async throws -> ExecutionResult { diff --git a/Tests/AndroidRecordVideoArgumentTests.swift b/Tests/AndroidBackendTests/AndroidRecordVideoArgumentTests.swift similarity index 70% rename from Tests/AndroidRecordVideoArgumentTests.swift rename to Tests/AndroidBackendTests/AndroidRecordVideoArgumentTests.swift index b27e3721..c0bf25e5 100644 --- a/Tests/AndroidRecordVideoArgumentTests.swift +++ b/Tests/AndroidBackendTests/AndroidRecordVideoArgumentTests.swift @@ -1,12 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 import Testing -@testable import SimUse +@testable import AndroidBackend @Suite("Android record-video argument construction") struct AndroidRecordVideoArgumentTests { @Test("parseWMSize reads Physical size") func physicalSize() { - let parsed = RecordVideo.parseWMSize("Physical size: 1080x2400\n") + let parsed = AndroidRecordVideoCommand.parseWMSize("Physical size: 1080x2400\n") #expect(parsed?.width == 1080) #expect(parsed?.height == 2400) } @@ -14,25 +14,25 @@ struct AndroidRecordVideoArgumentTests { @Test("parseWMSize prefers an Override size over Physical size") func overrideSizePreferred() { let output = "Physical size: 1080x2400\nOverride size: 720x1600\n" - let parsed = RecordVideo.parseWMSize(output) + let parsed = AndroidRecordVideoCommand.parseWMSize(output) #expect(parsed?.width == 720) #expect(parsed?.height == 1600) } @Test("parseWMSize returns nil for unparseable output") func unparseable() { - #expect(RecordVideo.parseWMSize("cannot connect to display\n") == nil) + #expect(AndroidRecordVideoCommand.parseWMSize("cannot connect to display\n") == nil) } @Test("screenrecordArguments omits --time-limit below API 34") func api33NoTimeLimit() { - let args = RecordVideo.screenrecordArguments(serial: "emu-1", sdk: 33, bitrate: nil, size: nil) + let args = AndroidRecordVideoCommand.screenrecordArguments(serial: "emu-1", sdk: 33, bitrate: nil, size: nil) #expect(args == ["-s", "emu-1", "exec-out", "screenrecord", "--output-format=h264", "-"]) } @Test("screenrecordArguments adds --time-limit 0 on API 34+") func api34Unlimited() { - let args = RecordVideo.screenrecordArguments(serial: "emu-1", sdk: 34, bitrate: nil, size: nil) + let args = AndroidRecordVideoCommand.screenrecordArguments(serial: "emu-1", sdk: 34, bitrate: nil, size: nil) #expect(args.contains("--time-limit")) #expect(args.contains("0")) #expect(args.last == "-") @@ -40,7 +40,7 @@ struct AndroidRecordVideoArgumentTests { @Test("screenrecordArguments includes --bit-rate and --size when provided") func bitrateAndSize() { - let args = RecordVideo.screenrecordArguments(serial: "emu-1", sdk: 34, bitrate: 4_000_000, size: (width: 540, height: 1200)) + let args = AndroidRecordVideoCommand.screenrecordArguments(serial: "emu-1", sdk: 34, bitrate: 4_000_000, size: (width: 540, height: 1200)) #expect(args == [ "-s", "emu-1", "exec-out", "screenrecord", "--output-format=h264", "--time-limit", "0", @@ -57,7 +57,7 @@ struct AndroidRecordVideoArgumentTests { // itself is scale-gated. @Test("screenrecordArguments passes --bit-rate without --size (default scale)") func bitrateWithoutSizeAtDefaultScale() { - let args = RecordVideo.screenrecordArguments(serial: "emu-1", sdk: 34, bitrate: 4_000_000, size: nil) + let args = AndroidRecordVideoCommand.screenrecordArguments(serial: "emu-1", sdk: 34, bitrate: 4_000_000, size: nil) #expect(args.contains("--bit-rate")) #expect(args.contains("4000000")) #expect(!args.contains("--size")) @@ -65,14 +65,14 @@ struct AndroidRecordVideoArgumentTests { @Test("scaledSize halves dimensions and rounds down to even") func scaledSizeRounding() { - let scaled = RecordVideo.scaledSize((width: 1081, height: 2401), scale: 0.5) + let scaled = AndroidRecordVideoCommand.scaledSize((width: 1081, height: 2401), scale: 0.5) #expect(scaled.width == 540) #expect(scaled.height == 1200) } @Test("scaledSize at 1.0 returns the input unchanged (already even)") func scaledSizeIdentity() { - let scaled = RecordVideo.scaledSize((width: 1080, height: 2400), scale: 1.0) + let scaled = AndroidRecordVideoCommand.scaledSize((width: 1080, height: 2400), scale: 1.0) #expect(scaled.width == 1080) #expect(scaled.height == 2400) }