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 sampleRate: Double = 44100.0 nonisolated(unsafe) var channelsPerFrame: UInt32 = 2 nonisolated(unsafe) var appendCount: Int = 0 nonisolated(unsafe) var firstAppendLogged: Bool = false nonisolated(unsafe) var processCallCount: Int = 0 /// Serial queue for processing audio buffers off the real-time thread let audioQueue = DispatchQueue(label: "miniplayer.audioCapture") /// Pre-allocated data buffer (avoids malloc in real-time audio thread) var dataBuffer: UnsafeMutableRawPointer? var dataBufferSize: UInt32 = 0 } // MARK: - CoreAudio 输出设备 IOProc 回调 /// 从系统音频输出设备捕获 PCM 数据(适用于所有音频源:HLS、本地文件等) /// 运行在高优先级音频线程,最小化工作 private func audioDeviceIOProc( _ inDevice: AudioDeviceID, _ inNow: UnsafePointer, _ inInputData: UnsafePointer, _ inInputTime: UnsafePointer, _ inOutputData: UnsafeMutablePointer, _ inOutputTime: UnsafePointer, _ inClientData: UnsafeMutableRawPointer? ) -> OSStatus { guard let clientData = inClientData else { return noErr } let ctx = Unmanaged.fromOpaque(clientData).takeUnretainedValue() guard ctx.isRunning, let writerInput = ctx.writerInput, writerInput.isReadyForMoreMediaData else { return noErr } ctx.processCallCount += 1 let bufferList = inInputData.pointee guard bufferList.mNumberBuffers > 0 else { return noErr } let buffer = bufferList.mBuffers let dataSize = buffer.mDataByteSize guard dataSize > 0, let srcData = buffer.mData else { return noErr } // Copy to pre-allocated buffer (avoid malloc in real-time thread) if ctx.dataBufferSize < dataSize { if let existing = ctx.dataBuffer { free(existing) } ctx.dataBuffer = malloc(Int(dataSize)) ctx.dataBufferSize = dataSize } guard let dest = ctx.dataBuffer else { return noErr } memcpy(dest, srcData, Int(dataSize)) let pts = inInputTime.pointee let callNum = ctx.processCallCount // Process on serial queue (off the real-time audio thread) ctx.audioQueue.async { guard ctx.isRunning, let fd = ctx.formatDescription else { return } var blockBuffer: CMBlockBuffer? let blockStatus = CMBlockBufferCreateWithMemoryBlock( allocator: kCFAllocatorDefault, memoryBlock: nil, blockLength: Int(dataSize), blockAllocator: kCFAllocatorDefault, customBlockSource: nil, offsetToData: 0, dataLength: Int(dataSize), flags: kCMBlockBufferAssureMemoryNowFlag, blockBufferOut: &blockBuffer ) guard blockStatus == kCMBlockBufferNoErr, let bb = blockBuffer else { return } let replaceStatus = CMBlockBufferReplaceDataBytes( with: dest, blockBuffer: bb, offsetIntoDestination: 0, dataLength: Int(dataSize) ) guard replaceStatus == kCMBlockBufferNoErr else { return } var timingInfo = CMSampleTimingInfo() // Convert AudioTimeStamp to CMTime if pts.mFlags.contains(.sampleTimeValid) { let sampleRate = ctx.sampleRate timingInfo.presentationTimeStamp = CMTime( value: Int64(pts.mSampleTime), timescale: Int32(sampleRate) ) } else if pts.mFlags.contains(.hostTimeValid) { var timebase = mach_timebase_info_data_t() mach_timebase_info(&timebase) let nanos = pts.mHostTime * UInt64(timebase.numer) / UInt64(timebase.denom) timingInfo.presentationTimeStamp = CMTime(value: CMTimeValue(nanos), timescale: 1_000_000_000) } else { return } timingInfo.duration = .invalid timingInfo.decodeTimeStamp = .invalid var sampleBuffer: CMSampleBuffer? let bytesPerFrame = Int(ctx.channelsPerFrame) * MemoryLayout.size let sampleCount = Int(dataSize) / max(bytesPerFrame, 1) let createStatus = CMSampleBufferCreateReady( allocator: kCFAllocatorDefault, dataBuffer: bb, formatDescription: fd, sampleCount: sampleCount, sampleTimingEntryCount: 1, sampleTimingArray: &timingInfo, sampleSizeEntryCount: 0, sampleSizeArray: nil, sampleBufferOut: &sampleBuffer ) guard createStatus == noErr, let sb = sampleBuffer else { if callNum <= 3 { NSLog("[Recorder] \u{26a0}\u{fe0f} Audio sample buffer create failed: %d", createStatus) } return } if writerInput.isReadyForMoreMediaData && writerInput.append(sb) { ctx.appendCount += 1 if !ctx.firstAppendLogged { ctx.firstAppendLogged = true NSLog("[Recorder] ✓ First audio captured: pts=%.2fs, size=%u", pts.mSampleTime / ctx.sampleRate, dataSize) } } } return noErr } // MARK: - PlayerRecorder /// 全局状态:录制是否正在 finalize(阻止应用退出) enum RecorderState { static nonisolated(unsafe) var isFinalizing: Bool = false static nonisolated(unsafe) var pendingTerminate: Bool = false } /// 视频源录制器:AVPlayerItemVideoOutput 抓帧 + CoreAudio 输出设备捕获音频 /// 边录边写文件,录制结束后仅改名和移动 @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 // 音频 (CoreAudio 输出设备) private var audioDeviceID: AudioDeviceID = 0 private var audioCaptureContext: AudioCaptureContext? private var audioIOProc: AudioDeviceIOProc? 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() // 启动音频捕获 (CoreAudio) startAudioCapture(audioWriterInput: aInput) } // MARK: - 停止录制 func stopRecording() { guard isRecording else { return } isRecording = false isRunning = false RecorderState.isFinalizing = true // 阻止应用退出,直到 finishWriting 完成 captureTimer?.invalidate() captureTimer = nil stopDurationTimer() // 保存音频统计并停止捕获 let audioCalls = audioCaptureContext?.processCallCount ?? 0 let audioAppended = audioCaptureContext?.appendCount ?? 0 NSLog("[Recorder] Stopping: videoFrames=%d, audioProcessCalls=%d, audioAppended=%d", captureFrameCount, audioCalls, audioAppended) stopAudioCapture() currentPlayerItem = nil videoInput?.markAsFinished() audioInput?.markAsFinished() writer?.finishWriting { [weak self] in NSLog("[Recorder] finishWriting callback invoked") DispatchQueue.main.async { RecorderState.isFinalizing = false // 允许应用退出了 guard let self = self, let w = self.writer else { NSLog("[Recorder] ⚠️ self or writer is nil in finishWriting callback") if RecorderState.pendingTerminate { RecorderState.pendingTerminate = false NSApp.reply(toApplicationShouldTerminate: true) } return } let vFrames = self.captureFrameCount NSLog("[Recorder] finishWriting: status=%d, error=%@, videoFrames=%d, audioFrames=%d", w.status.rawValue, w.error?.localizedDescription ?? "none", vFrames, audioAppended) 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") self.checkPendingTerminate() } } 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) } self.checkPendingTerminate() } } } lastPixelBuffer = nil } // MARK: - 音频捕获 (CoreAudio 输出设备) /// 从系统音频输出设备捕获音频(适用于 HLS、本地文件等所有音频源) private func startAudioCapture(audioWriterInput: AVAssetWriterInput) { // 获取默认音频输出设备 var deviceID: AudioDeviceID = 0 var size = UInt32(MemoryLayout.size) var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) let status = AudioObjectGetPropertyData( AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID ) guard status == noErr, deviceID != 0 else { NSLog("[Recorder] \u{274c} Cannot get default audio output device: %d", status) return } self.audioDeviceID = deviceID // 获取输出音频流格式 var streamFormatAddress = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyStreamFormat, mScope: kAudioDevicePropertyScopeOutput, mElement: kAudioObjectPropertyElementMain ) var asbd = AudioStreamBasicDescription() size = UInt32(MemoryLayout.size) let fmtStatus = AudioObjectGetPropertyData(deviceID, &streamFormatAddress, 0, nil, &size, &asbd) guard fmtStatus == noErr else { NSLog("[Recorder] \u{274c} Cannot get audio stream format: %d", fmtStatus) return } NSLog("[Recorder] Audio output format: %.1fHz, %uch, %ubit, formatID=%u", asbd.mSampleRate, asbd.mChannelsPerFrame, asbd.mBitsPerChannel, asbd.mFormatID) // 创建 context let context = AudioCaptureContext() context.writerInput = audioWriterInput context.sampleRate = Double(asbd.mSampleRate) context.channelsPerFrame = asbd.mChannelsPerFrame context.isRunning = true self.audioCaptureContext = context // 创建格式描述 var fmtDesc: CMAudioFormatDescription? var mutableASBD = asbd CMAudioFormatDescriptionCreate( allocator: kCFAllocatorDefault, asbd: &mutableASBD, layoutSize: 0, layout: nil, magicCookieSize: 0, magicCookie: nil, extensions: nil, formatDescriptionOut: &fmtDesc ) context.formatDescription = fmtDesc // 创建 IOProc(使用 unmanaged pointer 传递 context) let contextPtr = Unmanaged.passUnretained(context).toOpaque() var ioProc: AudioDeviceIOProc? let createStatus = AudioDeviceCreateIOProcID(deviceID, audioDeviceIOProc, contextPtr, &ioProc) guard createStatus == noErr, let proc = ioProc else { NSLog("[Recorder] \u{274c} AudioDeviceCreateIOProcID failed: %d", createStatus) return } self.audioIOProc = proc // 启动音频捕获 let startStatus = AudioDeviceStart(deviceID, proc) guard startStatus == noErr else { NSLog("[Recorder] \u{274c} AudioDeviceStart failed: %d", startStatus) AudioDeviceDestroyIOProcID(deviceID, proc) self.audioIOProc = nil return } NSLog("[Recorder] \u{2713} CoreAudio output device capture started (deviceID=%u)", deviceID) } private func stopAudioCapture() { audioCaptureContext?.isRunning = false if let proc = audioIOProc, audioDeviceID != 0 { AudioDeviceStop(audioDeviceID, proc) AudioDeviceDestroyIOProcID(audioDeviceID, proc) } audioIOProc = nil audioCaptureContext = nil } // 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 { if RecorderState.pendingTerminate { RecorderState.pendingTerminate = false NSApp.reply(toApplicationShouldTerminate: true) } 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) } self.checkPendingTerminate() } } } private func checkPendingTerminate() { if RecorderState.pendingTerminate { RecorderState.pendingTerminate = false NSLog("[MiniPlayer] Pending terminate triggered, replying to terminate") NSApp.reply(toApplicationShouldTerminate: true) } } } #endif