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? nonisolated(unsafe) private var isRunning = false // 视频:缓存上一帧,确保静态场景也能持续写入 private var lastPixelBuffer: CVPixelBuffer? private var captureFrameCount: Int = 0 // 音频 private var audioReader: AVAssetReader? private var audioReaderOutput: AVAssetReaderTrackOutput? private nonisolated(unsafe) var audioFeedTimer: DispatchSourceTimer? // 共享时间基准:player.currentTime() at record start nonisolated(unsafe) private var recordStartTime: CMTime = .zero nonisolated(unsafe) private var audioStartHostTime: CFTimeInterval = 0 nonisolated(unsafe) private var pendingAudioSample: CMSampleBuffer? @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 } // MARK: - 开始录制 func startRecording(from output: AVPlayerItemVideoOutput, sourceURL: URL, startTime: CMTime) { guard !isRecording else { return } weakOutput = output lastPixelBuffer = nil captureFrameCount = 0 isRunning = false recordStartTime = startTime // 共享时间基准 audioStartHostTime = 0 pendingAudioSample = nil // 临时文件 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 } let success = writer.startWriting() if !success { print("[Recorder] startWriting failed: \(writer.error?.localizedDescription ?? "unknown")") onError?("Cannot start writer: \(writer.error?.localizedDescription ?? "unknown")") return } // session 起点为 0,视频和音频都用相对时间戳 writer.startSession(atSourceTime: .zero) print("[Recorder] Writer started, session at .zero") // 启动视频抓帧 isRecording = true isRunning = true startTimer() startCaptureLoop() // 启动音频读取 startAudioCapture(sourceURL: sourceURL, startTime: startTime, audioWriterInput: aInput) } // MARK: - 停止录制 func stopRecording() { guard isRecording else { return } isRecording = false isRunning = false captureTimer?.invalidate() captureTimer = nil stopTimer() // 停止音频 timer audioFeedTimer?.cancel() audioFeedTimer = nil // 停止音频 reader audioReader?.cancelReading() audioReader = nil audioReaderOutput = nil pendingAudioSample = nil videoInput?.markAsFinished() audioInput?.markAsFinished() writer?.finishWriting { [weak self] in Task { @MainActor in guard let self = self, let w = self.writer else { return } let count = self.captureFrameCount print("[Recorder] finishWriting status: \(w.status.rawValue), error: \(w.error?.localizedDescription ?? "none"), frames: \(count)") if w.status == .completed, let url = self.tempURL { self.showSaveDialog(tempURL: url) } else { self.onError?("Recording failed: \(w.error?.localizedDescription ?? "unknown (status=\(w.status.rawValue))")") if let url = self.tempURL { try? FileManager.default.removeItem(at: url) } } } } lastPixelBuffer = nil } // MARK: - 视频抓帧 (30fps Timer) private var captureTimer: Timer? private func startCaptureLoop() { captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in Task { @MainActor in self?.captureVideoFrame() } } } private func captureVideoFrame() { guard isRunning, let output = weakOutput, let input = videoInput, input.isReadyForMoreMediaData else { return } let hostTime = CACurrentMediaTime() let itemTime = output.itemTime(forHostTime: hostTime) // 尝试获取新帧,否则复用上一帧 if let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) { lastPixelBuffer = pb } guard let pb = lastPixelBuffer else { return } // 相对时间戳:itemTime - recordStartTime let relativeTime = CMTimeSubtract(itemTime, recordStartTime) guard relativeTime.seconds >= 0 else { return } let sampleBuffer = Self.createSampleBuffer(from: pb, time: relativeTime) if let sb = sampleBuffer { if input.append(sb) { captureFrameCount += 1 } } } 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: - 音频读取(DispatchSourceTimer 限速到实时) private func startAudioCapture(sourceURL: URL, startTime: CMTime, audioWriterInput: AVAssetWriterInput) { let asset = AVURLAsset(url: sourceURL) Task { do { let audioTracks = try await asset.loadTracks(withMediaType: .audio) guard let audioTrack = audioTracks.first else { print("[Recorder] No audio track in source") return } guard self.isRunning else { return } let reader = try AVAssetReader(asset: asset) let readerOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: [ AVFormatIDKey: kAudioFormatLinearPCM, AVSampleRateKey: 44100, AVNumberOfChannelsKey: 2, AVLinearPCMBitDepthKey: 16, AVLinearPCMIsFloatKey: false, AVLinearPCMIsBigEndianKey: false, AVLinearPCMIsNonInterleaved: false ]) readerOutput.alwaysCopiesSampleData = false // 从播放位置开始读取 reader.timeRange = CMTimeRange(start: startTime, duration: .positiveInfinity) if reader.canAdd(readerOutput) { reader.add(readerOutput) reader.startReading() self.audioReader = reader self.audioReaderOutput = readerOutput // 记录 wall-clock 起始时间,用于实时限速 self.audioStartHostTime = CACurrentMediaTime() // 用 DispatchSourceTimer 每 20ms 喂一次音频,限速到实时 let queue = DispatchQueue(label: "recorder.audio.feed") let feedTimer = DispatchSource.makeTimerSource(queue: queue) feedTimer.schedule(deadline: .now() + .milliseconds(10), repeating: .milliseconds(20)) feedTimer.setEventHandler { [weak self] in self?.feedAudioSamplesRealtime(writerInput: audioWriterInput, readerOutput: readerOutput, reader: reader) } self.audioFeedTimer = feedTimer feedTimer.resume() } } catch { print("[Recorder] Audio capture setup failed: \(error)") } } } /// 每 20ms 调用,只写入不超过当前实时进度的音频 private nonisolated func feedAudioSamplesRealtime(writerInput: AVAssetWriterInput, readerOutput: AVAssetReaderTrackOutput, reader: AVAssetReader) { guard isRunning, reader.status == .reading else { if isRunning && reader.status != .reading { writerInput.markAsFinished() audioFeedTimer?.cancel() } return } guard writerInput.isReadyForMoreMediaData else { return } // 实时进度:录制开始以来经过的秒数 let elapsed = CACurrentMediaTime() - audioStartHostTime let maxAudioTime = elapsed + 0.05 // 允许 50ms 预读 // 先处理上次缓存的超前 sample if let pending = pendingAudioSample { let pts = CMSampleBufferGetPresentationTimeStamp(pending) let relative = CMTimeSubtract(pts, recordStartTime) if relative.seconds > maxAudioTime { return // 还没到时间,等下次 tick } pendingAudioSample = nil if let nb = Self.remapAudioBuffer(pending, offset: recordStartTime) { _ = writerInput.append(nb) } } // 继续读取,直到追上实时进度 while isRunning, reader.status == .reading, writerInput.isReadyForMoreMediaData { guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else { // 源文件音频读完 if reader.status != .reading { writerInput.markAsFinished() audioFeedTimer?.cancel() } return } let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) let relative = CMTimeSubtract(pts, recordStartTime) // 超出实时进度 → 缓存,等下次 tick if relative.seconds > maxAudioTime { pendingAudioSample = sampleBuffer return } // 跳过负时间戳(音频 pts 在 recordStartTime 之前) guard relative.seconds >= 0 else { continue } if let nb = Self.remapAudioBuffer(sampleBuffer, offset: recordStartTime) { if !writerInput.append(nb) { return } } } } /// 重映射音频 sample buffer 的时间戳为相对时间 private nonisolated static func remapAudioBuffer(_ sampleBuffer: CMSampleBuffer, offset: CMTime) -> CMSampleBuffer? { var count: CMItemCount = 0 CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: 0, arrayToFill: nil, entriesNeededOut: &count) guard count > 0 else { return sampleBuffer } 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