fix: 视频只有一帧 + 音频长度不对
视频修复: - 移除 hasNewPixelBuffer 依赖,改为缓存 lastPixelBuffer - 每 1/30s 始终写入帧(新帧或复用上一帧),确保静态场景也持续输出 - 加 isRunning 检查防止停止后继续写入 音频修复: - 用 requestMediaDataWhenReady 替代 busy-loop - writer 控制读取节奏,不再一次性读完全部源音频 - stopRecording 时 cancelReading + markAsFinished 立即停止 音视频同步: - 视频用 itemTime 相对 firstFrameTime 的时间戳 - 音频用源文件 pts 相对 firstFrameTime 的时间戳 - 两者共享同一时间基准
This commit is contained in:
parent
42f3cf2b84
commit
06345b07de
@ -122,7 +122,8 @@ final class PlayerBridge: ObservableObject {
|
|||||||
}
|
}
|
||||||
guard !queue.isEmpty else { return }
|
guard !queue.isEmpty else { return }
|
||||||
let sourceURL = queue[currentIndex].url
|
let sourceURL = queue[currentIndex].url
|
||||||
screenRecorder.startRecording(from: output, sourceURL: sourceURL)
|
let startTime = player.currentTime()
|
||||||
|
screenRecorder.startRecording(from: output, sourceURL: sourceURL, startTime: startTime)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,14 +17,10 @@ final class PlayerRecorder: NSObject {
|
|||||||
private var audioReaderOutput: AVAssetReaderTrackOutput?
|
private var audioReaderOutput: AVAssetReaderTrackOutput?
|
||||||
private var audioCaptureQueue: DispatchQueue?
|
private var audioCaptureQueue: DispatchQueue?
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
/// 线程安全的音频停止标志,后台队列直接读取
|
|
||||||
nonisolated(unsafe) private var audioStopped: Bool = false
|
|
||||||
/// 音频线程读取 firstFrameTime 的镜像
|
|
||||||
nonisolated(unsafe) private var audioFirstFrameTime: CMTime?
|
|
||||||
|
|
||||||
// 音频预加载状态
|
// 视频:缓存上一帧,确保静态场景也能持续写入
|
||||||
private var pendingAudioAsset: AVURLAsset?
|
private var lastPixelBuffer: CVPixelBuffer?
|
||||||
private var pendingAudioTrack: AVAssetTrack?
|
private var captureFrameCount: Int = 0
|
||||||
|
|
||||||
@Published var isRecording = false
|
@Published var isRecording = false
|
||||||
@Published var durationText = "00:00"
|
@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 }
|
guard !isRecording else { return }
|
||||||
|
|
||||||
weakOutput = output
|
weakOutput = output
|
||||||
firstFrameTime = nil
|
firstFrameTime = nil
|
||||||
|
lastPixelBuffer = nil
|
||||||
|
captureFrameCount = 0
|
||||||
isRunning = false
|
isRunning = false
|
||||||
audioStopped = false
|
|
||||||
|
|
||||||
// 临时文件
|
// 临时文件
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
@ -97,12 +94,12 @@ final class PlayerRecorder: NSObject {
|
|||||||
|
|
||||||
// 启动视频抓帧
|
// 启动视频抓帧
|
||||||
isRecording = true
|
isRecording = true
|
||||||
isRunning = true // 必须在 startCaptureLoop 之前设置
|
isRunning = true
|
||||||
startTimer()
|
startTimer()
|
||||||
startCaptureLoop()
|
startCaptureLoop()
|
||||||
|
|
||||||
// 准备音频读取(等待 firstFrameTime 后才真正开始)
|
// 启动音频读取(从播放位置开始)
|
||||||
prepareAudioCapture(sourceURL: sourceURL)
|
startAudioCapture(sourceURL: sourceURL, startTime: startTime, audioWriterInput: aInput)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 停止录制
|
/// 停止录制
|
||||||
@ -113,7 +110,11 @@ final class PlayerRecorder: NSObject {
|
|||||||
captureTimer?.invalidate()
|
captureTimer?.invalidate()
|
||||||
captureTimer = nil
|
captureTimer = nil
|
||||||
stopTimer()
|
stopTimer()
|
||||||
stopAudioCapture()
|
|
||||||
|
// 停止音频:先 cancel reader,再 markAsFinished
|
||||||
|
audioReader?.cancelReading()
|
||||||
|
audioReader = nil
|
||||||
|
audioReaderOutput = nil
|
||||||
|
|
||||||
videoInput?.markAsFinished()
|
videoInput?.markAsFinished()
|
||||||
audioInput?.markAsFinished()
|
audioInput?.markAsFinished()
|
||||||
@ -126,13 +127,14 @@ final class PlayerRecorder: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastPixelBuffer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 视频抓帧
|
// MARK: - 视频抓帧
|
||||||
private var captureTimer: Timer?
|
private var captureTimer: Timer?
|
||||||
|
|
||||||
private func startCaptureLoop() {
|
private func startCaptureLoop() {
|
||||||
// 用 Timer 每 1/30 秒抓一帧
|
|
||||||
captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
|
captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.captureVideoFrame()
|
self?.captureVideoFrame()
|
||||||
@ -141,33 +143,34 @@ final class PlayerRecorder: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func captureVideoFrame() {
|
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 hostTime = CACurrentMediaTime()
|
||||||
let itemTime = output.itemTime(forHostTime: hostTime)
|
let itemTime = output.itemTime(forHostTime: hostTime)
|
||||||
|
|
||||||
guard output.hasNewPixelBuffer(forItemTime: itemTime),
|
// 尝试获取新帧,否则复用上一帧(确保静态场景也持续写入)
|
||||||
let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) else { return }
|
if let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) {
|
||||||
|
lastPixelBuffer = pb
|
||||||
// 第一帧:用它的时间作为 session 起点
|
}
|
||||||
if firstFrameTime == nil {
|
|
||||||
firstFrameTime = itemTime
|
guard let pb = lastPixelBuffer else { return }
|
||||||
audioFirstFrameTime = itemTime // 同步给音频线程
|
|
||||||
writer?.startSession(atSourceTime: itemTime)
|
// 第一帧:启动 session
|
||||||
isRunning = true
|
if firstFrameTime == nil {
|
||||||
// 音频预加载完成后,用 firstFrameTime 作为起始时间开始读取
|
firstFrameTime = itemTime
|
||||||
beginAudioReading(startTime: itemTime)
|
writer?.startSession(atSourceTime: itemTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 相对时间戳
|
|
||||||
guard let firstTime = firstFrameTime else { return }
|
guard let firstTime = firstFrameTime else { return }
|
||||||
let relativeTime = CMTimeSubtract(itemTime, firstTime)
|
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)
|
||||||
if let sb = sampleBuffer {
|
if let sb = sampleBuffer {
|
||||||
_ = input.append(sb)
|
if input.append(sb) {
|
||||||
|
captureFrameCount += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,13 +190,9 @@ final class PlayerRecorder: NSObject {
|
|||||||
return sampleBuffer
|
return sampleBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 音频读取(从源 URL)
|
// MARK: - 音频读取(从源 URL,用 requestMediaDataWhenReady 控制节奏)
|
||||||
|
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 {
|
||||||
@ -202,84 +201,65 @@ final class PlayerRecorder: NSObject {
|
|||||||
print("[Recorder] No audio track in source")
|
print("[Recorder] No audio track in source")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.pendingAudioTrack = audioTrack
|
guard self.isRunning else { return }
|
||||||
// 如果 firstFrameTime 已经设置好了,立即开始读取
|
|
||||||
if let fft = self.firstFrameTime {
|
let reader = try AVAssetReader(asset: asset)
|
||||||
self.beginAudioReading(startTime: fft)
|
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 {
|
} catch {
|
||||||
print("[Recorder] Audio track load failed: \(error)")
|
print("[Recorder] Audio capture setup failed: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用 firstFrameTime 作为起始时间,创建 reader 并开始后台读取
|
/// 由 requestMediaDataWhenReady 回调,writer 需要数据时才读取
|
||||||
private func beginAudioReading(startTime: CMTime) {
|
private func feedAudioSamples(writerInput: AVAssetWriterInput,
|
||||||
guard let asset = pendingAudioAsset, let audioTrack = pendingAudioTrack else {
|
readerOutput: AVAssetReaderTrackOutput,
|
||||||
return // 轨道还没加载好,prepareAudioCapture 的回调会再次调用
|
reader: AVAssetReader) {
|
||||||
}
|
while writerInput.isReadyForMoreMediaData {
|
||||||
guard let aInput = audioInput else { return }
|
guard isRunning, reader.status == .reading,
|
||||||
// 防止重复启动
|
let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
|
||||||
guard audioReader == nil else { return }
|
// 录制已停止或读取完毕
|
||||||
|
if isRunning {
|
||||||
do {
|
writerInput.markAsFinished()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
return
|
||||||
} 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接在后台队列处理,不走 MainActor(避免队列积压)
|
// 重映射时间戳:源文件时间 → 相对于录制开始的时间
|
||||||
guard let firstTime = audioFirstFrameTime else {
|
guard let firstTime = self.firstFrameTime else {
|
||||||
// 视频第一帧还没到,跳过这个音频 buffer
|
// 视频第一帧还没到,丢弃这个 buffer
|
||||||
continue
|
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
|
var count: CMItemCount = 0
|
||||||
CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: 0, arrayToFill: nil, entriesNeededOut: &count)
|
CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: 0, arrayToFill: nil, entriesNeededOut: &count)
|
||||||
var timingInfo = [CMSampleTimingInfo](repeating: CMSampleTimingInfo(), count: 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)
|
CMSampleBufferCreateCopyWithNewTiming(allocator: kCFAllocatorDefault, sampleBuffer: sampleBuffer, sampleTimingEntryCount: count, sampleTimingArray: &timingInfo, sampleBufferOut: &newBuffer)
|
||||||
|
|
||||||
if let nb = 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
|
// MARK: - Timer
|
||||||
private func startTimer() {
|
private func startTimer() {
|
||||||
startDate = Date()
|
startDate = Date()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user