From ff4902ffe67c560171adec096fee8a3a06f5dfb3 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Wed, 24 Jun 2026 22:08:09 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A7=86=E9=A2=91=E5=8F=AA=E6=9C=89?= =?UTF-8?q?=E4=B8=80=E5=B8=A7+=E9=9F=B3=E9=A2=91=E8=B6=85=E9=95=BF?= =?UTF-8?q?=E2=80=94=E2=80=94=E9=87=8D=E5=86=99=E5=BD=95=E5=88=B6=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E5=9F=BA=E5=87=86=E5=92=8C=E9=9F=B3=E9=A2=91=E9=99=90?= =?UTF-8?q?=E9=80=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 视频修复: - startSession(atSourceTime: .zero) 替代 itemTime - 之前 session 起点是绝对 itemTime(如120s),但 sample 时间戳 是相对的(0, 1/30...),writer 因 0<120 丢弃帧 - 现在统一用 player.currentTime() 作为共享时间基准 音频修复: - 用 DispatchSourceTimer(20ms) 替代 requestMediaDataWhenReady - 每 20ms 检查实时进度(elapsed wall-clock),只写入不超前的音频 - 超前的 sample 缓存到 pendingAudioSample 等下次 tick - 停止时 cancel timer + cancel reader,音频长度精确对齐视频 音视频同步: - 两者共享 recordStartTime = player.currentTime() - 视频: itemTime - recordStartTime - 音频: audioPTS - recordStartTime --- Sources/PlayerRecorder.swift | 164 +++++++++++++++++++++++------------ 1 file changed, 110 insertions(+), 54 deletions(-) diff --git a/Sources/PlayerRecorder.swift b/Sources/PlayerRecorder.swift index 1d754f1..a95b1d6 100644 --- a/Sources/PlayerRecorder.swift +++ b/Sources/PlayerRecorder.swift @@ -12,16 +12,22 @@ final class PlayerRecorder: NSObject { private var timer: Timer? private var startDate: Date? private var weakOutput: AVPlayerItemVideoOutput? - private var firstFrameTime: CMTime? - private var audioReader: AVAssetReader? - private var audioReaderOutput: AVAssetReaderTrackOutput? - private var audioCaptureQueue: DispatchQueue? - private var isRunning = false + nonisolated(unsafe) private var isRunning = false // 视频:缓存上一帧,确保静态场景也能持续写入 private var lastPixelBuffer: CVPixelBuffer? private var captureFrameCount: Int = 0 + // 音频 + private var audioReader: AVAssetReader? + private var audioReaderOutput: AVAssetReaderTrackOutput? + private nonisolated(unsafe) var audioFeedTimer: DispatchSourceTimer? + + // 共享时间基准:player.currentTime() at record start + nonisolated(unsafe) private var recordStartTime: CMTime = .zero + nonisolated(unsafe) private var audioStartHostTime: CFTimeInterval = 0 + nonisolated(unsafe) private var pendingAudioSample: CMSampleBuffer? + @Published var isRecording = false @Published var durationText = "00:00" @@ -37,15 +43,17 @@ final class PlayerRecorder: NSObject { return dir } - /// 开始录制 + // MARK: - 开始录制 func startRecording(from output: AVPlayerItemVideoOutput, sourceURL: URL, startTime: CMTime) { guard !isRecording else { return } weakOutput = output - firstFrameTime = nil lastPixelBuffer = nil captureFrameCount = 0 isRunning = false + recordStartTime = startTime // 共享时间基准 + audioStartHostTime = 0 + pendingAudioSample = nil // 临时文件 let formatter = DateFormatter() @@ -91,6 +99,8 @@ final class PlayerRecorder: NSObject { } writer.startWriting() + // session 起点为 0,视频和音频都用相对时间戳 + writer.startSession(atSourceTime: .zero) // 启动视频抓帧 isRecording = true @@ -98,11 +108,11 @@ final class PlayerRecorder: NSObject { startTimer() startCaptureLoop() - // 启动音频读取(从播放位置开始) + // 启动音频读取 startAudioCapture(sourceURL: sourceURL, startTime: startTime, audioWriterInput: aInput) } - /// 停止录制 + // MARK: - 停止录制 func stopRecording() { guard isRecording else { return } isRecording = false @@ -111,10 +121,15 @@ final class PlayerRecorder: NSObject { captureTimer = nil stopTimer() - // 停止音频:先 cancel reader,再 markAsFinished + // 停止音频 timer + audioFeedTimer?.cancel() + audioFeedTimer = nil + + // 停止音频 reader audioReader?.cancelReading() audioReader = nil audioReaderOutput = nil + pendingAudioSample = nil videoInput?.markAsFinished() audioInput?.markAsFinished() @@ -123,6 +138,8 @@ final class PlayerRecorder: NSObject { Task { @MainActor in guard let self = self else { return } if let url = self.tempURL { + let count = self.captureFrameCount + print("[Recorder] Finish writing: \(count) video frames") self.showSaveDialog(tempURL: url) } } @@ -131,7 +148,7 @@ final class PlayerRecorder: NSObject { lastPixelBuffer = nil } - // MARK: - 视频抓帧 + // MARK: - 视频抓帧 (30fps Timer) private var captureTimer: Timer? private func startCaptureLoop() { @@ -149,21 +166,15 @@ final class PlayerRecorder: NSObject { let hostTime = CACurrentMediaTime() let itemTime = output.itemTime(forHostTime: hostTime) - // 尝试获取新帧,否则复用上一帧(确保静态场景也持续写入) + // 尝试获取新帧,否则复用上一帧 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) + // 相对时间戳:itemTime - recordStartTime + let relativeTime = CMTimeSubtract(itemTime, recordStartTime) guard relativeTime.seconds >= 0 else { return } let sampleBuffer = Self.createSampleBuffer(from: pb, time: relativeTime) @@ -190,7 +201,7 @@ final class PlayerRecorder: NSObject { return sampleBuffer } - // MARK: - 音频读取(从源 URL,用 requestMediaDataWhenReady 控制节奏) + // MARK: - 音频读取(DispatchSourceTimer 限速到实时) private func startAudioCapture(sourceURL: URL, startTime: CMTime, audioWriterInput: AVAssetWriterInput) { let asset = AVURLAsset(url: sourceURL) @@ -225,14 +236,20 @@ final class PlayerRecorder: NSObject { self.audioReader = reader self.audioReaderOutput = readerOutput - // 用 requestMediaDataWhenReady 控制读取节奏(writer 需要数据时才读) - let queue = DispatchQueue(label: "recorder.audio.capture") - self.audioCaptureQueue = queue + // 记录 wall-clock 起始时间,用于实时限速 + self.audioStartHostTime = CACurrentMediaTime() - audioWriterInput.requestMediaDataWhenReady(on: queue) { [weak self] in - guard let self = self else { return } - self.feedAudioSamples(writerInput: audioWriterInput, readerOutput: readerOutput, reader: reader) + // 用 DispatchSourceTimer 每 20ms 喂一次音频,限速到实时 + let queue = DispatchQueue(label: "recorder.audio.feed") + let feedTimer = DispatchSource.makeTimerSource(queue: queue) + feedTimer.schedule(deadline: .now() + .milliseconds(10), repeating: .milliseconds(20)) + feedTimer.setEventHandler { [weak self] in + self?.feedAudioSamplesRealtime(writerInput: audioWriterInput, + readerOutput: readerOutput, + reader: reader) } + self.audioFeedTimer = feedTimer + feedTimer.resume() } } catch { print("[Recorder] Audio capture setup failed: \(error)") @@ -240,40 +257,60 @@ final class PlayerRecorder: NSObject { } } - /// 由 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 { + /// 每 20ms 调用,只写入不超过当前实时进度的音频 + private nonisolated func feedAudioSamplesRealtime(writerInput: AVAssetWriterInput, + readerOutput: AVAssetReaderTrackOutput, + reader: AVAssetReader) { + guard isRunning, reader.status == .reading else { + if isRunning && reader.status != .reading { + writerInput.markAsFinished() + audioFeedTimer?.cancel() + } + return + } + guard writerInput.isReadyForMoreMediaData else { return } + + // 实时进度:录制开始以来经过的秒数 + let elapsed = CACurrentMediaTime() - audioStartHostTime + let maxAudioTime = elapsed + 0.05 // 允许 50ms 预读 + + // 先处理上次缓存的超前 sample + if let pending = pendingAudioSample { + let pts = CMSampleBufferGetPresentationTimeStamp(pending) + let relative = CMTimeSubtract(pts, recordStartTime) + if relative.seconds > maxAudioTime { + return // 还没到时间,等下次 tick + } + pendingAudioSample = nil + if let nb = Self.remapAudioBuffer(pending, offset: recordStartTime) { + _ = writerInput.append(nb) + } + } + + // 继续读取,直到追上实时进度 + while isRunning, reader.status == .reading, writerInput.isReadyForMoreMediaData { + guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else { + // 源文件音频读完 + if reader.status != .reading { writerInput.markAsFinished() + audioFeedTimer?.cancel() } return } - // 重映射时间戳:源文件时间 → 相对于录制开始的时间 - guard let firstTime = self.firstFrameTime else { - // 视频第一帧还没到,丢弃这个 buffer - continue + let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + let relative = CMTimeSubtract(pts, recordStartTime) + + // 超出实时进度 → 缓存,等下次 tick + if relative.seconds > maxAudioTime { + pendingAudioSample = sampleBuffer + return } - var count: CMItemCount = 0 - CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: 0, arrayToFill: nil, entriesNeededOut: &count) - var timingInfo = [CMSampleTimingInfo](repeating: CMSampleTimingInfo(), count: count) - CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: count, arrayToFill: &timingInfo, entriesNeededOut: nil) + // 跳过负时间戳(音频 pts 在 recordStartTime 之前) + guard relative.seconds >= 0 else { continue } - for i in 0.. CMSampleBuffer? { + var count: CMItemCount = 0 + CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: 0, arrayToFill: nil, entriesNeededOut: &count) + guard count > 0 else { return sampleBuffer } + + var timingInfo = [CMSampleTimingInfo](repeating: CMSampleTimingInfo(), count: count) + CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: count, arrayToFill: &timingInfo, entriesNeededOut: nil) + + for i in 0..