import AVFoundation import CoreMedia #if os(macOS) import AppKit // MARK: - 音频捕获上下文 class AudioCaptureContext { var writerInput: AVAssetWriterInput? nonisolated(unsafe) var isRunning: Bool = true nonisolated(unsafe) var appendCount: Int = 0 nonisolated(unsafe) var formatDescription: CMAudioFormatDescription? nonisolated(unsafe) var firstAppendLogged: Bool = false } /// 全局引用,供 C 回调访问(单实例录制,线程安全由 isRunning 保护) private nonisolated(unsafe) var gAudioContext: AudioCaptureContext? // MARK: - MTAudioProcessingTap C 回调 private func tapPrepare(_ tap: MTAudioProcessingTap, _ maxFramesPerSlice: CMItemCount, _ processingFormat: UnsafePointer) { print("[Recorder] tapPrepare called, maxFrames=\(maxFramesPerSlice)") guard let ctx = gAudioContext else { print("[Recorder] tapPrepare: gAudioContext is nil!") return } var asbd = processingFormat.pointee print("[Recorder] tapPrepare: sampleRate=\(asbd.mSampleRate), channels=\(asbd.mChannelsPerFrame)") CMAudioFormatDescriptionCreate(allocator: kCFAllocatorDefault, asbd: &asbd, layoutSize: 0, layout: nil, magicCookieSize: 0, magicCookie: nil, extensions: nil, formatDescriptionOut: &ctx.formatDescription) } private func tapInit(_ tap: MTAudioProcessingTap, _ clientInfo: UnsafeMutableRawPointer?, _ tapStorageOut: UnsafeMutablePointer) { // 初始化 tap } private func tapUnprepare(_ tap: MTAudioProcessingTap) { // Nothing to clean up } private func tapProcess(_ tap: MTAudioProcessingTap, _ numberFrames: CMItemCount, _ flags: MTAudioProcessingTapFlags, _ bufferListInOut: UnsafeMutablePointer, _ numberFramesOut: UnsafeMutablePointer, _ flagsOut: UnsafeMutablePointer) { numberFramesOut.pointee = 0 flagsOut.pointee = 0 guard numberFrames > 0 else { return } // 先获取源音频数据 var timeRange = CMTimeRange() var srcFlags: MTAudioProcessingTapFlags = 0 var actualFrames: CMItemCount = 0 let status = MTAudioProcessingTapGetSourceAudio( tap, numberFrames, bufferListInOut, &srcFlags, &timeRange, &actualFrames ) numberFramesOut.pointee = actualFrames flagsOut.pointee = srcFlags guard status == noErr, actualFrames > 0 else { return } // 通过全局变量获取 context guard let ctx = gAudioContext, ctx.isRunning, let writerInput = ctx.writerInput, writerInput.isReadyForMoreMediaData else { return } let bufferList = bufferListInOut.pointee guard bufferList.mNumberBuffers > 0 else { return } let buf = bufferList.mBuffers let dataSize = Int(buf.mDataByteSize) guard dataSize > 0, let srcData = buf.mData else { return } // 创建 CMBlockBuffer var blockBuffer: CMBlockBuffer? let blockStatus = CMBlockBufferCreateWithMemoryBlock( allocator: kCFAllocatorDefault, memoryBlock: nil, blockLength: dataSize, blockAllocator: kCFAllocatorDefault, customBlockSource: nil, offsetToData: 0, dataLength: dataSize, flags: kCMBlockBufferAssureMemoryNowFlag, blockBufferOut: &blockBuffer ) guard blockStatus == kCMBlockBufferNoErr, let bb = blockBuffer else { return } let replaceStatus = CMBlockBufferReplaceDataBytes( with: srcData, blockBuffer: bb, offsetIntoDestination: 0, dataLength: dataSize ) guard replaceStatus == kCMBlockBufferNoErr else { return } // 获取 format description guard let fd = ctx.formatDescription else { return } // 用累积帧数计算 PTS let sampleRate = Double(timeRange.duration.timescale) > 0 ? Double(timeRange.duration.timescale) : 44100.0 let ptsValue = Double(ctx.appendCount) / sampleRate let pts = CMTime(seconds: ptsValue, preferredTimescale: Int32(sampleRate)) var sampleBuffer: CMSampleBuffer? let createStatus = CMAudioSampleBufferCreateReadyWithPacketDescriptions( allocator: kCFAllocatorDefault, dataBuffer: bb, formatDescription: fd, sampleCount: actualFrames, presentationTimeStamp: pts, packetDescriptions: nil, sampleBufferOut: &sampleBuffer ) guard createStatus == noErr, let sb = sampleBuffer else { return } if writerInput.append(sb) { ctx.appendCount += actualFrames if !ctx.firstAppendLogged { ctx.firstAppendLogged = true print("[Recorder] ✓ First audio frame appended! pts=\(pts.seconds)s, frames=\(actualFrames)") } } } // 空的 init/finalize 回调 private func tapInit(_ tap: MTAudioProcessingTap) {} private func tapFinalize(_ tap: MTAudioProcessingTap) {} /// 视频源录制器:从 AVPlayerItemVideoOutput 抓帧 + MTAudioProcessingTap 捕获音频,写入 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 // 音频 (MTAudioProcessingTap) private var audioTap: MTAudioProcessingTap? private var audioContext: AudioCaptureContext? private weak var currentPlayerItem: AVPlayerItem? nonisolated(unsafe) private var recordStartTime: CMTime = .zero @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, playerItem: AVPlayerItem, startTime: CMTime) { guard !isRecording else { return } weakOutput = output currentPlayerItem = playerItem lastPixelBuffer = nil captureFrameCount = 0 isRunning = false recordStartTime = startTime // 临时文件 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 } writer.startSession(atSourceTime: .zero) print("[Recorder] Writer started, session at .zero") // 启动视频抓帧 isRecording = true isRunning = true startTimer() startCaptureLoop() // 启动音频捕获(MTAudioProcessingTap) setupAudioTap(playerItem: playerItem, audioWriterInput: aInput) } // MARK: - 停止录制 func stopRecording() { guard isRecording else { return } isRecording = false isRunning = false captureTimer?.invalidate() captureTimer = nil stopTimer() // 停止音频 tap audioContext?.isRunning = false // 移除 audioMix(移除 tap) currentPlayerItem?.audioMix = nil currentPlayerItem = nil // 释放 tap 和 context audioTap = nil audioContext = nil gAudioContext = 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"), video 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: - 音频 Tap 设置 private func setupAudioTap(playerItem: AVPlayerItem, audioWriterInput: AVAssetWriterInput) { let asset = playerItem.asset Task { do { let audioTracks = try await asset.loadTracks(withMediaType: .audio) guard let audioTrack = audioTracks.first else { print("[Recorder] ❌ No audio track for tap") return } guard self.isRunning else { print("[Recorder] ❌ isRunning=false, aborting audio tap setup") return } print("[Recorder] Audio track found: \(audioTrack.trackID), setting up tap...") // 创建音频捕获上下文 let context = AudioCaptureContext() context.writerInput = audioWriterInput context.isRunning = true self.audioContext = context // ★ 设置全局引用,供 C 回调访问 gAudioContext = context // 创建 MTAudioProcessingTap var callbacks = MTAudioProcessingTapCallbacks( version: kMTAudioProcessingTapCallbacksVersion_0, clientInfo: nil, init: tapInit, finalize: tapFinalize, prepare: tapPrepare, unprepare: tapUnprepare, process: tapProcess ) var tap: MTAudioProcessingTap? let status = MTAudioProcessingTapCreate( kCFAllocatorDefault, &callbacks, kMTAudioProcessingTapCreationFlag_PostEffects, &tap ) guard status == noErr, let audioTap = tap else { print("[Recorder] ❌ MTAudioProcessingTapCreate failed: \(status)") gAudioContext = nil return } self.audioTap = audioTap // 创建 audioMix 并挂上 tap let params = AVMutableAudioMixInputParameters(track: audioTrack) params.audioTapProcessor = audioTap let audioMix = AVMutableAudioMix() audioMix.inputParameters = [params] playerItem.audioMix = audioMix print("[Recorder] ✓ Audio tap installed on player item") } catch { print("[Recorder] ❌ Audio tap setup failed: \(error)") } } } // 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 } // 相对时间戳 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: - 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