Record video source via AVPlayerItemVideoOutput, not screen capture
This commit is contained in:
parent
87c66c3c70
commit
5d9e93ba95
@ -84,7 +84,8 @@ final class PlayerBridge: ObservableObject {
|
|||||||
private var fullscreenWindow: NSWindow?
|
private var fullscreenWindow: NSWindow?
|
||||||
private var playlistWindow: NSWindow?
|
private var playlistWindow: NSWindow?
|
||||||
private var fullscreenEventMonitor: Any?
|
private var fullscreenEventMonitor: Any?
|
||||||
let screenRecorder = ScreenRecorder()
|
let screenRecorder = PlayerRecorder()
|
||||||
|
var videoOutput: AVPlayerItemVideoOutput?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - 初始化
|
// MARK: - 初始化
|
||||||
@ -115,7 +116,11 @@ final class PlayerBridge: ObservableObject {
|
|||||||
if screenRecorder.isRecording {
|
if screenRecorder.isRecording {
|
||||||
screenRecorder.stopRecording()
|
screenRecorder.stopRecording()
|
||||||
} else {
|
} else {
|
||||||
screenRecorder.startRecording()
|
guard let output = videoOutput else {
|
||||||
|
showToast("No video playing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
screenRecorder.startRecording(from: output)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@ -160,6 +165,17 @@ final class PlayerBridge: ObservableObject {
|
|||||||
itemStatusObserver = nil
|
itemStatusObserver = nil
|
||||||
|
|
||||||
let playerItem = AVPlayerItem(url: item.url)
|
let playerItem = AVPlayerItem(url: item.url)
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
// 添加 video output 用于录制
|
||||||
|
let pixelBufferAttributes: [String: Any] = [
|
||||||
|
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
|
||||||
|
]
|
||||||
|
let output = AVPlayerItemVideoOutput(pixelBufferAttributes: pixelBufferAttributes)
|
||||||
|
playerItem.add(output)
|
||||||
|
videoOutput = output
|
||||||
|
#endif
|
||||||
|
|
||||||
player.replaceCurrentItem(with: playerItem)
|
player.replaceCurrentItem(with: playerItem)
|
||||||
player.play()
|
player.play()
|
||||||
isPlaying = true
|
isPlaying = true
|
||||||
|
|||||||
186
Sources/PlayerRecorder.swift
Normal file
186
Sources/PlayerRecorder.swift
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
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
|
||||||
@ -1,199 +0,0 @@
|
|||||||
import AVFoundation
|
|
||||||
#if os(macOS)
|
|
||||||
import AppKit
|
|
||||||
|
|
||||||
/// 屏幕录制器:录制屏幕 + 麦克风音频,保存到 ~/Movies/MiniPlayer/
|
|
||||||
@MainActor
|
|
||||||
final class ScreenRecorder: NSObject, AVCaptureFileOutputRecordingDelegate {
|
|
||||||
|
|
||||||
private var session: AVCaptureSession?
|
|
||||||
private var fileOutput: AVCaptureMovieFileOutput?
|
|
||||||
private var isConfigured = false
|
|
||||||
private var timer: Timer?
|
|
||||||
private var startDate: Date?
|
|
||||||
|
|
||||||
@Published var isRecording = false
|
|
||||||
@Published var durationText = "00:00"
|
|
||||||
|
|
||||||
var onRecordingSaved: ((URL) -> Void)?
|
|
||||||
var onError: ((String) -> Void)?
|
|
||||||
|
|
||||||
/// 保存目录
|
|
||||||
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() {
|
|
||||||
guard !isRecording else { return }
|
|
||||||
|
|
||||||
// 首次配置 session(避免每次重建)
|
|
||||||
if !isConfigured {
|
|
||||||
guard configureSession() else { return }
|
|
||||||
isConfigured = true
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let fileOutput = fileOutput else {
|
|
||||||
onError?("Recording not ready")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成文件名:录制_2026-06-24_15-30-45.mp4
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
|
|
||||||
let filename = "Recording_\(formatter.string(from: Date())).mp4"
|
|
||||||
let outputURL = saveDirectory.appendingPathComponent(filename)
|
|
||||||
|
|
||||||
// 启动 session 并开始录制
|
|
||||||
if let session = session, !session.isRunning {
|
|
||||||
session.startRunning()
|
|
||||||
}
|
|
||||||
|
|
||||||
fileOutput.startRecording(to: outputURL, recordingDelegate: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 停止录制 → 弹出保存对话框
|
|
||||||
func stopRecording() {
|
|
||||||
guard isRecording, let fileOutput = fileOutput else { return }
|
|
||||||
fileOutput.stopRecording()
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 弹出保存对话框
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func configureSession() -> Bool {
|
|
||||||
let session = AVCaptureSession()
|
|
||||||
session.sessionPreset = .hd1920x1080
|
|
||||||
|
|
||||||
// 屏幕输入(主显示器)
|
|
||||||
guard let displayID = NSScreen.main?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else {
|
|
||||||
onError?("No display found")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let screenInput = AVCaptureScreenInput(displayID: displayID)
|
|
||||||
guard let screenInput = screenInput else {
|
|
||||||
onError?("Cannot create screen input")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
screenInput.capturesMouseClicks = false
|
|
||||||
screenInput.capturesCursor = true
|
|
||||||
|
|
||||||
if session.canAddInput(screenInput) {
|
|
||||||
session.addInput(screenInput)
|
|
||||||
} else {
|
|
||||||
onError?("Cannot add screen input")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 麦克风输入
|
|
||||||
if let audioDevice = AVCaptureDevice.default(for: .audio),
|
|
||||||
let audioInput = try? AVCaptureDeviceInput(device: audioDevice) {
|
|
||||||
if session.canAddInput(audioInput) {
|
|
||||||
session.addInput(audioInput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文件输出
|
|
||||||
let output = AVCaptureMovieFileOutput()
|
|
||||||
if session.canAddOutput(output) {
|
|
||||||
session.addOutput(output)
|
|
||||||
} else {
|
|
||||||
onError?("Cannot add file output")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
self.session = session
|
|
||||||
self.fileOutput = output
|
|
||||||
|
|
||||||
// 请求屏幕录制权限
|
|
||||||
#if canImport(ScreenCaptureKit)
|
|
||||||
if !CGPreflightScreenCaptureAccess() {
|
|
||||||
CGRequestScreenCaptureAccess()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - AVCaptureFileOutputRecordingDelegate
|
|
||||||
nonisolated func fileOutputRecordingDidStart(_ output: AVCaptureFileOutput) {
|
|
||||||
Task { @MainActor in
|
|
||||||
self.isRecording = true
|
|
||||||
self.startTimer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
|
|
||||||
Task { @MainActor in
|
|
||||||
self.isRecording = false
|
|
||||||
self.stopTimer()
|
|
||||||
if let error = error {
|
|
||||||
self.onError?("Recording failed: \(error.localizedDescription)")
|
|
||||||
} else {
|
|
||||||
self.showSaveDialog(tempURL: outputFileURL)
|
|
||||||
}
|
|
||||||
self.session?.stopRunning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
session?.stopRunning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
Loading…
x
Reference in New Issue
Block a user