diff --git a/Sources/PlayerRecorder.swift b/Sources/PlayerRecorder.swift index 3afceb2..00067f1 100644 --- a/Sources/PlayerRecorder.swift +++ b/Sources/PlayerRecorder.swift @@ -1,61 +1,75 @@ import AVFoundation import CoreMedia +import MediaToolbox +import Foundation #if os(macOS) import AppKit -// MARK: - 音频捕获上下文 +// MARK: - 全局音频上下文(C 回调无法访问 Swift 对象,用全局变量) +private nonisolated(unsafe) var gAudioContext: AudioCaptureContext? + 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 appendCount: Int = 0 nonisolated(unsafe) var firstAppendLogged: Bool = false + nonisolated(unsafe) var processCallCount: Int = 0 } -/// 全局引用,供 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 - } + +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 - 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) + 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 tapInit(_ tap: MTAudioProcessingTap, - _ clientInfo: UnsafeMutableRawPointer?, - _ tapStorageOut: UnsafeMutablePointer) { - // 初始化 tap -} - -private func tapUnprepare(_ tap: MTAudioProcessingTap) { +private func tapUnprepareCallback(_ tap: MTAudioProcessingTap) { // Nothing to clean up } -private func tapProcess(_ tap: MTAudioProcessingTap, - _ numberFrames: CMItemCount, - _ flags: MTAudioProcessingTapFlags, - _ bufferListInOut: UnsafeMutablePointer, - _ numberFramesOut: UnsafeMutablePointer, - _ flagsOut: UnsafeMutablePointer) { +private func tapProcessCallback( + _ 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 @@ -75,6 +89,8 @@ private func tapProcess(_ tap: MTAudioProcessingTap, let writerInput = ctx.writerInput, writerInput.isReadyForMoreMediaData else { return } + ctx.processCallCount += 1 + let bufferList = bufferListInOut.pointee guard bufferList.mNumberBuffers > 0 else { return } @@ -103,7 +119,7 @@ private func tapProcess(_ tap: MTAudioProcessingTap, // 获取 format description guard let fd = ctx.formatDescription else { return } - // 用累积帧数计算 PTS + // PTS 基于累积帧数 let sampleRate = Double(timeRange.duration.timescale) > 0 ? Double(timeRange.duration.timescale) : 44100.0 let ptsValue = Double(ctx.appendCount) / sampleRate @@ -125,28 +141,28 @@ private func tapProcess(_ tap: MTAudioProcessingTap, ctx.appendCount += actualFrames if !ctx.firstAppendLogged { ctx.firstAppendLogged = true - print("[Recorder] ✓ First audio frame appended! pts=\(pts.seconds)s, frames=\(actualFrames)") + NSLog("[Recorder] ✓ First audio appended: pts=%.2fs, frames=%ld", pts.seconds, actualFrames) } } } -// 空的 init/finalize 回调 -private func tapInit(_ tap: MTAudioProcessingTap) {} -private func tapFinalize(_ tap: MTAudioProcessingTap) {} +// MARK: - PlayerRecorder -/// 视频源录制器:从 AVPlayerItemVideoOutput 抓帧 + MTAudioProcessingTap 捕获音频,写入 mp4 +/// 视频源录制器:AVPlayerItemVideoOutput 抓帧 + MTAudioProcessingTap 捕获音频 +/// 边录边写文件,录制结束后仅改名和移动 @MainActor final class PlayerRecorder: NSObject { private var writer: AVAssetWriter? private var videoInput: AVAssetWriterInput? private var audioInput: AVAssetWriterInput? - private var timer: Timer? + private var captureTimer: Timer? + private var durationTimer: Timer? private var startDate: Date? - private var weakOutput: AVPlayerItemVideoOutput? + private weak var weakOutput: AVPlayerItemVideoOutput? nonisolated(unsafe) private var isRunning = false - // 视频:缓存上一帧,确保静态场景也能持续写入 + // 视频:缓存上一帧(处理静态画面) private var lastPixelBuffer: CVPixelBuffer? private var captureFrameCount: Int = 0 @@ -155,6 +171,7 @@ final class PlayerRecorder: NSObject { private var audioContext: AudioCaptureContext? private weak var currentPlayerItem: AVPlayerItem? + // 录制起始时间(用于计算视频相对时间戳) nonisolated(unsafe) private var recordStartTime: CMTime = .zero @Published var isRecording = false @@ -163,8 +180,10 @@ final class PlayerRecorder: NSObject { 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) @@ -173,6 +192,7 @@ final class PlayerRecorder: NSObject { } // MARK: - 开始录制 + func startRecording(from output: AVPlayerItemVideoOutput, playerItem: AVPlayerItem, startTime: CMTime) { guard !isRecording else { return } @@ -183,11 +203,11 @@ final class PlayerRecorder: NSObject { 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) + // 临时文件(边录边写,结束仅改名移动) + let tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4" + let url = saveDirectory.appendingPathComponent(tempFilename) + // 删除旧文件 + try? FileManager.default.removeItem(at: url) tempURL = url do { @@ -199,7 +219,7 @@ final class PlayerRecorder: NSObject { guard let writer = writer else { return } - // 视频输入 + // 视频输入(H264) let videoSettings: [String: Any] = [ AVVideoCodecKey: AVVideoCodecType.h264, AVVideoWidthKey: 1920, @@ -212,7 +232,7 @@ final class PlayerRecorder: NSObject { videoInput = vInput } - // 音频输入 + // 音频输入(AAC) let audioSettings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 44100, @@ -226,39 +246,44 @@ final class PlayerRecorder: NSObject { audioInput = aInput } - let success = writer.startWriting() - if !success { - print("[Recorder] startWriting failed: \(writer.error?.localizedDescription ?? "unknown")") + 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) - print("[Recorder] Writer started, session at .zero") + NSLog("[Recorder] Writer started, writing to: %@", url.lastPathComponent) - // 启动视频抓帧 + // 启动视频抓帧(30fps) isRecording = true isRunning = true - startTimer() startCaptureLoop() + startDurationTimer() // 启动音频捕获(MTAudioProcessingTap) setupAudioTap(playerItem: playerItem, audioWriterInput: aInput) } // MARK: - 停止录制 + func stopRecording() { guard isRecording else { return } isRecording = false isRunning = false captureTimer?.invalidate() captureTimer = nil - stopTimer() + stopDurationTimer() // 停止音频 tap audioContext?.isRunning = false + let totalAudioFrames = audioContext?.appendCount ?? 0 + let processCalls = audioContext?.processCallCount ?? 0 + NSLog("[Recorder] Stopping: videoFrames=%d, audioProcessCalls=%d, audioAppended=%d", + captureFrameCount, processCalls, totalAudioFrames) - // 移除 audioMix(移除 tap) + // 移除 audioMix currentPlayerItem?.audioMix = nil currentPlayerItem = nil @@ -273,10 +298,23 @@ final class PlayerRecorder: NSObject { 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) + 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 { + // 边录边写完成,仅做改名 + let finalFilename = self.generateFinalFilename() + let finalURL = self.saveDirectory.appendingPathComponent(finalFilename) + do { + try FileManager.default.moveItem(at: tempURL, to: finalURL) + NSLog("[Recorder] ✓ Saved: %@", finalURL.path) + self.onRecordingSaved?(finalURL) + } catch { + self.onError?("Save failed: \(error.localizedDescription)") + } } else { self.onError?("Recording failed: \(w.error?.localizedDescription ?? "unknown (status=\(w.status.rawValue))")") if let url = self.tempURL { @@ -290,6 +328,7 @@ final class PlayerRecorder: NSObject { } // MARK: - 音频 Tap 设置 + private func setupAudioTap(playerItem: AVPlayerItem, audioWriterInput: AVAssetWriterInput) { let asset = playerItem.asset @@ -297,15 +336,15 @@ final class PlayerRecorder: NSObject { do { let audioTracks = try await asset.loadTracks(withMediaType: .audio) guard let audioTrack = audioTracks.first else { - print("[Recorder] ❌ No audio track for tap") + NSLog("[Recorder] ❌ No audio track for tap") return } guard self.isRunning else { - print("[Recorder] ❌ isRunning=false, aborting audio tap setup") + NSLog("[Recorder] ❌ isRunning=false, aborting audio tap setup") return } - print("[Recorder] Audio track found: \(audioTrack.trackID), setting up tap...") + NSLog("[Recorder] Audio track found: %d, setting up tap...", audioTrack.trackID) // 创建音频捕获上下文 let context = AudioCaptureContext() @@ -313,18 +352,18 @@ final class PlayerRecorder: NSObject { context.isRunning = true self.audioContext = context - // ★ 设置全局引用,供 C 回调访问 + // 全局引用,供 C 回调访问 gAudioContext = context - // 创建 MTAudioProcessingTap + // 创建 MTAudioProcessingTap 回调结构体 var callbacks = MTAudioProcessingTapCallbacks( version: kMTAudioProcessingTapCallbacksVersion_0, clientInfo: nil, - init: tapInit, - finalize: tapFinalize, - prepare: tapPrepare, - unprepare: tapUnprepare, - process: tapProcess + init: tapInitCallback, + finalize: tapFinalizeCallback, + prepare: tapPrepareCallback, + unprepare: tapUnprepareCallback, + process: tapProcessCallback ) var tap: MTAudioProcessingTap? @@ -336,7 +375,7 @@ final class PlayerRecorder: NSObject { ) guard status == noErr, let audioTap = tap else { - print("[Recorder] ❌ MTAudioProcessingTapCreate failed: \(status)") + NSLog("[Recorder] ❌ MTAudioProcessingTapCreate failed: %d", status) gAudioContext = nil return } @@ -350,15 +389,14 @@ final class PlayerRecorder: NSObject { audioMix.inputParameters = [params] playerItem.audioMix = audioMix - print("[Recorder] ✓ Audio tap installed on player item") + NSLog("[Recorder] ✓ Audio tap installed on playerItem") } catch { - print("[Recorder] ❌ Audio tap setup failed: \(error)") + NSLog("[Recorder] Audio tap setup error: %@", error.localizedDescription) } } } // MARK: - 视频抓帧 (30fps Timer) - private var captureTimer: Timer? private func startCaptureLoop() { captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in @@ -375,19 +413,20 @@ final class PlayerRecorder: NSObject { 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 { + // 创建 sample buffer 并写入 + if let sb = Self.createSampleBuffer(from: pb, time: relativeTime) { if input.append(sb) { captureFrameCount += 1 } @@ -401,20 +440,31 @@ final class PlayerRecorder: NSObject { info.decodeTimeStamp = .invalid var formatDescription: CMFormatDescription? - CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &formatDescription) + 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) + CMSampleBufferCreateReadyWithImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + formatDescription: fd, + sampleTiming: &info, + sampleBufferOut: &sampleBuffer + ) return sampleBuffer } - // MARK: - Timer (显示录制时长) - private func startTimer() { + // MARK: - 计时器 + + private func startDurationTimer() { startDate = Date() durationText = "00:00" - timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + 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)) @@ -430,37 +480,19 @@ final class PlayerRecorder: NSObject { } } - private func stopTimer() { - timer?.invalidate() - timer = nil + private func stopDurationTimer() { + durationTimer?.invalidate() + durationTimer = 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) - } - } + // MARK: - 文件命名 + + private func generateFinalFilename() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + return "Recording_\(formatter.string(from: Date())).mp4" } }