187 lines
6.4 KiB
Swift
187 lines
6.4 KiB
Swift
import AVFoundation
|
||
#if os(macOS)
|
||
import AppKit
|
||
|
||
/// 视频源录制器:从 AVPlayerItemVideoOutput 抓帧,用 AVAssetWriter 写入文件
|
||
@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 displayLink: CVDisplayLink?
|
||
private var weakOutput: AVPlayerItemVideoOutput?
|
||
|
||
@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
|
||
}
|
||
|
||
/// 开始录制(从 videoOutput 抓帧)
|
||
func startRecording(from output: AVPlayerItemVideoOutput) {
|
||
guard !isRecording else { return }
|
||
|
||
weakOutput = output
|
||
|
||
// 创建临时文件
|
||
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
|
||
}
|
||
|
||
// 视频输入
|
||
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) == true {
|
||
writer?.add(vInput)
|
||
videoInput = vInput
|
||
}
|
||
|
||
writer?.startWriting()
|
||
writer?.startSession(atSourceTime: .zero)
|
||
|
||
isRecording = true
|
||
startTimer()
|
||
startCaptureLoop()
|
||
}
|
||
|
||
/// 停止录制
|
||
func stopRecording() {
|
||
guard isRecording else { return }
|
||
isRecording = false
|
||
stopTimer()
|
||
stopCaptureLoop()
|
||
|
||
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() {
|
||
let queue = DispatchQueue(label: "recorder.capture")
|
||
videoInput?.requestMediaDataWhenReady(on: queue) { [weak self] in
|
||
self?.captureFrame()
|
||
}
|
||
}
|
||
|
||
private func captureFrame() {
|
||
guard let output = weakOutput, let input = videoInput, input.isReadyForMoreMediaData else { return }
|
||
|
||
let time = output.itemTime(forHostTime: CACurrentMediaTime())
|
||
if output.hasNewPixelBuffer(forItemTime: time), let pb = output.copyPixelBuffer(forItemTime: time, itemTimeForDisplay: nil) {
|
||
let sampleBuffer = createSampleBuffer(from: pb, time: time)
|
||
if let sb = sampleBuffer {
|
||
input.append(sb)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func createSampleBuffer(from pixelBuffer: CVPixelBuffer, time: CMTime) -> CMSampleBuffer? {
|
||
var info = CMSampleTimingInfo()
|
||
info.presentationTimeStamp = time
|
||
info.duration = CMTime(value: 1, timescale: 30)
|
||
|
||
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
|
||
}
|
||
|
||
private func stopCaptureLoop() {
|
||
// requestMediaDataWhenReady 会在 isReadyForMoreMediaData=false 时停止
|
||
}
|
||
|
||
// 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
|