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