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:
parent
06345b07de
commit
ff4902ffe6
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user