From 5d9e93ba953c6fff94d153e065e7de7bdd73d65f Mon Sep 17 00:00:00 2001 From: yumoqing Date: Wed, 24 Jun 2026 20:04:08 +0800 Subject: [PATCH] Record video source via AVPlayerItemVideoOutput, not screen capture --- Sources/PlayerBridge.swift | 20 +++- Sources/PlayerRecorder.swift | 186 ++++++++++++++++++++++++++++++++ Sources/ScreenRecorder.swift | 199 ----------------------------------- 3 files changed, 204 insertions(+), 201 deletions(-) create mode 100644 Sources/PlayerRecorder.swift delete mode 100644 Sources/ScreenRecorder.swift diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index d512247..2397d55 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -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 diff --git a/Sources/PlayerRecorder.swift b/Sources/PlayerRecorder.swift new file mode 100644 index 0000000..3738ba0 --- /dev/null +++ b/Sources/PlayerRecorder.swift @@ -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 diff --git a/Sources/ScreenRecorder.swift b/Sources/ScreenRecorder.swift deleted file mode 100644 index 88fdb44..0000000 --- a/Sources/ScreenRecorder.swift +++ /dev/null @@ -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