Record video source via AVPlayerItemVideoOutput, not screen capture

This commit is contained in:
yumoqing 2026-06-24 20:04:08 +08:00
parent 87c66c3c70
commit 5d9e93ba95
3 changed files with 204 additions and 201 deletions

View File

@ -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

View 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

View File

@ -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