MiniPlayer/Sources/PlayerRecorder.swift

323 lines
12 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
@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
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
startTimer()
startCaptureLoop()
// URL
startAudioCapture(sourceURL: sourceURL, startTime: startTime, audioWriterInput: aInput)
}
///
func stopRecording() {
guard isRecording else { return }
isRecording = false
isRunning = false
stopTimer()
stopAudioCapture()
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)
}
}
}
}
// MARK: -
private func startCaptureLoop() {
guard let vInput = videoInput else { return }
let queue = DispatchQueue(label: "recorder.video.capture")
vInput.requestMediaDataWhenReady(on: queue) { [weak self] in
guard let self = self, self.isRunning else { return }
Task { @MainActor in
self.captureVideoFrame()
}
}
}
private func captureVideoFrame() {
guard let output = weakOutput, let input = videoInput, input.isReadyForMoreMediaData else { return }
let hostTime = CACurrentMediaTime()
let itemTime = output.itemTime(forHostTime: hostTime)
guard output.hasNewPixelBuffer(forItemTime: itemTime),
let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) else { return }
// session
if firstFrameTime == nil {
firstFrameTime = itemTime
writer?.startSession(atSourceTime: itemTime)
isRunning = true
}
//
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 {
_ = input.append(sb)
}
}
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
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
}
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
self?.readAudioLoop(reader: reader, output: output)
}
}
} catch {
print("[Recorder] Audio capture setup failed: \(error)")
}
}
}
private nonisolated func readAudioLoop(reader: AVAssetReader, output: AVAssetReaderTrackOutput) {
while reader.status == .reading {
guard let sampleBuffer = output.copyNextSampleBuffer() else {
break
}
// MainActor
Task { @MainActor [weak self] in
guard let self = self, self.isRunning else { return }
guard let firstTime = self.firstFrameTime else { return }
guard let input = self.audioInput, input.isReadyForMoreMediaData else { return }
let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
let relativeTime = CMTimeSubtract(pts, firstTime)
guard relativeTime.seconds >= 0 else { return }
// sample buffer with new time
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 {
_ = input.append(nb)
}
}
// MainActor
Thread.sleep(forTimeInterval: 0.005)
}
}
private func stopAudioCapture() {
audioReader?.cancelReading()
audioReader = nil
audioReaderOutput = nil
}
// 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