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 playlistWindow: NSWindow?
|
||||
private var fullscreenEventMonitor: Any?
|
||||
let screenRecorder = ScreenRecorder()
|
||||
let screenRecorder = PlayerRecorder()
|
||||
var videoOutput: AVPlayerItemVideoOutput?
|
||||
#endif
|
||||
|
||||
// MARK: - 初始化
|
||||
@ -115,7 +116,11 @@ final class PlayerBridge: ObservableObject {
|
||||
if screenRecorder.isRecording {
|
||||
screenRecorder.stopRecording()
|
||||
} else {
|
||||
screenRecorder.startRecording()
|
||||
guard let output = videoOutput else {
|
||||
showToast("No video playing")
|
||||
return
|
||||
}
|
||||
screenRecorder.startRecording(from: output)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@ -160,6 +165,17 @@ final class PlayerBridge: ObservableObject {
|
||||
itemStatusObserver = nil
|
||||
|
||||
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.play()
|
||||
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