fix: 视频只有一帧+音频超长——重写录制时间基准和音频限速

视频修复:
- 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
This commit is contained in:
yumoqing 2026-06-24 22:08:09 +08:00
parent 06345b07de
commit ff4902ffe6

View File

@ -12,16 +12,22 @@ final class PlayerRecorder: NSObject {
private var timer: Timer? private var timer: Timer?
private var startDate: Date? private var startDate: Date?
private var weakOutput: AVPlayerItemVideoOutput? private var weakOutput: AVPlayerItemVideoOutput?
private var firstFrameTime: CMTime? nonisolated(unsafe) private var isRunning = false
private var audioReader: AVAssetReader?
private var audioReaderOutput: AVAssetReaderTrackOutput?
private var audioCaptureQueue: DispatchQueue?
private var isRunning = false
// //
private var lastPixelBuffer: CVPixelBuffer? private var lastPixelBuffer: CVPixelBuffer?
private var captureFrameCount: Int = 0 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 isRecording = false
@Published var durationText = "00:00" @Published var durationText = "00:00"
@ -37,15 +43,17 @@ final class PlayerRecorder: NSObject {
return dir return dir
} }
/// // MARK: -
func startRecording(from output: AVPlayerItemVideoOutput, sourceURL: URL, startTime: CMTime) { func startRecording(from output: AVPlayerItemVideoOutput, sourceURL: URL, startTime: CMTime) {
guard !isRecording else { return } guard !isRecording else { return }
weakOutput = output weakOutput = output
firstFrameTime = nil
lastPixelBuffer = nil lastPixelBuffer = nil
captureFrameCount = 0 captureFrameCount = 0
isRunning = false isRunning = false
recordStartTime = startTime //
audioStartHostTime = 0
pendingAudioSample = nil
// //
let formatter = DateFormatter() let formatter = DateFormatter()
@ -91,6 +99,8 @@ final class PlayerRecorder: NSObject {
} }
writer.startWriting() writer.startWriting()
// session 0
writer.startSession(atSourceTime: .zero)
// //
isRecording = true isRecording = true
@ -98,11 +108,11 @@ final class PlayerRecorder: NSObject {
startTimer() startTimer()
startCaptureLoop() startCaptureLoop()
// //
startAudioCapture(sourceURL: sourceURL, startTime: startTime, audioWriterInput: aInput) startAudioCapture(sourceURL: sourceURL, startTime: startTime, audioWriterInput: aInput)
} }
/// // MARK: -
func stopRecording() { func stopRecording() {
guard isRecording else { return } guard isRecording else { return }
isRecording = false isRecording = false
@ -111,10 +121,15 @@ final class PlayerRecorder: NSObject {
captureTimer = nil captureTimer = nil
stopTimer() stopTimer()
// cancel reader markAsFinished // timer
audioFeedTimer?.cancel()
audioFeedTimer = nil
// reader
audioReader?.cancelReading() audioReader?.cancelReading()
audioReader = nil audioReader = nil
audioReaderOutput = nil audioReaderOutput = nil
pendingAudioSample = nil
videoInput?.markAsFinished() videoInput?.markAsFinished()
audioInput?.markAsFinished() audioInput?.markAsFinished()
@ -123,6 +138,8 @@ final class PlayerRecorder: NSObject {
Task { @MainActor in Task { @MainActor in
guard let self = self else { return } guard let self = self else { return }
if let url = self.tempURL { if let url = self.tempURL {
let count = self.captureFrameCount
print("[Recorder] Finish writing: \(count) video frames")
self.showSaveDialog(tempURL: url) self.showSaveDialog(tempURL: url)
} }
} }
@ -131,7 +148,7 @@ final class PlayerRecorder: NSObject {
lastPixelBuffer = nil lastPixelBuffer = nil
} }
// MARK: - // MARK: - (30fps Timer)
private var captureTimer: Timer? private var captureTimer: Timer?
private func startCaptureLoop() { private func startCaptureLoop() {
@ -149,21 +166,15 @@ final class PlayerRecorder: NSObject {
let hostTime = CACurrentMediaTime() let hostTime = CACurrentMediaTime()
let itemTime = output.itemTime(forHostTime: hostTime) let itemTime = output.itemTime(forHostTime: hostTime)
// //
if let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) { if let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) {
lastPixelBuffer = pb lastPixelBuffer = pb
} }
guard let pb = lastPixelBuffer else { return } guard let pb = lastPixelBuffer else { return }
// session // itemTime - recordStartTime
if firstFrameTime == nil { let relativeTime = CMTimeSubtract(itemTime, recordStartTime)
firstFrameTime = itemTime
writer?.startSession(atSourceTime: itemTime)
}
guard let firstTime = firstFrameTime else { return }
let relativeTime = CMTimeSubtract(itemTime, firstTime)
guard relativeTime.seconds >= 0 else { return } guard relativeTime.seconds >= 0 else { return }
let sampleBuffer = Self.createSampleBuffer(from: pb, time: relativeTime) let sampleBuffer = Self.createSampleBuffer(from: pb, time: relativeTime)
@ -190,7 +201,7 @@ final class PlayerRecorder: NSObject {
return sampleBuffer return sampleBuffer
} }
// MARK: - URL requestMediaDataWhenReady // MARK: - DispatchSourceTimer
private func startAudioCapture(sourceURL: URL, startTime: CMTime, audioWriterInput: AVAssetWriterInput) { private func startAudioCapture(sourceURL: URL, startTime: CMTime, audioWriterInput: AVAssetWriterInput) {
let asset = AVURLAsset(url: sourceURL) let asset = AVURLAsset(url: sourceURL)
@ -225,14 +236,20 @@ final class PlayerRecorder: NSObject {
self.audioReader = reader self.audioReader = reader
self.audioReaderOutput = readerOutput self.audioReaderOutput = readerOutput
// requestMediaDataWhenReady writer // wall-clock
let queue = DispatchQueue(label: "recorder.audio.capture") self.audioStartHostTime = CACurrentMediaTime()
self.audioCaptureQueue = queue
audioWriterInput.requestMediaDataWhenReady(on: queue) { [weak self] in // DispatchSourceTimer 20ms
guard let self = self else { return } let queue = DispatchQueue(label: "recorder.audio.feed")
self.feedAudioSamples(writerInput: audioWriterInput, readerOutput: readerOutput, reader: reader) 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 { } catch {
print("[Recorder] Audio capture setup failed: \(error)") print("[Recorder] Audio capture setup failed: \(error)")
@ -240,40 +257,60 @@ final class PlayerRecorder: NSObject {
} }
} }
/// requestMediaDataWhenReady writer /// 20ms
private func feedAudioSamples(writerInput: AVAssetWriterInput, private nonisolated func feedAudioSamplesRealtime(writerInput: AVAssetWriterInput,
readerOutput: AVAssetReaderTrackOutput, readerOutput: AVAssetReaderTrackOutput,
reader: AVAssetReader) { reader: AVAssetReader) {
while writerInput.isReadyForMoreMediaData { guard isRunning, reader.status == .reading else {
guard isRunning, reader.status == .reading, if isRunning && reader.status != .reading {
let sampleBuffer = readerOutput.copyNextSampleBuffer() else { writerInput.markAsFinished()
// audioFeedTimer?.cancel()
if isRunning { }
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() writerInput.markAsFinished()
audioFeedTimer?.cancel()
} }
return return
} }
// let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
guard let firstTime = self.firstFrameTime else { let relative = CMTimeSubtract(pts, recordStartTime)
// buffer
continue // tick
if relative.seconds > maxAudioTime {
pendingAudioSample = sampleBuffer
return
} }
var count: CMItemCount = 0 // pts recordStartTime
CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: 0, arrayToFill: nil, entriesNeededOut: &count) guard relative.seconds >= 0 else { continue }
var timingInfo = [CMSampleTimingInfo](repeating: CMSampleTimingInfo(), count: count)
CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: count, arrayToFill: &timingInfo, entriesNeededOut: nil)
for i in 0..<count { if let nb = Self.remapAudioBuffer(sampleBuffer, offset: recordStartTime) {
timingInfo[i].decodeTimeStamp = .invalid
timingInfo[i].presentationTimeStamp = CMTimeSubtract(timingInfo[i].presentationTimeStamp, firstTime)
}
var newBuffer: CMSampleBuffer?
CMSampleBufferCreateCopyWithNewTiming(allocator: kCFAllocatorDefault, sampleBuffer: sampleBuffer, sampleTimingEntryCount: count, sampleTimingArray: &timingInfo, sampleBufferOut: &newBuffer)
if let nb = newBuffer {
if !writerInput.append(nb) { if !writerInput.append(nb) {
return return
} }
@ -281,7 +318,26 @@ final class PlayerRecorder: NSObject {
} }
} }
// MARK: - Timer /// sample buffer
private nonisolated static func remapAudioBuffer(_ sampleBuffer: CMSampleBuffer, offset: CMTime) -> 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..<count {
timingInfo[i].decodeTimeStamp = .invalid
timingInfo[i].presentationTimeStamp = CMTimeSubtract(timingInfo[i].presentationTimeStamp, offset)
}
var newBuffer: CMSampleBuffer?
CMSampleBufferCreateCopyWithNewTiming(allocator: kCFAllocatorDefault, sampleBuffer: sampleBuffer, sampleTimingEntryCount: count, sampleTimingArray: &timingInfo, sampleBufferOut: &newBuffer)
return newBuffer
}
// MARK: - Timer ()
private func startTimer() { private func startTimer() {
startDate = Date() startDate = Date()
durationText = "00:00" durationText = "00:00"