视频修复: - 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
395 lines
15 KiB
Swift
395 lines
15 KiB
Swift
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
|