import AVFoundation #if os(macOS) import AppKit /// 视频源录制器:从 AVPlayerItemVideoOutput 抓帧 + 源 URL 音频轨道,写入 mp4 @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 weakOutput: AVPlayerItemVideoOutput? private var firstFrameTime: CMTime? private var audioReader: AVAssetReader? private var audioReaderOutput: AVAssetReaderTrackOutput? private var audioCaptureQueue: DispatchQueue? private var isRunning = false /// 线程安全的音频停止标志,后台队列直接读取 nonisolated(unsafe) private var audioStopped: Bool = false /// 音频线程读取 firstFrameTime 的镜像 nonisolated(unsafe) private var audioFirstFrameTime: CMTime? // 音频预加载状态 private var pendingAudioAsset: AVURLAsset? private var pendingAudioTrack: AVAssetTrack? @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 } /// 开始录制 func startRecording(from output: AVPlayerItemVideoOutput, sourceURL: URL) { guard !isRecording else { return } weakOutput = output firstFrameTime = nil isRunning = false audioStopped = false // 临时文件 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 } guard let writer = writer else { 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) { writer.add(vInput) videoInput = vInput } // 音频输入 let audioSettings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 44100, AVNumberOfChannelsKey: 2, AVEncoderBitRateKey: 128000 ] let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) aInput.expectsMediaDataInRealTime = true if writer.canAdd(aInput) { writer.add(aInput) audioInput = aInput } writer.startWriting() // 启动视频抓帧 isRecording = true isRunning = true // 必须在 startCaptureLoop 之前设置 startTimer() startCaptureLoop() // 准备音频读取(等待 firstFrameTime 后才真正开始) prepareAudioCapture(sourceURL: sourceURL) } /// 停止录制 func stopRecording() { guard isRecording else { return } isRecording = false isRunning = false captureTimer?.invalidate() captureTimer = nil stopTimer() stopAudioCapture() 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 var captureTimer: Timer? private func startCaptureLoop() { // 用 Timer 每 1/30 秒抓一帧 captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in Task { @MainActor in self?.captureVideoFrame() } } } private func captureVideoFrame() { guard let output = weakOutput, let input = videoInput, input.isReadyForMoreMediaData else { return } let hostTime = CACurrentMediaTime() let itemTime = output.itemTime(forHostTime: hostTime) guard output.hasNewPixelBuffer(forItemTime: itemTime), let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) else { return } // 第一帧:用它的时间作为 session 起点 if firstFrameTime == nil { firstFrameTime = itemTime audioFirstFrameTime = itemTime // 同步给音频线程 writer?.startSession(atSourceTime: itemTime) isRunning = true // 音频预加载完成后,用 firstFrameTime 作为起始时间开始读取 beginAudioReading(startTime: itemTime) } // 相对时间戳 guard let firstTime = firstFrameTime else { return } let relativeTime = CMTimeSubtract(itemTime, firstTime) guard relativeTime.seconds >= 0 else { return } let sampleBuffer = Self.createSampleBuffer(from: pb, time: relativeTime) if let sb = sampleBuffer { _ = input.append(sb) } } private static func createSampleBuffer(from pixelBuffer: CVPixelBuffer, time: CMTime) -> CMSampleBuffer? { var info = CMSampleTimingInfo() info.presentationTimeStamp = time info.duration = CMTime(value: 1, timescale: 30) info.decodeTimeStamp = .invalid 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 } // MARK: - 音频读取(从源 URL) /// 预加载音频轨道(异步),不立即开始读取 private func prepareAudioCapture(sourceURL: URL) { let asset = AVURLAsset(url: sourceURL) pendingAudioAsset = asset pendingAudioTrack = nil Task { do { let audioTracks = try await asset.loadTracks(withMediaType: .audio) guard let audioTrack = audioTracks.first else { print("[Recorder] No audio track in source") return } self.pendingAudioTrack = audioTrack // 如果 firstFrameTime 已经设置好了,立即开始读取 if let fft = self.firstFrameTime { self.beginAudioReading(startTime: fft) } } catch { print("[Recorder] Audio track load failed: \(error)") } } } /// 用 firstFrameTime 作为起始时间,创建 reader 并开始后台读取 private func beginAudioReading(startTime: CMTime) { guard let asset = pendingAudioAsset, let audioTrack = pendingAudioTrack else { return // 轨道还没加载好,prepareAudioCapture 的回调会再次调用 } guard let aInput = audioInput else { return } // 防止重复启动 guard audioReader == nil else { return } do { let reader = try AVAssetReader(asset: asset) let output = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: [ AVFormatIDKey: kAudioFormatLinearPCM, AVSampleRateKey: 44100, AVNumberOfChannelsKey: 2, AVLinearPCMBitDepthKey: 16, AVLinearPCMIsFloatKey: false, AVLinearPCMIsBigEndianKey: false, AVLinearPCMIsNonInterleaved: false ]) output.alwaysCopiesSampleData = false // 从视频第一帧时间开始读取,确保音视频同步 reader.timeRange = CMTimeRange(start: startTime, duration: .positiveInfinity) if reader.canAdd(output) { reader.add(output) reader.startReading() self.audioReader = reader self.audioReaderOutput = output // 后台线程读取音频 let queue = DispatchQueue(label: "recorder.audio.capture") self.audioCaptureQueue = queue queue.async { [weak self] in guard let self = self else { return } self.readAudioLoop(reader: reader, output: output, audioInput: aInput) } } } catch { print("[Recorder] Audio capture setup failed: \(error)") } // 清理预加载状态 pendingAudioAsset = nil pendingAudioTrack = nil } private nonisolated func readAudioLoop(reader: AVAssetReader, output: AVAssetReaderTrackOutput, audioInput: AVAssetWriterInput) { while !audioStopped && reader.status == .reading { guard let sampleBuffer = output.copyNextSampleBuffer() else { break } // 直接在后台队列处理,不走 MainActor(避免队列积压) guard let firstTime = audioFirstFrameTime else { // 视频第一帧还没到,跳过这个音频 buffer continue } guard audioInput.isReadyForMoreMediaData else { continue } let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) let relativeTime = CMTimeSubtract(pts, firstTime) guard relativeTime.seconds >= 0 else { continue } // 重建 sample buffer with new time var count: CMItemCount = 0 CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: 0, arrayToFill: nil, entriesNeededOut: &count) var timingInfo = [CMSampleTimingInfo](repeating: CMSampleTimingInfo(), count: count) CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: count, arrayToFill: &timingInfo, entriesNeededOut: nil) for i in 0.. 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