fix: 音视频同步——音频从 firstFrameTime 开始读取

之前音频用 player.currentTime() 作为起始时间,视频用
AVPlayerItemVideoOutput 的 firstFrameTime,两者可能不一致
导致音视频不同步。

改为:
- prepareAudioCapture: 只预加载音频轨道,不立即开始读取
- captureVideoFrame: 第一帧时设置 firstFrameTime 后调用
  beginAudioReading(startTime: firstFrameTime)
- 音频和视频共用同一个时间基准,确保精确同步
- 移除 startRecording 的 startTime 参数
This commit is contained in:
yumoqing 2026-06-24 21:07:07 +08:00
parent 60a3369d82
commit 42f3cf2b84
2 changed files with 69 additions and 37 deletions

View File

@ -122,8 +122,7 @@ final class PlayerBridge: ObservableObject {
} }
guard !queue.isEmpty else { return } guard !queue.isEmpty else { return }
let sourceURL = queue[currentIndex].url let sourceURL = queue[currentIndex].url
let startTime = player.currentTime() screenRecorder.startRecording(from: output, sourceURL: sourceURL)
screenRecorder.startRecording(from: output, sourceURL: sourceURL, startTime: startTime)
} }
#endif #endif
} }

View File

@ -22,6 +22,10 @@ final class PlayerRecorder: NSObject {
/// 音频线程读取 firstFrameTime 的镜像 /// 音频线程读取 firstFrameTime 的镜像
nonisolated(unsafe) private var audioFirstFrameTime: CMTime? nonisolated(unsafe) private var audioFirstFrameTime: CMTime?
// 音频预加载状态
private var pendingAudioAsset: AVURLAsset?
private var pendingAudioTrack: AVAssetTrack?
@Published var isRecording = false @Published var isRecording = false
@Published var durationText = "00:00" @Published var durationText = "00:00"
@ -38,7 +42,7 @@ final class PlayerRecorder: NSObject {
} }
/// 开始录制 /// 开始录制
func startRecording(from output: AVPlayerItemVideoOutput, sourceURL: URL, startTime: CMTime) { func startRecording(from output: AVPlayerItemVideoOutput, sourceURL: URL) {
guard !isRecording else { return } guard !isRecording else { return }
weakOutput = output weakOutput = output
@ -97,8 +101,8 @@ final class PlayerRecorder: NSObject {
startTimer() startTimer()
startCaptureLoop() startCaptureLoop()
// 启动音频读取(从源 URL) // 准备音频读取(等待 firstFrameTime 后才真正开始)
startAudioCapture(sourceURL: sourceURL, startTime: startTime, audioWriterInput: aInput) prepareAudioCapture(sourceURL: sourceURL)
} }
/// 停止录制 /// 停止录制
@ -151,6 +155,8 @@ final class PlayerRecorder: NSObject {
audioFirstFrameTime = itemTime // 同步给音频线程 audioFirstFrameTime = itemTime // 同步给音频线程
writer?.startSession(atSourceTime: itemTime) writer?.startSession(atSourceTime: itemTime)
isRunning = true isRunning = true
// 音频预加载完成后,用 firstFrameTime 作为起始时间开始读取
beginAudioReading(startTime: itemTime)
} }
// 相对时间戳 // 相对时间戳
@ -182,8 +188,12 @@ final class PlayerRecorder: NSObject {
} }
// MARK: - 音频读取(从源 URL) // MARK: - 音频读取(从源 URL)
private func startAudioCapture(sourceURL: URL, startTime: CMTime, audioWriterInput: AVAssetWriterInput) {
/// 预加载音频轨道(异步),不立即开始读取
private func prepareAudioCapture(sourceURL: URL) {
let asset = AVURLAsset(url: sourceURL) let asset = AVURLAsset(url: sourceURL)
pendingAudioAsset = asset
pendingAudioTrack = nil
Task { Task {
do { do {
@ -192,7 +202,27 @@ final class PlayerRecorder: NSObject {
print("[Recorder] No audio track in source") print("[Recorder] No audio track in source")
return return
} }
self.pendingAudioTrack = audioTrack
// 如果 firstFrameTime 已经设置好了,立即开始读取
if let fft = self.firstFrameTime {
self.beginAudioReading(startTime: fft)
}
} catch {
print("[Recorder] Audio track load 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 reader = try AVAssetReader(asset: asset)
let output = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: [ let output = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: [
AVFormatIDKey: kAudioFormatLinearPCM, AVFormatIDKey: kAudioFormatLinearPCM,
@ -205,7 +235,7 @@ final class PlayerRecorder: NSObject {
]) ])
output.alwaysCopiesSampleData = false output.alwaysCopiesSampleData = false
// 从播放位置开始读取 // 从视频第一帧时间开始读取,确保音视频同步
reader.timeRange = CMTimeRange(start: startTime, duration: .positiveInfinity) reader.timeRange = CMTimeRange(start: startTime, duration: .positiveInfinity)
if reader.canAdd(output) { if reader.canAdd(output) {
@ -219,14 +249,17 @@ final class PlayerRecorder: NSObject {
let queue = DispatchQueue(label: "recorder.audio.capture") let queue = DispatchQueue(label: "recorder.audio.capture")
self.audioCaptureQueue = queue self.audioCaptureQueue = queue
queue.async { [weak self] in queue.async { [weak self] in
guard let self = self, let aInput = self.audioInput else { return } guard let self = self else { return }
self.readAudioLoop(reader: reader, output: output, audioInput: aInput) self.readAudioLoop(reader: reader, output: output, audioInput: aInput)
} }
} }
} catch { } catch {
print("[Recorder] Audio capture setup failed: \(error)") print("[Recorder] Audio capture setup failed: \(error)")
} }
}
// 清理预加载状态
pendingAudioAsset = nil
pendingAudioTrack = nil
} }
private nonisolated func readAudioLoop(reader: AVAssetReader, output: AVAssetReaderTrackOutput, audioInput: AVAssetWriterInput) { private nonisolated func readAudioLoop(reader: AVAssetReader, output: AVAssetReaderTrackOutput, audioInput: AVAssetWriterInput) {