MiniPlayer/Sources/PlayerRecorder.swift
yumoqing 06345b07de fix: 视频只有一帧 + 音频长度不对
视频修复:
- 移除 hasNewPixelBuffer 依赖,改为缓存 lastPixelBuffer
- 每 1/30s 始终写入帧(新帧或复用上一帧),确保静态场景也持续输出
- 加 isRunning 检查防止停止后继续写入

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

音视频同步:
- 视频用 itemTime 相对 firstFrameTime 的时间戳
- 音频用源文件 pts 相对 firstFrameTime 的时间戳
- 两者共享同一时间基准
2026-06-24 21:19:10 +08:00

339 lines
13 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?
private var firstFrameTime: CMTime?
private var audioReader: AVAssetReader?
private var audioReaderOutput: AVAssetReaderTrackOutput?
private var audioCaptureQueue: DispatchQueue?
private var isRunning = false
//
private var lastPixelBuffer: CVPixelBuffer?
private var captureFrameCount: Int = 0
@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
}
///
func startRecording(from output: AVPlayerItemVideoOutput, sourceURL: URL, startTime: CMTime) {
guard !isRecording else { return }
weakOutput = output
firstFrameTime = nil
lastPixelBuffer = nil
captureFrameCount = 0
isRunning = false
//
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()
//
isRecording = true
isRunning = true
startTimer()
startCaptureLoop()
//
startAudioCapture(sourceURL: sourceURL, startTime: startTime, audioWriterInput: aInput)
}
///
func stopRecording() {
guard isRecording else { return }
isRecording = false
isRunning = false
captureTimer?.invalidate()
captureTimer = nil
stopTimer()
// cancel reader markAsFinished
audioReader?.cancelReading()
audioReader = nil
audioReaderOutput = nil
videoInput?.markAsFinished()
audioInput?.markAsFinished()
writer?.finishWriting { [weak self] in
Task { @MainActor in
guard let self = self else { return }
if let url = self.tempURL {
self.showSaveDialog(tempURL: url)
}
}
}
lastPixelBuffer = nil
}
// MARK: -
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 }
// session
if firstFrameTime == nil {
firstFrameTime = itemTime
writer?.startSession(atSourceTime: itemTime)
}
guard let firstTime = firstFrameTime else { return }
let relativeTime = CMTimeSubtract(itemTime, firstTime)
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: - URL requestMediaDataWhenReady
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
// 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 {
print("[Recorder] Audio capture setup failed: \(error)")
}
}
}
/// requestMediaDataWhenReady writer
private func feedAudioSamples(writerInput: AVAssetWriterInput,
readerOutput: AVAssetReaderTrackOutput,
reader: AVAssetReader) {
while writerInput.isReadyForMoreMediaData {
guard isRunning, reader.status == .reading,
let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
//
if isRunning {
writerInput.markAsFinished()
}
return
}
//
guard let firstTime = self.firstFrameTime else {
// buffer
continue
}
var count: CMItemCount = 0
CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: 0, arrayToFill: nil, entriesNeededOut: &count)
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, firstTime)
}
var newBuffer: CMSampleBuffer?
CMSampleBufferCreateCopyWithNewTiming(allocator: kCFAllocatorDefault, sampleBuffer: sampleBuffer, sampleTimingEntryCount: count, sampleTimingArray: &timingInfo, sampleBufferOut: &newBuffer)
if let nb = newBuffer {
if !writerInput.append(nb) {
return
}
}
}
}
// 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