MiniPlayer/Sources/PlayerRecorder.swift
yumoqing ff4902ffe6 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
2026-06-24 22:08:09 +08:00

395 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import AVFoundation
#if os(macOS)
import AppKit
/// AVPlayerItemVideoOutput + URL mp4
@MainActor
final class PlayerRecorder: NSObject {
private var writer: AVAssetWriter?
private var videoInput: AVAssetWriterInput?
private var audioInput: AVAssetWriterInput?
private var timer: Timer?
private var startDate: Date?
private var weakOutput: AVPlayerItemVideoOutput?
nonisolated(unsafe) private var isRunning = false
//
private var lastPixelBuffer: CVPixelBuffer?
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 durationText = "00:00"
var onRecordingSaved: ((URL) -> Void)?
var onError: ((String) -> Void)?
private var tempURL: URL?
private var saveDirectory: URL {
let movies = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first!
let dir = movies.appendingPathComponent("MiniPlayer", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
// MARK: -
func startRecording(from output: AVPlayerItemVideoOutput, sourceURL: URL, startTime: CMTime) {
guard !isRecording else { return }
weakOutput = output
lastPixelBuffer = nil
captureFrameCount = 0
isRunning = false
recordStartTime = startTime //
audioStartHostTime = 0
pendingAudioSample = nil
//
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
let filename = "Recording_\(formatter.string(from: Date())).mp4"
let url = saveDirectory.appendingPathComponent(filename)
tempURL = url
do {
writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
} catch {
onError?("Cannot create writer: \(error.localizedDescription)")
return
}
guard let writer = writer else { return }
//
let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: 1920,
AVVideoHeightKey: 1080
]
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
vInput.expectsMediaDataInRealTime = true
if writer.canAdd(vInput) {
writer.add(vInput)
videoInput = vInput
}
//
let audioSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 2,
AVEncoderBitRateKey: 128000
]
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
aInput.expectsMediaDataInRealTime = true
if writer.canAdd(aInput) {
writer.add(aInput)
audioInput = aInput
}
writer.startWriting()
// session 0
writer.startSession(atSourceTime: .zero)
//
isRecording = true
isRunning = true
startTimer()
startCaptureLoop()
//
startAudioCapture(sourceURL: sourceURL, startTime: startTime, audioWriterInput: aInput)
}
// MARK: -
func stopRecording() {
guard isRecording else { return }
isRecording = false
isRunning = false
captureTimer?.invalidate()
captureTimer = nil
stopTimer()
// timer
audioFeedTimer?.cancel()
audioFeedTimer = nil
// reader
audioReader?.cancelReading()
audioReader = nil
audioReaderOutput = nil
pendingAudioSample = nil
videoInput?.markAsFinished()
audioInput?.markAsFinished()
writer?.finishWriting { [weak self] in
Task { @MainActor in
guard let self = self else { return }
if let url = self.tempURL {
let count = self.captureFrameCount
print("[Recorder] Finish writing: \(count) video frames")
self.showSaveDialog(tempURL: url)
}
}
}
lastPixelBuffer = nil
}
// MARK: - (30fps Timer)
private var captureTimer: Timer?
private func startCaptureLoop() {
captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.captureVideoFrame()
}
}
}
private func captureVideoFrame() {
guard isRunning, let output = weakOutput, let input = videoInput,
input.isReadyForMoreMediaData else { return }
let hostTime = CACurrentMediaTime()
let itemTime = output.itemTime(forHostTime: hostTime)
//
if let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) {
lastPixelBuffer = pb
}
guard let pb = lastPixelBuffer else { return }
// itemTime - recordStartTime
let relativeTime = CMTimeSubtract(itemTime, recordStartTime)
guard relativeTime.seconds >= 0 else { return }
let sampleBuffer = Self.createSampleBuffer(from: pb, time: relativeTime)
if let sb = sampleBuffer {
if input.append(sb) {
captureFrameCount += 1
}
}
}
private static func createSampleBuffer(from pixelBuffer: CVPixelBuffer, time: CMTime) -> CMSampleBuffer? {
var info = CMSampleTimingInfo()
info.presentationTimeStamp = time
info.duration = CMTime(value: 1, timescale: 30)
info.decodeTimeStamp = .invalid
var formatDescription: CMFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &formatDescription)
guard let fd = formatDescription else { return nil }
var sampleBuffer: CMSampleBuffer?
CMSampleBufferCreateReadyWithImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescription: fd, sampleTiming: &info, sampleBufferOut: &sampleBuffer)
return sampleBuffer
}
// MARK: - DispatchSourceTimer
private func startAudioCapture(sourceURL: URL, startTime: CMTime, audioWriterInput: AVAssetWriterInput) {
let asset = AVURLAsset(url: sourceURL)
Task {
do {
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
guard let audioTrack = audioTracks.first else {
print("[Recorder] No audio track in source")
return
}
guard self.isRunning else { return }
let reader = try AVAssetReader(asset: asset)
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
// wall-clock
self.audioStartHostTime = CACurrentMediaTime()
// DispatchSourceTimer 20ms
let queue = DispatchQueue(label: "recorder.audio.feed")
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 {
print("[Recorder] Audio capture setup failed: \(error)")
}
}
}
/// 20ms
private nonisolated func feedAudioSamplesRealtime(writerInput: AVAssetWriterInput,
readerOutput: AVAssetReaderTrackOutput,
reader: AVAssetReader) {
guard isRunning, reader.status == .reading else {
if isRunning && reader.status != .reading {
writerInput.markAsFinished()
audioFeedTimer?.cancel()
}
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()
audioFeedTimer?.cancel()
}
return
}
let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
let relative = CMTimeSubtract(pts, recordStartTime)
// tick
if relative.seconds > maxAudioTime {
pendingAudioSample = sampleBuffer
return
}
// pts recordStartTime
guard relative.seconds >= 0 else { continue }
if let nb = Self.remapAudioBuffer(sampleBuffer, offset: recordStartTime) {
if !writerInput.append(nb) {
return
}
}
}
}
/// 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() {
startDate = Date()
durationText = "00:00"
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self = self, let start = self.startDate else { return }
let elapsed = Int(Date().timeIntervalSince(start))
let m = elapsed / 60
let s = elapsed % 60
let h = m / 60
if h > 0 {
self.durationText = String(format: "%d:%02d:%02d", h, m % 60, s)
} else {
self.durationText = String(format: "%02d:%02d", m, s)
}
}
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
startDate = nil
durationText = "00:00"
}
// MARK: -
private func showSaveDialog(tempURL: URL) {
let panel = NSSavePanel()
panel.nameFieldStringValue = tempURL.lastPathComponent
panel.allowedContentTypes = [.mpeg4Movie]
panel.directoryURL = saveDirectory
panel.title = "Save Recording"
panel.begin { [weak self] response in
guard let self = self else { return }
if response == .OK, let destURL = panel.url {
do {
if FileManager.default.fileExists(atPath: destURL.path) {
try FileManager.default.removeItem(at: destURL)
}
try FileManager.default.moveItem(at: tempURL, to: destURL)
self.onRecordingSaved?(destURL)
} catch {
self.onError?("Save failed: \(error.localizedDescription)")
}
} else {
try? FileManager.default.removeItem(at: tempURL)
}
}
}
}
#endif