diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index bdb2434..274f31b 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -122,7 +122,8 @@ final class PlayerBridge: ObservableObject { } guard !queue.isEmpty else { return } let sourceURL = queue[currentIndex].url - screenRecorder.startRecording(from: output, sourceURL: sourceURL) + let startTime = player.currentTime() + screenRecorder.startRecording(from: output, sourceURL: sourceURL, startTime: startTime) } #endif } diff --git a/Sources/PlayerRecorder.swift b/Sources/PlayerRecorder.swift index ff7e638..1d754f1 100644 --- a/Sources/PlayerRecorder.swift +++ b/Sources/PlayerRecorder.swift @@ -17,14 +17,10 @@ final class PlayerRecorder: NSObject { private var audioReaderOutput: AVAssetReaderTrackOutput? private var audioCaptureQueue: DispatchQueue? private var isRunning = false - /// 线程安全的音频停止标志,后台队列直接读取 - nonisolated(unsafe) private var audioStopped: Bool = false - /// 音频线程读取 firstFrameTime 的镜像 - nonisolated(unsafe) private var audioFirstFrameTime: CMTime? - // 音频预加载状态 - private var pendingAudioAsset: AVURLAsset? - private var pendingAudioTrack: AVAssetTrack? + // 视频:缓存上一帧,确保静态场景也能持续写入 + private var lastPixelBuffer: CVPixelBuffer? + private var captureFrameCount: Int = 0 @Published var isRecording = false @Published var durationText = "00:00" @@ -42,13 +38,14 @@ final class PlayerRecorder: NSObject { } /// 开始录制 - func startRecording(from output: AVPlayerItemVideoOutput, sourceURL: URL) { + func startRecording(from output: AVPlayerItemVideoOutput, sourceURL: URL, startTime: CMTime) { guard !isRecording else { return } weakOutput = output firstFrameTime = nil + lastPixelBuffer = nil + captureFrameCount = 0 isRunning = false - audioStopped = false // 临时文件 let formatter = DateFormatter() @@ -97,12 +94,12 @@ final class PlayerRecorder: NSObject { // 启动视频抓帧 isRecording = true - isRunning = true // 必须在 startCaptureLoop 之前设置 + isRunning = true startTimer() startCaptureLoop() - // 准备音频读取(等待 firstFrameTime 后才真正开始) - prepareAudioCapture(sourceURL: sourceURL) + // 启动音频读取(从播放位置开始) + startAudioCapture(sourceURL: sourceURL, startTime: startTime, audioWriterInput: aInput) } /// 停止录制 @@ -113,7 +110,11 @@ final class PlayerRecorder: NSObject { captureTimer?.invalidate() captureTimer = nil stopTimer() - stopAudioCapture() + + // 停止音频:先 cancel reader,再 markAsFinished + audioReader?.cancelReading() + audioReader = nil + audioReaderOutput = nil videoInput?.markAsFinished() audioInput?.markAsFinished() @@ -126,13 +127,14 @@ final class PlayerRecorder: NSObject { } } } + + lastPixelBuffer = nil } // MARK: - 视频抓帧 private var captureTimer: Timer? private func startCaptureLoop() { - // 用 Timer 每 1/30 秒抓一帧 captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in Task { @MainActor in self?.captureVideoFrame() @@ -141,33 +143,34 @@ final class PlayerRecorder: NSObject { } private func captureVideoFrame() { - guard let output = weakOutput, let input = videoInput, input.isReadyForMoreMediaData else { return } + guard isRunning, let output = weakOutput, let input = videoInput, + input.isReadyForMoreMediaData else { return } let hostTime = CACurrentMediaTime() let itemTime = output.itemTime(forHostTime: hostTime) - guard output.hasNewPixelBuffer(forItemTime: itemTime), - let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) else { return } - - // 第一帧:用它的时间作为 session 起点 - if firstFrameTime == nil { - firstFrameTime = itemTime - audioFirstFrameTime = itemTime // 同步给音频线程 - writer?.startSession(atSourceTime: itemTime) - isRunning = true - // 音频预加载完成后,用 firstFrameTime 作为起始时间开始读取 - beginAudioReading(startTime: itemTime) + // 尝试获取新帧,否则复用上一帧(确保静态场景也持续写入) + if let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) { + lastPixelBuffer = pb + } + + guard let pb = lastPixelBuffer else { return } + + // 第一帧:启动 session + if firstFrameTime == nil { + firstFrameTime = itemTime + writer?.startSession(atSourceTime: itemTime) } - // 相对时间戳 guard let firstTime = firstFrameTime else { return } let relativeTime = CMTimeSubtract(itemTime, firstTime) - guard relativeTime.seconds >= 0 else { return } let sampleBuffer = Self.createSampleBuffer(from: pb, time: relativeTime) if let sb = sampleBuffer { - _ = input.append(sb) + if input.append(sb) { + captureFrameCount += 1 + } } } @@ -187,13 +190,9 @@ final class PlayerRecorder: NSObject { return sampleBuffer } - // MARK: - 音频读取(从源 URL) - - /// 预加载音频轨道(异步),不立即开始读取 - private func prepareAudioCapture(sourceURL: URL) { + // MARK: - 音频读取(从源 URL,用 requestMediaDataWhenReady 控制节奏) + private func startAudioCapture(sourceURL: URL, startTime: CMTime, audioWriterInput: AVAssetWriterInput) { let asset = AVURLAsset(url: sourceURL) - pendingAudioAsset = asset - pendingAudioTrack = nil Task { do { @@ -202,84 +201,65 @@ final class PlayerRecorder: NSObject { print("[Recorder] No audio track in source") return } - self.pendingAudioTrack = audioTrack - // 如果 firstFrameTime 已经设置好了,立即开始读取 - if let fft = self.firstFrameTime { - self.beginAudioReading(startTime: fft) + guard self.isRunning else { return } + + let reader = try AVAssetReader(asset: asset) + let readerOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: 44100, + AVNumberOfChannelsKey: 2, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false, + AVLinearPCMIsNonInterleaved: false + ]) + readerOutput.alwaysCopiesSampleData = false + + // 从播放位置开始读取 + reader.timeRange = CMTimeRange(start: startTime, duration: .positiveInfinity) + + if reader.canAdd(readerOutput) { + reader.add(readerOutput) + reader.startReading() + + self.audioReader = reader + self.audioReaderOutput = readerOutput + + // 用 requestMediaDataWhenReady 控制读取节奏(writer 需要数据时才读) + let queue = DispatchQueue(label: "recorder.audio.capture") + self.audioCaptureQueue = queue + + audioWriterInput.requestMediaDataWhenReady(on: queue) { [weak self] in + guard let self = self else { return } + self.feedAudioSamples(writerInput: audioWriterInput, readerOutput: readerOutput, reader: reader) + } } } catch { - print("[Recorder] Audio track load failed: \(error)") + print("[Recorder] Audio capture setup failed: \(error)") } } } - /// 用 firstFrameTime 作为起始时间,创建 reader 并开始后台读取 - private func beginAudioReading(startTime: CMTime) { - guard let asset = pendingAudioAsset, let audioTrack = pendingAudioTrack else { - return // 轨道还没加载好,prepareAudioCapture 的回调会再次调用 - } - guard let aInput = audioInput else { return } - // 防止重复启动 - guard audioReader == nil else { return } - - do { - let reader = try AVAssetReader(asset: asset) - let output = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: [ - AVFormatIDKey: kAudioFormatLinearPCM, - AVSampleRateKey: 44100, - AVNumberOfChannelsKey: 2, - AVLinearPCMBitDepthKey: 16, - AVLinearPCMIsFloatKey: false, - AVLinearPCMIsBigEndianKey: false, - AVLinearPCMIsNonInterleaved: false - ]) - output.alwaysCopiesSampleData = false - - // 从视频第一帧时间开始读取,确保音视频同步 - reader.timeRange = CMTimeRange(start: startTime, duration: .positiveInfinity) - - if reader.canAdd(output) { - reader.add(output) - reader.startReading() - - self.audioReader = reader - self.audioReaderOutput = output - - // 后台线程读取音频 - let queue = DispatchQueue(label: "recorder.audio.capture") - self.audioCaptureQueue = queue - queue.async { [weak self] in - guard let self = self else { return } - self.readAudioLoop(reader: reader, output: output, audioInput: aInput) + /// 由 requestMediaDataWhenReady 回调,writer 需要数据时才读取 + private func feedAudioSamples(writerInput: AVAssetWriterInput, + readerOutput: AVAssetReaderTrackOutput, + reader: AVAssetReader) { + while writerInput.isReadyForMoreMediaData { + guard isRunning, reader.status == .reading, + let sampleBuffer = readerOutput.copyNextSampleBuffer() else { + // 录制已停止或读取完毕 + if isRunning { + writerInput.markAsFinished() } - } - } catch { - print("[Recorder] Audio capture setup failed: \(error)") - } - - // 清理预加载状态 - pendingAudioAsset = nil - pendingAudioTrack = nil - } - - private nonisolated func readAudioLoop(reader: AVAssetReader, output: AVAssetReaderTrackOutput, audioInput: AVAssetWriterInput) { - while !audioStopped && reader.status == .reading { - guard let sampleBuffer = output.copyNextSampleBuffer() else { - break + return } - // 直接在后台队列处理,不走 MainActor(避免队列积压) - guard let firstTime = audioFirstFrameTime else { - // 视频第一帧还没到,跳过这个音频 buffer + // 重映射时间戳:源文件时间 → 相对于录制开始的时间 + guard let firstTime = self.firstFrameTime else { + // 视频第一帧还没到,丢弃这个 buffer continue } - guard audioInput.isReadyForMoreMediaData else { continue } - let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) - let relativeTime = CMTimeSubtract(pts, firstTime) - guard relativeTime.seconds >= 0 else { continue } - - // 重建 sample buffer with new time var count: CMItemCount = 0 CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: 0, arrayToFill: nil, entriesNeededOut: &count) var timingInfo = [CMSampleTimingInfo](repeating: CMSampleTimingInfo(), count: count) @@ -294,18 +274,13 @@ final class PlayerRecorder: NSObject { CMSampleBufferCreateCopyWithNewTiming(allocator: kCFAllocatorDefault, sampleBuffer: sampleBuffer, sampleTimingEntryCount: count, sampleTimingArray: &timingInfo, sampleBufferOut: &newBuffer) if let nb = newBuffer { - _ = audioInput.append(nb) + if !writerInput.append(nb) { + return + } } } } - private func stopAudioCapture() { - audioStopped = true // 先设标志,loop 立即退出 - audioReader?.cancelReading() - audioReader = nil - audioReaderOutput = nil - } - // MARK: - Timer private func startTimer() { startDate = Date()