refactor: streamline node invoke handling
This commit is contained in:
@@ -40,7 +40,6 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
func record(
|
||||
screenIndex: Int?,
|
||||
durationMs: Int?,
|
||||
@@ -48,165 +47,244 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
includeAudio: Bool?,
|
||||
outPath: String?) async throws -> String
|
||||
{
|
||||
let config = try self.makeRecordConfig(
|
||||
screenIndex: screenIndex,
|
||||
durationMs: durationMs,
|
||||
fps: fps,
|
||||
includeAudio: includeAudio,
|
||||
outPath: outPath)
|
||||
|
||||
let state = CaptureState()
|
||||
let recordQueue = DispatchQueue(label: "com.clawdis.screenrecord")
|
||||
|
||||
try await self.startCapture(state: state, config: config, recordQueue: recordQueue)
|
||||
try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000)
|
||||
try await self.stopCapture()
|
||||
try self.finalizeCapture(state: state)
|
||||
try await self.finishWriting(state: state)
|
||||
|
||||
return config.outURL.path
|
||||
}
|
||||
|
||||
private struct RecordConfig {
|
||||
let durationMs: Int
|
||||
let fpsValue: Double
|
||||
let includeAudio: Bool
|
||||
let outURL: URL
|
||||
}
|
||||
|
||||
private func makeRecordConfig(
|
||||
screenIndex: Int?,
|
||||
durationMs: Int?,
|
||||
fps: Double?,
|
||||
includeAudio: Bool?,
|
||||
outPath: String?) throws -> RecordConfig
|
||||
{
|
||||
if let idx = screenIndex, idx != 0 {
|
||||
throw ScreenRecordError.invalidScreenIndex(idx)
|
||||
}
|
||||
|
||||
let durationMs = Self.clampDurationMs(durationMs)
|
||||
let fps = Self.clampFps(fps)
|
||||
let fpsInt = Int32(fps.rounded())
|
||||
let fpsValue = Double(fpsInt)
|
||||
let includeAudio = includeAudio ?? true
|
||||
|
||||
if let idx = screenIndex, idx != 0 {
|
||||
throw ScreenRecordError.invalidScreenIndex(idx)
|
||||
}
|
||||
|
||||
let outURL: URL = {
|
||||
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return URL(fileURLWithPath: outPath)
|
||||
}
|
||||
return FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
|
||||
}()
|
||||
let outURL = self.makeOutputURL(outPath: outPath)
|
||||
try? FileManager.default.removeItem(at: outURL)
|
||||
|
||||
let state = CaptureState()
|
||||
let recordQueue = DispatchQueue(label: "com.clawdbot.screenrecord")
|
||||
return RecordConfig(
|
||||
durationMs: durationMs,
|
||||
fpsValue: fpsValue,
|
||||
includeAudio: includeAudio,
|
||||
outURL: outURL)
|
||||
}
|
||||
|
||||
private func makeOutputURL(outPath: String?) -> URL {
|
||||
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return URL(fileURLWithPath: outPath)
|
||||
}
|
||||
return FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
|
||||
}
|
||||
|
||||
private func startCapture(
|
||||
state: CaptureState,
|
||||
config: RecordConfig,
|
||||
recordQueue: DispatchQueue) async throws
|
||||
{
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
let handler: @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void = { sample, type, error in
|
||||
// ReplayKit can call the capture handler on a background queue.
|
||||
// Serialize writes to avoid queue asserts.
|
||||
recordQueue.async {
|
||||
if let error {
|
||||
state.withLock { state in
|
||||
if state.handlerError == nil { state.handlerError = error }
|
||||
}
|
||||
return
|
||||
}
|
||||
guard CMSampleBufferDataIsReady(sample) else { return }
|
||||
|
||||
switch type {
|
||||
case .video:
|
||||
let pts = CMSampleBufferGetPresentationTimeStamp(sample)
|
||||
let shouldSkip = state.withLock { state in
|
||||
if let lastVideoTime = state.lastVideoTime {
|
||||
let delta = CMTimeSubtract(pts, lastVideoTime)
|
||||
return delta.seconds < (1.0 / fpsValue)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if shouldSkip { return }
|
||||
|
||||
if state.withLock({ $0.writer == nil }) {
|
||||
guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else {
|
||||
state.withLock { state in
|
||||
if state.handlerError == nil {
|
||||
state.handlerError = ScreenRecordError.captureFailed("Missing image buffer")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
let width = CVPixelBufferGetWidth(imageBuffer)
|
||||
let height = CVPixelBufferGetHeight(imageBuffer)
|
||||
do {
|
||||
let w = try AVAssetWriter(outputURL: outURL, fileType: .mp4)
|
||||
let settings: [String: Any] = [
|
||||
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||
AVVideoWidthKey: width,
|
||||
AVVideoHeightKey: height,
|
||||
]
|
||||
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
|
||||
vInput.expectsMediaDataInRealTime = true
|
||||
guard w.canAdd(vInput) else {
|
||||
throw ScreenRecordError.writeFailed("Cannot add video input")
|
||||
}
|
||||
w.add(vInput)
|
||||
|
||||
if includeAudio {
|
||||
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil)
|
||||
aInput.expectsMediaDataInRealTime = true
|
||||
if w.canAdd(aInput) {
|
||||
w.add(aInput)
|
||||
state.withLock { state in
|
||||
state.audioInput = aInput
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard w.startWriting() else {
|
||||
throw ScreenRecordError
|
||||
.writeFailed(w.error?.localizedDescription ?? "Failed to start writer")
|
||||
}
|
||||
w.startSession(atSourceTime: pts)
|
||||
state.withLock { state in
|
||||
state.writer = w
|
||||
state.videoInput = vInput
|
||||
state.started = true
|
||||
}
|
||||
} catch {
|
||||
state.withLock { state in
|
||||
if state.handlerError == nil { state.handlerError = error }
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let vInput = state.withLock { $0.videoInput }
|
||||
let isStarted = state.withLock { $0.started }
|
||||
guard let vInput, isStarted else { return }
|
||||
if vInput.isReadyForMoreMediaData {
|
||||
if vInput.append(sample) {
|
||||
state.withLock { state in
|
||||
state.sawVideo = true
|
||||
state.lastVideoTime = pts
|
||||
}
|
||||
} else {
|
||||
let err = state.withLock { $0.writer?.error }
|
||||
if let err {
|
||||
state.withLock { state in
|
||||
if state.handlerError == nil {
|
||||
state.handlerError = ScreenRecordError.writeFailed(err.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .audioApp, .audioMic:
|
||||
let aInput = state.withLock { $0.audioInput }
|
||||
let isStarted = state.withLock { $0.started }
|
||||
guard includeAudio, let aInput, isStarted else { return }
|
||||
if aInput.isReadyForMoreMediaData {
|
||||
_ = aInput.append(sample)
|
||||
}
|
||||
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let handler = self.makeCaptureHandler(
|
||||
state: state,
|
||||
config: config,
|
||||
recordQueue: recordQueue)
|
||||
let completion: @Sendable (Error?) -> Void = { error in
|
||||
if let error { cont.resume(throwing: error) } else { cont.resume() }
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
startReplayKitCapture(
|
||||
includeAudio: includeAudio,
|
||||
includeAudio: config.includeAudio,
|
||||
handler: handler,
|
||||
completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try await Task.sleep(nanoseconds: UInt64(durationMs) * 1_000_000)
|
||||
private func makeCaptureHandler(
|
||||
state: CaptureState,
|
||||
config: RecordConfig,
|
||||
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
|
||||
{
|
||||
{ sample, type, error in
|
||||
// ReplayKit can call the capture handler on a background queue.
|
||||
// Serialize writes to avoid queue asserts.
|
||||
recordQueue.async {
|
||||
if let error {
|
||||
state.withLock { state in
|
||||
if state.handlerError == nil { state.handlerError = error }
|
||||
}
|
||||
return
|
||||
}
|
||||
guard CMSampleBufferDataIsReady(sample) else { return }
|
||||
|
||||
switch type {
|
||||
case .video:
|
||||
self.handleVideoSample(sample, state: state, config: config)
|
||||
case .audioApp, .audioMic:
|
||||
self.handleAudioSample(sample, state: state, includeAudio: config.includeAudio)
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleVideoSample(
|
||||
_ sample: CMSampleBuffer,
|
||||
state: CaptureState,
|
||||
config: RecordConfig)
|
||||
{
|
||||
let pts = CMSampleBufferGetPresentationTimeStamp(sample)
|
||||
let shouldSkip = state.withLock { state in
|
||||
if let lastVideoTime = state.lastVideoTime {
|
||||
let delta = CMTimeSubtract(pts, lastVideoTime)
|
||||
return delta.seconds < (1.0 / config.fpsValue)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if shouldSkip { return }
|
||||
|
||||
if state.withLock({ $0.writer == nil }) {
|
||||
self.prepareWriter(sample: sample, state: state, config: config, pts: pts)
|
||||
}
|
||||
|
||||
let vInput = state.withLock { $0.videoInput }
|
||||
let isStarted = state.withLock { $0.started }
|
||||
guard let vInput, isStarted else { return }
|
||||
if vInput.isReadyForMoreMediaData {
|
||||
if vInput.append(sample) {
|
||||
state.withLock { state in
|
||||
state.sawVideo = true
|
||||
state.lastVideoTime = pts
|
||||
}
|
||||
} else {
|
||||
let err = state.withLock { $0.writer?.error }
|
||||
if let err {
|
||||
state.withLock { state in
|
||||
if state.handlerError == nil {
|
||||
state.handlerError = ScreenRecordError.writeFailed(err.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareWriter(
|
||||
sample: CMSampleBuffer,
|
||||
state: CaptureState,
|
||||
config: RecordConfig,
|
||||
pts: CMTime)
|
||||
{
|
||||
guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else {
|
||||
state.withLock { state in
|
||||
if state.handlerError == nil {
|
||||
state.handlerError = ScreenRecordError.captureFailed("Missing image buffer")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
let width = CVPixelBufferGetWidth(imageBuffer)
|
||||
let height = CVPixelBufferGetHeight(imageBuffer)
|
||||
do {
|
||||
let writer = try AVAssetWriter(outputURL: config.outURL, fileType: .mp4)
|
||||
let settings: [String: Any] = [
|
||||
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||
AVVideoWidthKey: width,
|
||||
AVVideoHeightKey: height,
|
||||
]
|
||||
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
|
||||
vInput.expectsMediaDataInRealTime = true
|
||||
guard writer.canAdd(vInput) else {
|
||||
throw ScreenRecordError.writeFailed("Cannot add video input")
|
||||
}
|
||||
writer.add(vInput)
|
||||
|
||||
if config.includeAudio {
|
||||
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil)
|
||||
aInput.expectsMediaDataInRealTime = true
|
||||
if writer.canAdd(aInput) {
|
||||
writer.add(aInput)
|
||||
state.withLock { state in
|
||||
state.audioInput = aInput
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard writer.startWriting() else {
|
||||
throw ScreenRecordError.writeFailed(
|
||||
writer.error?.localizedDescription ?? "Failed to start writer")
|
||||
}
|
||||
writer.startSession(atSourceTime: pts)
|
||||
state.withLock { state in
|
||||
state.writer = writer
|
||||
state.videoInput = vInput
|
||||
state.started = true
|
||||
}
|
||||
} catch {
|
||||
state.withLock { state in
|
||||
if state.handlerError == nil { state.handlerError = error }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAudioSample(
|
||||
_ sample: CMSampleBuffer,
|
||||
state: CaptureState,
|
||||
includeAudio: Bool)
|
||||
{
|
||||
let aInput = state.withLock { $0.audioInput }
|
||||
let isStarted = state.withLock { $0.started }
|
||||
guard includeAudio, let aInput, isStarted else { return }
|
||||
if aInput.isReadyForMoreMediaData {
|
||||
_ = aInput.append(sample)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopCapture() async throws {
|
||||
let stopError = await withCheckedContinuation { cont in
|
||||
Task { @MainActor in
|
||||
stopReplayKitCapture { error in cont.resume(returning: error) }
|
||||
}
|
||||
}
|
||||
if let stopError { throw stopError }
|
||||
}
|
||||
|
||||
let handlerErrorSnapshot = state.withLock { $0.handlerError }
|
||||
if let handlerErrorSnapshot { throw handlerErrorSnapshot }
|
||||
private func finalizeCapture(state: CaptureState) throws {
|
||||
if let handlerErrorSnapshot = state.withLock({ $0.handlerError }) {
|
||||
throw handlerErrorSnapshot
|
||||
}
|
||||
let writerSnapshot = state.withLock { $0.writer }
|
||||
let videoInputSnapshot = state.withLock { $0.videoInput }
|
||||
let audioInputSnapshot = state.withLock { $0.audioInput }
|
||||
@@ -217,7 +295,13 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
|
||||
videoInputSnapshot.markAsFinished()
|
||||
audioInputSnapshot?.markAsFinished()
|
||||
_ = writerSnapshot
|
||||
}
|
||||
|
||||
private func finishWriting(state: CaptureState) async throws {
|
||||
guard let writerSnapshot = state.withLock({ $0.writer }) else {
|
||||
throw ScreenRecordError.captureFailed("Missing writer")
|
||||
}
|
||||
let writerBox = UncheckedSendableBox(value: writerSnapshot)
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
writerBox.value.finishWriting {
|
||||
@@ -231,8 +315,6 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outURL.path
|
||||
}
|
||||
|
||||
private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
|
||||
|
||||
Reference in New Issue
Block a user