fix: 视频只有一帧 + 音频长度不对

视频修复:
- 移除 hasNewPixelBuffer 依赖,改为缓存 lastPixelBuffer
- 每 1/30s 始终写入帧(新帧或复用上一帧),确保静态场景也持续输出
- 加 isRunning 检查防止停止后继续写入

音频修复:
- 用 requestMediaDataWhenReady 替代 busy-loop
- writer 控制读取节奏,不再一次性读完全部源音频
- stopRecording 时 cancelReading + markAsFinished 立即停止

音视频同步:
- 视频用 itemTime 相对 firstFrameTime 的时间戳
- 音频用源文件 pts 相对 firstFrameTime 的时间戳
- 两者共享同一时间基准
This commit is contained in:
yumoqing 2026-06-24 21:19:10 +08:00
parent 42f3cf2b84
commit 06345b07de
2 changed files with 86 additions and 110 deletions

View File

@ -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
} }

View File

@ -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,29 +201,10 @@ 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 {
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 readerOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: [
AVFormatIDKey: kAudioFormatLinearPCM, AVFormatIDKey: kAudioFormatLinearPCM,
AVSampleRateKey: 44100, AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 2, AVNumberOfChannelsKey: 2,
@ -233,53 +213,53 @@ final class PlayerRecorder: NSObject {
AVLinearPCMIsBigEndianKey: false, AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false AVLinearPCMIsNonInterleaved: false
]) ])
output.alwaysCopiesSampleData = false readerOutput.alwaysCopiesSampleData = false
// //
reader.timeRange = CMTimeRange(start: startTime, duration: .positiveInfinity) reader.timeRange = CMTimeRange(start: startTime, duration: .positiveInfinity)
if reader.canAdd(output) { if reader.canAdd(readerOutput) {
reader.add(output) reader.add(readerOutput)
reader.startReading() reader.startReading()
self.audioReader = reader self.audioReader = reader
self.audioReaderOutput = output self.audioReaderOutput = readerOutput
// 线 // requestMediaDataWhenReady writer
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
audioWriterInput.requestMediaDataWhenReady(on: queue) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.readAudioLoop(reader: reader, output: output, audioInput: aInput) self.feedAudioSamples(writerInput: audioWriterInput, readerOutput: readerOutput, reader: reader)
} }
} }
} 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) { /// requestMediaDataWhenReady writer
while !audioStopped && reader.status == .reading { private func feedAudioSamples(writerInput: AVAssetWriterInput,
guard let sampleBuffer = output.copyNextSampleBuffer() else { readerOutput: AVAssetReaderTrackOutput,
break reader: AVAssetReader) {
while writerInput.isReadyForMoreMediaData {
guard isRunning, reader.status == .reading,
let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
//
if isRunning {
writerInput.markAsFinished()
}
return
} }
// 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,16 +274,11 @@ 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