import AVFoundation import CoreMedia import MediaToolbox import Foundation #if os(macOS) import AppKit // MARK: - 全局音频上下文(C 回调无法访问 Swift 对象,用全局变量) private nonisolated(unsafe) var gAudioContext: AudioCaptureContext? class AudioCaptureContext { var writerInput: AVAssetWriterInput? nonisolated(unsafe) var isRunning: Bool = true nonisolated(unsafe) var formatDescription: CMAudioFormatDescription? nonisolated(unsafe) var appendCount: Int = 0 nonisolated(unsafe) var firstAppendLogged: Bool = false nonisolated(unsafe) var processCallCount: Int = 0 } // MARK: - MTAudioProcessingTap C 回调 private func tapInitCallback( _ tap: MTAudioProcessingTap, _ clientInfo: UnsafeMutableRawPointer?, _ tapStorageOut: UnsafeMutablePointer ) { // tapStorage 不需要额外分配,context 通过全局变量访问 tapStorageOut.pointee = clientInfo } private func tapFinalizeCallback(_ tap: MTAudioProcessingTap) { // 清理由 stopRecording 负责 } private func tapPrepareCallback( _ tap: MTAudioProcessingTap, _ maxFrames: CMItemCount, _ processingFormat: UnsafePointer ) { guard let ctx = gAudioContext else { return } var asbd = processingFormat.pointee var fmtDesc: CMAudioFormatDescription? CMAudioFormatDescriptionCreate( allocator: kCFAllocatorDefault, asbd: &asbd, layoutSize: 0, layout: nil, magicCookieSize: 0, magicCookie: nil, extensions: nil, formatDescriptionOut: &fmtDesc ) ctx.formatDescription = fmtDesc NSLog("[Recorder] tapPrepare: %.1fHz, %uch", asbd.mSampleRate, asbd.mChannelsPerFrame) } private func tapUnprepareCallback(_ tap: MTAudioProcessingTap) { // Nothing to clean up } private func tapProcessCallback( _ tap: MTAudioProcessingTap, _ numberFrames: CMItemCount, _ flags: MTAudioProcessingTapFlags, _ bufferListInOut: UnsafeMutablePointer, _ numberFramesOut: UnsafeMutablePointer, _ flagsOut: UnsafeMutablePointer ) { // 始终先获取源音频(保证音频管道正常运行) 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 } ctx.processCallCount += 1 // 定期记录回调活跃状态(每500次调用打一次日志) if ctx.processCallCount == 1 || ctx.processCallCount % 500 == 0 { NSLog("[Recorder] tapProcess #%d: frames=%ld, timescale=%d", ctx.processCallCount, actualFrames, timeRange.duration.timescale) } 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 { if ctx.processCallCount == 1 { NSLog("[Recorder] ⚠️ formatDescription nil on first process call") } return } // PTS 基于累积帧数(与视频一样从0开始) 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 { if ctx.processCallCount <= 3 { NSLog("[Recorder] ⚠️ CMAudioSampleBufferCreate failed: %d", createStatus) } return } if writerInput.append(sb) { ctx.appendCount += actualFrames if !ctx.firstAppendLogged { ctx.firstAppendLogged = true NSLog("[Recorder] ✓ First audio appended: pts=%.2fs, frames=%ld", pts.seconds, actualFrames) } } else if ctx.processCallCount <= 3 { NSLog("[Recorder] ⚠️ writerInput.append failed (input ready=%d)", writerInput.isReadyForMoreMediaData) } } // MARK: - PlayerRecorder /// 视频源录制器:AVPlayerItemVideoOutput 抓帧 + MTAudioProcessingTap 捕获音频 /// 边录边写文件,录制结束后仅改名和移动 @MainActor final class PlayerRecorder: NSObject { private var writer: AVAssetWriter? private var videoInput: AVAssetWriterInput? private var audioInput: AVAssetWriterInput? private var captureTimer: Timer? private var durationTimer: Timer? private var startDate: Date? private weak 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? private var tracksObservation: NSKeyValueObservation? // 录制起始时间(用于计算视频相对时间戳) 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 tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4" let url = saveDirectory.appendingPathComponent(tempFilename) // 删除旧文件 try? FileManager.default.removeItem(at: url) tempURL = url do { writer = try AVAssetWriter(outputURL: url, fileType: .mp4) } catch { onError?("Cannot create writer: \(error.localizedDescription)") return } guard let writer = writer else { return } // 视频输入(H264) 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 } // 音频输入(AAC) 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 } guard writer.startWriting() else { NSLog("[Recorder] startWriting failed: %@", writer.error?.localizedDescription ?? "unknown") onError?("Cannot start writer: \(writer.error?.localizedDescription ?? "unknown")") return } // session 起点为 0,视频和音频都用相对时间戳 writer.startSession(atSourceTime: .zero) NSLog("[Recorder] Writer started, writing to: %@", url.lastPathComponent) // 启动视频抓帧(30fps) isRecording = true isRunning = true startCaptureLoop() startDurationTimer() // 启动音频捕获(MTAudioProcessingTap) setupAudioTap(playerItem: playerItem, audioWriterInput: aInput) } // MARK: - 停止录制 func stopRecording() { guard isRecording else { return } isRecording = false isRunning = false captureTimer?.invalidate() captureTimer = nil stopDurationTimer() // 停止音频 tap audioContext?.isRunning = false tracksObservation = nil let totalAudioFrames = audioContext?.appendCount ?? 0 let processCalls = audioContext?.processCallCount ?? 0 NSLog("[Recorder] Stopping: videoFrames=%d, audioProcessCalls=%d, audioAppended=%d", captureFrameCount, processCalls, totalAudioFrames) // 移除 audioMix currentPlayerItem?.audioMix = nil currentPlayerItem = nil // 释放 tap 和 context audioTap = nil audioContext = nil gAudioContext = nil videoInput?.markAsFinished() audioInput?.markAsFinished() writer?.finishWriting { [weak self] in NSLog("[Recorder] finishWriting callback invoked") DispatchQueue.main.async { guard let self = self, let w = self.writer else { NSLog("[Recorder] ⚠️ self or writer is nil in finishWriting callback") return } let vFrames = self.captureFrameCount NSLog("[Recorder] finishWriting: status=%d, error=%@, videoFrames=%d, audioFrames=%d", w.status.rawValue, w.error?.localizedDescription ?? "none", vFrames, totalAudioFrames) if w.status == .completed, let tempURL = self.tempURL { // 检查文件是否存在且有内容 if FileManager.default.fileExists(atPath: tempURL.path) { let size = (try? FileManager.default.attributesOfItem(atPath: tempURL.path)[.size] as? UInt64) ?? 0 NSLog("[Recorder] ✓ Temp file exists: %@, size=%llu bytes", tempURL.lastPathComponent, size) self.showSaveDialog(tempURL: tempURL) } else { NSLog("[Recorder] ⚠️ Temp file missing: %@", tempURL.path) self.onError?("Recording file missing") } } else { NSLog("[Recorder] ✗ finishWriting failed: status=%d, error=%@", w.status.rawValue, w.error?.localizedDescription ?? "unknown") 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) { // HLS 流的 tracks 是异步加载的 // 策略:先同步检查 → KVO 观察 → 定时轮询保底(含 async load 备选) // 先打印当前 track 状态(诊断) let tracks = playerItem.tracks NSLog("[Recorder] setupAudioTap: playerItem.tracks.count=%d", tracks.count) for (i, track) in tracks.enumerated() { if let at = track.assetTrack { NSLog("[Recorder] track[%d]: mediaType=%@, trackID=%d, enabled=%d", i, at.mediaType.rawValue as NSString, at.trackID, track.isEnabled) } else { NSLog("[Recorder] track[%d]: assetTrack=NIL (HLS not loaded yet?)", i) } } // 先尝试直接获取(本地文件场景) if let audioTrack = self.findAudioTrackID(in: playerItem) { NSLog("[Recorder] ✓ Audio track immediately available: trackID=%d", audioTrack) self.installTap(on: playerItem, trackID: audioTrack, audioWriterInput: audioWriterInput) return } // tracks 尚未加载 — 用 KVO 观察变化 NSLog("[Recorder] ⏳ Audio tracks not yet loaded, setting up KVO + polling...") let observation = playerItem.observe(\.tracks, options: [.new]) { [weak self] item, _ in guard let self, self.isRunning, self.audioTap == nil else { return } let tCount = item.tracks.count NSLog("[Recorder] KVO tracks changed: count=%d", tCount) if let trackID = self.findAudioTrackID(in: item) { NSLog("[Recorder] ✓ Audio track via KVO: trackID=%d", trackID) Task { @MainActor in guard self.isRunning, self.audioTap == nil else { return } self.installTap(on: item, trackID: trackID, audioWriterInput: audioWriterInput) } self.tracksObservation = nil } } self.tracksObservation = observation // 保底轮询:含 async asset.load(.tracks) 备选 // 超时保护:30秒后放弃 Task { var attempts = 0 var didTryAsyncLoad = false while attempts < 300 && self.isRunning && self.audioTap == nil { // 30秒 try await Task.sleep(nanoseconds: 100_000_000) // 0.1秒 attempts += 1 // 方式1: 从 playerItem.tracks 查找 if let trackID = self.findAudioTrackID(in: playerItem) { NSLog("[Recorder] ✓ Audio track via polling (attempt %d): trackID=%d", attempts, trackID) await MainActor.run { guard self.isRunning, self.audioTap == nil else { return } self.installTap(on: playerItem, trackID: trackID, audioWriterInput: audioWriterInput) } self.tracksObservation = nil return } // 方式2: 5秒后尝试 async load asset tracks(HLS 可能通过这个路径加载) if !didTryAsyncLoad && attempts >= 50 { didTryAsyncLoad = true NSLog("[Recorder] Trying async asset.load(.tracks)...") do { let assetTracks = try await playerItem.asset.load(.tracks) NSLog("[Recorder] async load returned %d tracks", assetTracks.count) for (i, t) in assetTracks.enumerated() { NSLog("[Recorder] assetTrack[%d]: mediaType=%@, trackID=%d", i, t.mediaType.rawValue as NSString, t.trackID) } if let audioAssetTrack = assetTracks.first(where: { $0.mediaType == .audio }) { let trackID = audioAssetTrack.trackID NSLog("[Recorder] ✓ Audio track via async load: trackID=%d", trackID) await MainActor.run { guard self.isRunning, self.audioTap == nil else { return } self.installTap(on: playerItem, trackID: trackID, audioWriterInput: audioWriterInput) } self.tracksObservation = nil return } } catch { NSLog("[Recorder] async load failed: %@", error.localizedDescription) } } // 每5秒打一次状态日志 if attempts % 50 == 0 { let tc = playerItem.tracks.count NSLog("[Recorder] polling attempt %d: playerItem.tracks.count=%d, tapInstalled=%d", attempts, tc, self.audioTap != nil ? 1 : 0) } } if self.audioTap == nil { NSLog("[Recorder] ❌ No audio track found after %d polling attempts (30s timeout)", attempts) self.tracksObservation = nil } } } /// 从 playerItem.tracks 查找音频 trackID /// 对 HLS 流,assetTrack 可能延迟加载或始终为 nil private func findAudioTrackID(in playerItem: AVPlayerItem) -> CMPersistentTrackID? { let itemTracks = playerItem.tracks guard !itemTracks.isEmpty else { return nil } for (i, track) in itemTracks.enumerated() { guard let assetTrack = track.assetTrack else { NSLog("[Recorder] findTrack: track[%d] assetTrack=nil (not loaded)", i) continue } NSLog("[Recorder] findTrack: track[%d] mediaType=%@, trackID=%d, enabled=%d", i, assetTrack.mediaType.rawValue as NSString, assetTrack.trackID, track.isEnabled) if assetTrack.mediaType == .audio { return assetTrack.trackID } } return nil } private func installTap(on playerItem: AVPlayerItem, trackID: CMPersistentTrackID, audioWriterInput: AVAssetWriterInput) { // 创建音频捕获上下文 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: tapInitCallback, finalize: tapFinalizeCallback, prepare: tapPrepareCallback, unprepare: tapUnprepareCallback, process: tapProcessCallback ) var tap: MTAudioProcessingTap? let status = MTAudioProcessingTapCreate( kCFAllocatorDefault, &callbacks, kMTAudioProcessingTapCreationFlag_PostEffects, &tap ) guard status == noErr, let audioTap = tap else { NSLog("[Recorder] ❌ MTAudioProcessingTapCreate failed: %d", status) gAudioContext = nil return } self.audioTap = audioTap // 关键:用 playerItem 的 trackID 创建 audioMix input parameters let params = AVMutableAudioMixInputParameters() params.trackID = trackID params.audioTapProcessor = audioTap let audioMix = AVMutableAudioMix() audioMix.inputParameters = [params] playerItem.audioMix = audioMix NSLog("[Recorder] ✓ Audio tap installed on playerItem (trackID=%d)", trackID) } // MARK: - 视频抓帧 (30fps 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 } // 创建 sample buffer 并写入 if let sb = Self.createSampleBuffer(from: pb, time: relativeTime) { 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: - 计时器 private func startDurationTimer() { startDate = Date() durationText = "00:00" durationTimer = 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 stopDurationTimer() { durationTimer?.invalidate() durationTimer = nil startDate = nil durationText = "00:00" } // MARK: - 文件命名 private func generateFinalFilename() -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" return "Recording_\(formatter.string(from: Date())).mp4" } // MARK: - 保存对话框 private func showSaveDialog(tempURL: URL) { let panel = NSSavePanel() panel.nameFieldStringValue = generateFinalFilename() panel.allowedContentTypes = [.mpeg4Movie] panel.directoryURL = saveDirectory panel.title = "保存录制文件" panel.prompt = "保存" panel.begin { [weak self] response in Task { @MainActor in guard let self = self else { return } if response == .OK, let finalURL = panel.url { do { // 如果目标文件已存在,先删除 if FileManager.default.fileExists(atPath: finalURL.path) { try FileManager.default.removeItem(at: finalURL) } // 移动临时文件到最终位置 try FileManager.default.moveItem(at: tempURL, to: finalURL) NSLog("[Recorder] ✓ Saved: %@", finalURL.path) self.onRecordingSaved?(finalURL) } catch { self.onError?("Save failed: \(error.localizedDescription)") try? FileManager.default.removeItem(at: tempURL) } } else { // 用户取消,删除临时文件 NSLog("[Recorder] User cancelled save, deleting temp file") try? FileManager.default.removeItem(at: tempURL) } } } } } #endif