From 221075ce8ab1e7fd2d81e890a52169733cdf5fb5 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Sun, 28 Jun 2026 18:19:10 +0800 Subject: [PATCH] refactor: replace AVAssetWriter+Tap recording with StreamRecorderKit FFmpegRecorder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlayerRecorder.swift: 883 lines → ~150 lines - Recording now uses FFmpeg to download stream directly from URL - No longer depends on AVPlayerItemVideoOutput/copyPixelBuffer/MTAudioProcessingTap - Recording decoupled from playback (pausing playback doesn't affect recording) - Better quality: direct stream copy/transcode instead of re-encoding captured frames - Added StreamRecorderKit as local SPM dependency --- Package.swift | 6 +- Sources/PlayerBridge.swift | 24 +- Sources/PlayerRecorder.swift | 880 +++-------------------------------- 3 files changed, 85 insertions(+), 825 deletions(-) diff --git a/Package.swift b/Package.swift index 2d0c188..cfa97bc 100644 --- a/Package.swift +++ b/Package.swift @@ -9,13 +9,15 @@ let package = Package( .macOS(.v14) ], dependencies: [ - .package(path: "../swiftbricks") + .package(path: "../swiftbricks"), + .package(path: "../StreamRecorder") ], targets: [ .executableTarget( name: "MiniPlayer", dependencies: [ - .product(name: "SwiftBricks", package: "swiftbricks") + .product(name: "SwiftBricks", package: "swiftbricks"), + .product(name: "StreamRecorderKit", package: "StreamRecorder") ], resources: [.process("Resources")] ) diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 5e0fe52..44e4d04 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -85,7 +85,6 @@ final class PlayerBridge: ObservableObject { private var playlistWindow: NSWindow? private var fullscreenEventMonitor: Any? let screenRecorder = PlayerRecorder() - var videoOutput: AVPlayerItemVideoOutput? #endif // MARK: - 初始化 @@ -116,17 +115,12 @@ final class PlayerBridge: ObservableObject { if screenRecorder.isRecording { screenRecorder.stopRecording() } else { - guard let output = videoOutput else { - showToast("No video playing") + guard !queue.isEmpty, currentIndex >= 0, currentIndex < queue.count else { + showToast("No media playing") return } - guard !queue.isEmpty else { return } - let startTime = player.currentTime() - guard let playerItem = player.currentItem else { - showToast("No player item") - return - } - screenRecorder.startRecording(from: output, playerItem: playerItem, startTime: startTime) + let item = queue[currentIndex] + screenRecorder.startRecording(url: item.url) } #endif } @@ -178,16 +172,6 @@ final class PlayerBridge: ObservableObject { let asset = AVURLAsset(url: item.url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) let playerItem = AVPlayerItem(asset: asset) - #if os(macOS) - // 添加 video output 用于录制 - let pixelBufferAttributes: [String: Any] = [ - kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA - ] - let output = AVPlayerItemVideoOutput(pixelBufferAttributes: pixelBufferAttributes) - playerItem.add(output) - videoOutput = output - #endif - player.replaceCurrentItem(with: playerItem) player.play() isPlaying = true diff --git a/Sources/PlayerRecorder.swift b/Sources/PlayerRecorder.swift index fd29f13..5539856 100644 --- a/Sources/PlayerRecorder.swift +++ b/Sources/PlayerRecorder.swift @@ -1,737 +1,100 @@ -import AVFoundation -import CoreMedia -import MediaToolbox import Foundation +import StreamRecorderKit #if os(macOS) import AppKit -// MARK: - 全局音频 Tap 上下文(C 回调无法访问 Swift 对象) - -private nonisolated(unsafe) var gTapContext: AudioTapContext? - -class AudioTapContext { - nonisolated(unsafe) var writerInput: AVAssetWriterInput? - nonisolated(unsafe) var isRecording: Bool = false - nonisolated(unsafe) var formatDescription: CMAudioFormatDescription? - nonisolated(unsafe) var sampleRate: Double = 0 - nonisolated(unsafe) var channelsPerFrame: UInt32 = 0 - nonisolated(unsafe) var appendCount: Int = 0 - nonisolated(unsafe) var processCallCount: Int = 0 - nonisolated(unsafe) var firstAppendLogged: Bool = false - nonisolated(unsafe) var totalFramesWritten: Int64 = 0 - - /// 双缓冲区(消除数据竞争:写A时async读B,交替使用) - nonisolated(unsafe) var dataBuffers: (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) = (nil, nil) - nonisolated(unsafe) var dataBufferSizes: (Int, Int) = (0, 0) - nonisolated(unsafe) var writeIndex: Int = 0 - - // === 诊断统计 === - nonisolated(unsafe) var peakAmplitude: Float = 0 // 全局峰值 - nonisolated(unsafe) var silentCallCount: Int = 0 // 静音回调次数 - nonisolated(unsafe) var activeCallCount: Int = 0 // 有声音的回调次数 - nonisolated(unsafe) var skippedNotRecording: Int = 0 // isRecording=false 跳过次数 - nonisolated(unsafe) var skippedNoFormat: Int = 0 // formatDescription 为空跳过次数 - nonisolated(unsafe) var skippedNoData: Int = 0 // buffer 无数据跳过次数 - nonisolated(unsafe) var appendFailCount: Int = 0 // append 失败次数 - nonisolated(unsafe) var notReadyCount: Int = 0 // writerInput not ready 次数 - nonisolated(unsafe) var lastActiveTimestamp: Double = 0 // 最后一次有声音的时间(秒) - nonisolated(unsafe) var recordStartTime: Date? // 录制开始时间 - nonisolated(unsafe) var mixTrackID: Int32 = -1 // audioMix 绑定的 trackID - - let audioQueue = DispatchQueue(label: "miniplayer.audioTap") -} - -// MARK: - MTAudioProcessingTap C 回调 - -private func tapInit( - _ tap: MTAudioProcessingTap, - _ clientInfo: UnsafeMutableRawPointer?, - _ tapStorageOut: UnsafeMutablePointer -) { - tapStorageOut.pointee = clientInfo -} - -private func tapFinalize(_ tap: MTAudioProcessingTap) { - // Context lifecycle managed elsewhere -} - -private func tapPrepare( - _ tap: MTAudioProcessingTap, - _ maxFrames: CMItemCount, - _ processingFormat: UnsafePointer -) { - let asbd = processingFormat.pointee - NSLog("[Recorder] Tap prepare: rate=%.0f ch=%u formatFlags=0x%x", - asbd.mSampleRate, asbd.mChannelsPerFrame, asbd.mFormatFlags) - - let storage = MTAudioProcessingTapGetStorage(tap) - let ctx = Unmanaged.fromOpaque(storage).takeUnretainedValue() - ctx.sampleRate = asbd.mSampleRate - ctx.channelsPerFrame = asbd.mChannelsPerFrame - - var fmtDesc: CMAudioFormatDescription? - var mutableASBD = asbd - CMAudioFormatDescriptionCreate( - allocator: kCFAllocatorDefault, - asbd: &mutableASBD, - layoutSize: 0, layout: nil, - magicCookieSize: 0, magicCookie: nil, - extensions: nil, - formatDescriptionOut: &fmtDesc - ) - ctx.formatDescription = fmtDesc - NSLog("[Recorder] Tap format ready: %@", fmtDesc != nil ? "OK" : "FAILED") -} - -private func tapUnprepare(_ tap: MTAudioProcessingTap) { - NSLog("[Recorder] Tap unprepare") -} - -private func tapProcess( - _ tap: MTAudioProcessingTap, - _ numberFrames: Int, - _ flags: UInt32, - _ bufferListInOut: UnsafeMutablePointer, - _ bufferListSizeOut: UnsafeMutablePointer, - _ flagsOut: UnsafeMutablePointer -) { - bufferListSizeOut.pointee = 1 - flagsOut.pointee = flags - - let storage = MTAudioProcessingTapGetStorage(tap) - let ctx = Unmanaged.fromOpaque(storage).takeUnretainedValue() - - ctx.processCallCount += 1 - guard ctx.isRecording else { ctx.skippedNotRecording += 1; return } - guard let _ = ctx.writerInput else { ctx.skippedNoFormat += 1; return } - guard let _ = ctx.formatDescription else { ctx.skippedNoFormat += 1; return } - - let ablPtr = UnsafeMutableAudioBufferListPointer(bufferListInOut) - - // 计算总数据量 - var totalSize: Int = 0 - for i in 0.. 0 { - totalSize += Int(ablPtr[i].mDataByteSize) - } - } - guard totalSize > 0 else { ctx.skippedNoData += 1; return } - - // === 诊断: 计算峰值音量 === - var bufferPeak: Float = 0 - for i in 0.. 0 else { continue } - let floatPtr = data.assumingMemoryBound(to: Float.self) - let sampleCount = Int(ablPtr[i].mDataByteSize) / MemoryLayout.size - for s in stride(from: 0, to: sampleCount, by: 16) { // 每16个采样检查一次,避免性能问题 - let absVal = abs(floatPtr[s]) - if absVal > bufferPeak { bufferPeak = absVal } - } - } - if bufferPeak > ctx.peakAmplitude { ctx.peakAmplitude = bufferPeak } - - // 静音阈值: float PCM < 0.001 ≈ -60dB - let isActive = bufferPeak > 0.001 - if isActive { - ctx.activeCallCount += 1 - if let start = ctx.recordStartTime { - ctx.lastActiveTimestamp = Date().timeIntervalSince(start) - } - } else { - ctx.silentCallCount += 1 - } - - // 双缓冲:写当前槽,async读另一个槽(消除数据竞争) - let wi = ctx.writeIndex - ctx.writeIndex = 1 - wi // 切换槽位 - - if ctx.dataBufferSizes.0 < totalSize || ctx.dataBufferSizes.1 < totalSize { - // 只在需要时扩容(不会在实时路径频繁触发) - let newSize = max(totalSize, 65536) // 至少64KB - if wi == 0 { - if let existing = ctx.dataBuffers.0 { free(existing) } - ctx.dataBuffers.0 = malloc(newSize) - ctx.dataBufferSizes.0 = newSize - } else { - if let existing = ctx.dataBuffers.1 { free(existing) } - ctx.dataBuffers.1 = malloc(newSize) - ctx.dataBufferSizes.1 = newSize - } - } - - let dest: UnsafeMutableRawPointer? - if wi == 0 { dest = ctx.dataBuffers.0 } else { dest = ctx.dataBuffers.1 } - guard let dest = dest else { return } - - var offset = 0 - for i in 0.. 0 { - let sz = Int(ablPtr[i].mDataByteSize) - memcpy(dest + offset, src, sz) - offset += sz - } - } - - let frameCount = Int64(numberFrames) - let startFrame = ctx.totalFramesWritten - let readWi = wi // async 读取的是刚写完的槽 - - // 在串行队列创建 CMSampleBuffer 并写入 - ctx.audioQueue.async { - guard ctx.isRecording, - let writerInput = ctx.writerInput, - let fd = ctx.formatDescription else { return } - - // 从已写完的槽读取(此时实时线程正在写另一个槽,安全) - let readDest: UnsafeMutableRawPointer? - if readWi == 0 { readDest = ctx.dataBuffers.0 } else { readDest = ctx.dataBuffers.1 } - guard let readDest = readDest else { return } - - var blockBuffer: CMBlockBuffer? - let blockStatus = CMBlockBufferCreateWithMemoryBlock( - allocator: kCFAllocatorDefault, memoryBlock: nil, - blockLength: totalSize, blockAllocator: kCFAllocatorDefault, - customBlockSource: nil, offsetToData: 0, - dataLength: totalSize, - flags: kCMBlockBufferAssureMemoryNowFlag, - blockBufferOut: &blockBuffer - ) - guard blockStatus == kCMBlockBufferNoErr, let bb = blockBuffer else { return } - - let replaceStatus = CMBlockBufferReplaceDataBytes( - with: readDest, blockBuffer: bb, - offsetIntoDestination: 0, dataLength: totalSize - ) - guard replaceStatus == kCMBlockBufferNoErr else { return } - - // PTS 基于累计帧数计算 - let pts = CMTime( - value: CMTimeValue(startFrame), - timescale: Int32(ctx.sampleRate) - ) - var timingInfo = CMSampleTimingInfo( - duration: CMTime(value: CMTimeValue(frameCount), timescale: Int32(ctx.sampleRate)), - presentationTimeStamp: pts, - decodeTimeStamp: .invalid - ) - - var sampleBuffer: CMSampleBuffer? - let createStatus = CMSampleBufferCreateReady( - allocator: kCFAllocatorDefault, - dataBuffer: bb, - formatDescription: fd, - sampleCount: Int(frameCount), - sampleTimingEntryCount: 1, - sampleTimingArray: &timingInfo, - sampleSizeEntryCount: 0, - sampleSizeArray: nil, - sampleBufferOut: &sampleBuffer - ) - guard createStatus == noErr, let sb = sampleBuffer else { - NSLog("[Recorder] ⚠️ SampleBuffer create failed: %d", createStatus) - return - } - - if writerInput.isReadyForMoreMediaData { - if writerInput.append(sb) { - ctx.appendCount += 1 - ctx.totalFramesWritten += frameCount - if !ctx.firstAppendLogged { - ctx.firstAppendLogged = true - NSLog("[Recorder] ✓ First audio captured: pts=%.3fs, frames=%lld, size=%d, peak=%.4f", - pts.seconds, frameCount, totalSize, bufferPeak) - } - } else { - ctx.appendFailCount += 1 - } - } else { - ctx.notReadyCount += 1 - } - } -} - -// MARK: - RecorderState +// MARK: - RecorderState(应用退出保护) enum RecorderState { static nonisolated(unsafe) var isFinalizing: Bool = false static nonisolated(unsafe) var pendingTerminate: Bool = false } -// MARK: - 诊断统计快照 - -struct AudioDiagStats { - let videoFrames: Int - let processCallCount: Int - let appendCount: Int - let totalFramesWritten: Int64 - let peakAmplitude: Float - let activeCallCount: Int - let silentCallCount: Int - let skippedNotRecording: Int - let skippedNoFormat: Int - let skippedNoData: Int - let appendFailCount: Int - let notReadyCount: Int - let lastActiveTimestamp: Double - let sampleRate: Double - let channelsPerFrame: UInt32 - let mixTrackID: Int32 -} - -// MARK: - PlayerRecorder +// MARK: - PlayerRecorder(基于 StreamRecorderKit FFmpegRecorder) +/// 使用 FFmpeg 直接从流 URL 下载录制,不再使用 AVAssetWriter + MTAudioProcessingTap 实时捕获方案。 +/// 优势: +/// - 直接从源流复制/转码,质量更好 +/// - 代码量从 883 行降至 ~150 行 +/// - 不再依赖 AVPlayerItemVideoOutput / copyPixelBuffer +/// - 录制与播放完全解耦(暂停播放不影响录制) @MainActor final class PlayerRecorder: NSObject { - - private var writer: AVAssetWriter? - nonisolated(unsafe) private var videoInput: AVAssetWriterInput? - private var audioInput: AVAssetWriterInput? - private var captureTimer: DispatchSourceTimer? - private let captureQueue = DispatchQueue(label: "miniplayer.videoCapture", qos: .userInteractive) + + private var recorder: FFmpegRecorder? + private var recordingTask: Task? private var durationTimer: Timer? private var startDate: Date? - nonisolated(unsafe) private weak var weakOutput: AVPlayerItemVideoOutput? - nonisolated(unsafe) private var isRunning = false - - nonisolated(unsafe) private var lastPixelBuffer: CVPixelBuffer? - nonisolated(unsafe) private var captureFrameCount: Int = 0 - - // Audio Tap - private var tapContext: AudioTapContext? - 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: - Audio Tap Setup - - /// 安装音频 Tap(使用 playerItem.tracks 获取轨道,支持 HLS 虚拟 asset) - /// 应在 playerItem 开始播放前调用(playIndex 中),避免播放中设置 audioMix 导致视频卡顿 - func installAudioTap(on playerItem: AVPlayerItem) { - // 确保全局上下文存在 - if gTapContext == nil { - gTapContext = AudioTapContext() - } - guard let ctx = gTapContext else { return } - - print("[Recorder:DIAG] === installAudioTap START ===") - - // HLS 虚拟 asset 的 asset.tracks 为空,必须用 playerItem.tracks - let itemTracks = playerItem.tracks - print("[Recorder:DIAG] playerItem.tracks count: \(itemTracks.count)") - - // 找到音频轨道 - var audioTrackID: CMPersistentTrackID = kCMPersistentTrackID_Invalid - for (i, t) in itemTracks.enumerated() { - let tid = t.assetTrack?.trackID ?? -1 - let mediaType = t.assetTrack?.mediaType.rawValue ?? "?" - let enabled = t.isEnabled - print("[Recorder:DIAG] itemTrack[\(i)]: trackID=\(tid) mediaType=\(mediaType) enabled=\(enabled)") - if t.assetTrack?.mediaType == .audio && t.isEnabled { - audioTrackID = tid - } - } - - if audioTrackID == kCMPersistentTrackID_Invalid { - print("[Recorder:DIAG] ⚠️ No enabled audio track found yet (tracks=\(itemTracks.count)), using wildcard") - } - - print("[Recorder:DIAG] Using audio trackID=\(audioTrackID)") - - // 创建 MTAudioProcessingTap - var callbacks = MTAudioProcessingTapCallbacks( - version: kMTAudioProcessingTapCallbacksVersion_0, - clientInfo: Unmanaged.passUnretained(ctx).toOpaque(), - init: tapInit, - finalize: tapFinalize, - prepare: tapPrepare, - unprepare: tapUnprepare, - process: tapProcess - ) - - var tap: MTAudioProcessingTap? - let tapStatus = MTAudioProcessingTapCreate( - kCFAllocatorDefault, &callbacks, - kMTAudioProcessingTapCreationFlag_PreEffects, - &tap - ) - print("[Recorder:DIAG] MTAudioProcessingTapCreate status=\(tapStatus) (noErr=\(noErr))") - guard tapStatus == noErr, let tapRef = tap else { - print("[Recorder:DIAG] ⚠️ Tap creation failed!") - return - } - - // 使用 kCMPersistentTrackID_Invalid 匹配所有音频轨道 - // HLS variant 切换会导致 trackID 变化(如 2→10),绑死具体 ID 会失效 - let inputParams = AVMutableAudioMixInputParameters() - inputParams.trackID = kCMPersistentTrackID_Invalid - inputParams.audioTapProcessor = tapRef - - let audioMix = AVMutableAudioMix() - audioMix.inputParameters = [inputParams] - - playerItem.audioMix = audioMix - - // 验证 - let verifyMix = playerItem.audioMix - print("[Recorder:DIAG] playerItem.audioMix set=\(verifyMix != nil)") - if let vMix = verifyMix, let params = vMix.inputParameters.first { - print("[Recorder:DIAG] verified trackID=\(params.trackID)") - } - print("[Recorder:DIAG] === installAudioTap DONE ===") - - ctx.mixTrackID = kCMPersistentTrackID_Invalid - - // 写诊断日志 - var diagLog = "=== installAudioTap Log ===\n" - diagLog += "Time: \(Date())\n" - diagLog += "Source: playerItem.tracks\n" - diagLog += "playerItem.tracks count: \(itemTracks.count)\n" - for (i, t) in itemTracks.enumerated() { - diagLog += " itemTrack[\(i)]: trackID=\(t.assetTrack?.trackID ?? -1) mediaType=\(t.assetTrack?.mediaType.rawValue ?? "?") enabled=\(t.isEnabled)\n" - } - diagLog += "Audio trackID used: kCMPersistentTrackID_Invalid (wildcard, matches all)\n" - diagLog += "Discovered audio trackID: \(audioTrackID)\n" - diagLog += "TapCreate status: \(tapStatus)\n" - diagLog += "audioMix set: \(verifyMix != nil)\n" - writeDiagLog(diagLog) - } - - private func writeDiagLog(_ content: String) { - if let moviesDir = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first { - let logPath = moviesDir.appendingPathComponent("MiniPlayer/tap_install_log.txt") - try? content.write(to: logPath, atomically: true, encoding: .utf8) - print("[Recorder:DIAG] Log written to: \(logPath.path)") - } - } - + // MARK: - 开始录制 - - func startRecording(from output: AVPlayerItemVideoOutput, playerItem: AVPlayerItem, startTime: CMTime) { + + func startRecording(url: URL) { guard !isRecording else { return } - - weakOutput = output - currentPlayerItem = playerItem - lastPixelBuffer = nil - captureFrameCount = 0 - isRunning = false - recordStartTime = startTime - - // 安装音频 Tap(如果 playIndex 中已预装,则跳过) - if playerItem.audioMix == nil { - installAudioTap(on: playerItem) - } else { - print("[Recorder:DIAG] audioMix already installed, skipping") - } - + + let recorder = FFmpegRecorder() + self.recorder = recorder + let tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4" - let url = saveDirectory.appendingPathComponent(tempFilename) - try? FileManager.default.removeItem(at: url) - tempURL = url - - // 获取 tap context(可能还没有 format,先用默认值) - let ctx = gTapContext ?? AudioTapContext() - if gTapContext == nil { gTapContext = ctx } - self.tapContext = ctx - - 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 编码)— 在 startWriting 之前添加 - // 使用 tap 已知的格式,或默认值 - let audioRate = ctx.sampleRate > 0 ? ctx.sampleRate : 48000.0 - let audioCh = ctx.channelsPerFrame > 0 ? ctx.channelsPerFrame : 2 - - var sourceFmtDesc: CMAudioFormatDescription? - if let tapFmt = ctx.formatDescription { - sourceFmtDesc = tapFmt - } else { - // Tap 还没有 prepare,创建默认格式描述 - var asbd = AudioStreamBasicDescription( - mSampleRate: audioRate, - mFormatID: kAudioFormatLinearPCM, - mFormatFlags: kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked | kAudioFormatFlagIsNonInterleaved, - mBytesPerPacket: 4, - mFramesPerPacket: 1, - mBytesPerFrame: 4, - mChannelsPerFrame: audioCh, - mBitsPerChannel: 32, - mReserved: 0 - ) - CMAudioFormatDescriptionCreate( - allocator: kCFAllocatorDefault, - asbd: &asbd, layoutSize: 0, layout: nil, - magicCookieSize: 0, magicCookie: nil, - extensions: nil, formatDescriptionOut: &sourceFmtDesc - ) - } - - if let fmtDesc = sourceFmtDesc { - let audioSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVSampleRateKey: audioRate, - AVNumberOfChannelsKey: audioCh, - AVEncoderBitRateKey: 128_000 - ] - let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings, sourceFormatHint: fmtDesc) - aInput.expectsMediaDataInRealTime = true - if writer.canAdd(aInput) { - writer.add(aInput) - audioInput = aInput - NSLog("[Recorder] AudioInput added: AAC rate=%.0f ch=%u", audioRate, audioCh) + let tempURL = saveDirectory.appendingPathComponent(tempFilename) + try? FileManager.default.removeItem(at: tempURL) + + isRecording = true + startDate = Date() + startDurationTimer() + + NSLog("[Recorder] Starting FFmpeg recording: %@ → %@", url.lastPathComponent, tempFilename) + + recordingTask = Task { [weak self] in + do { + try await recorder.start(url: url, outputPath: tempURL.path, duration: 0) + // duration=0 表示无限录制,直到 stop() 被调用 + // start 在 stop() 后会正常返回 + NSLog("[Recorder] ✓ Recording completed: %@", tempURL.path) + await MainActor.run { + RecorderState.isFinalizing = false + self?.stopDurationTimer() + self?.showSaveDialog(tempURL: tempURL) + } + } catch { + NSLog("[Recorder] ✗ Recording failed: %@", error.localizedDescription) + await MainActor.run { + RecorderState.isFinalizing = false + self?.stopDurationTimer() + self?.onError?(error.localizedDescription) + try? FileManager.default.removeItem(at: tempURL) + self?.checkPendingTerminate() + } } } - - guard writer.startWriting() else { - NSLog("[Recorder] startWriting failed: %@", writer.error?.localizedDescription ?? "unknown") - onError?("Cannot start writer: \(writer.error?.localizedDescription ?? "unknown")") - return - } - - writer.startSession(atSourceTime: .zero) - NSLog("[Recorder] Writer started, writing to: %@", url.lastPathComponent) - - // 重置 tap context 录制状态 - ctx.isRecording = true - ctx.writerInput = audioInput - ctx.appendCount = 0 - ctx.totalFramesWritten = 0 - ctx.firstAppendLogged = false - ctx.processCallCount = 0 - - // 重置诊断统计 - ctx.peakAmplitude = 0 - ctx.silentCallCount = 0 - ctx.activeCallCount = 0 - ctx.skippedNotRecording = 0 - ctx.skippedNoFormat = 0 - ctx.skippedNoData = 0 - ctx.appendFailCount = 0 - ctx.notReadyCount = 0 - ctx.lastActiveTimestamp = 0 - ctx.recordStartTime = Date() - - isRecording = true - isRunning = true - startCaptureLoop() - startDurationTimer() } - + // MARK: - 停止录制 - + func stopRecording() { guard isRecording else { return } isRecording = false - isRunning = false RecorderState.isFinalizing = true - captureTimer?.cancel() - captureTimer = nil stopDurationTimer() - - // 停止音频 tap 写入 - tapContext?.isRecording = false - - // 快照诊断数据(tap 停止后不会再更新) - let statsSnapshot = AudioDiagStats( - videoFrames: captureFrameCount, - processCallCount: tapContext?.processCallCount ?? 0, - appendCount: tapContext?.appendCount ?? 0, - totalFramesWritten: tapContext?.totalFramesWritten ?? 0, - peakAmplitude: tapContext?.peakAmplitude ?? 0, - activeCallCount: tapContext?.activeCallCount ?? 0, - silentCallCount: tapContext?.silentCallCount ?? 0, - skippedNotRecording: tapContext?.skippedNotRecording ?? 0, - skippedNoFormat: tapContext?.skippedNoFormat ?? 0, - skippedNoData: tapContext?.skippedNoData ?? 0, - appendFailCount: tapContext?.appendFailCount ?? 0, - notReadyCount: tapContext?.notReadyCount ?? 0, - lastActiveTimestamp: tapContext?.lastActiveTimestamp ?? 0, - sampleRate: tapContext?.sampleRate ?? 0, - channelsPerFrame: tapContext?.channelsPerFrame ?? 0, - mixTrackID: tapContext?.mixTrackID ?? -1 - ) - - NSLog("[Recorder] Stopping: videoFrames=%d, audioProcessCalls=%d, audioAppended=%d, peak=%.4f, active=%d, silent=%d", - statsSnapshot.videoFrames, statsSnapshot.processCallCount, statsSnapshot.appendCount, - statsSnapshot.peakAmplitude, statsSnapshot.activeCallCount, statsSnapshot.silentCallCount) - - // 诊断:检查录制结束时 tracks 是否变化 - if let pi = currentPlayerItem, let boundID = tapContext?.mixTrackID { - let currentTracks = pi.tracks - NSLog("[Recorder:DIAG] Stop-time tracks: count=%d", currentTracks.count) - var stopLog = "\n=== Stop-time tracks check ===\n" - stopLog += "Bound trackID: \(boundID)\n" - stopLog += "Current tracks count: \(currentTracks.count)\n" - var foundBoundTrack = false - for (i, t) in currentTracks.enumerated() { - let tid = t.assetTrack?.trackID ?? -1 - let mtype = t.assetTrack?.mediaType.rawValue ?? "?" - NSLog("[Recorder:DIAG] stopTrack[%d]: trackID=%d mediaType=%@ enabled=%d", i, tid, mtype, t.isEnabled) - stopLog += " track[\(i)]: trackID=\(tid) mediaType=\(mtype) enabled=\(t.isEnabled)\n" - if tid == boundID { - foundBoundTrack = true - } - } - stopLog += "Bound trackID \(boundID) still exists: \(foundBoundTrack)\n" - NSLog("[Recorder:DIAG] Bound trackID=%d still exists: %d", boundID, foundBoundTrack) - // 追加到日志文件 - if let moviesDir = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first { - let logPath = moviesDir.appendingPathComponent("MiniPlayer/tap_install_log.txt") - if let handle = try? FileHandle(forWritingTo: logPath) { - handle.seekToEndOfFile() - handle.write(stopLog.data(using: .utf8)!) - handle.closeFile() - } - } - } - - 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 - } - NSLog("[Recorder] finishWriting: status=%d, error=%@, videoFrames=%d, audioFrames=%d", - w.status.rawValue, - w.error?.localizedDescription ?? "none", - statsSnapshot.videoFrames, statsSnapshot.appendCount) - - 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) - // 写诊断报告(与 mp4 同目录) - self.writeDiagnosticReport(stats: statsSnapshot, videoURL: tempURL) - 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 + + NSLog("[Recorder] Stopping recording...") + recorder?.stop() } - // MARK: - 视频抓帧 (30fps, 独立队列避免阻塞主线程渲染) - - private func startCaptureLoop() { - let timer = DispatchSource.makeTimerSource(queue: captureQueue) - timer.schedule(deadline: .now(), repeating: .milliseconds(33), leeway: .milliseconds(2)) - timer.setEventHandler { [weak self] in - self?.captureVideoFrame() - } - timer.resume() - captureTimer = timer - } - - nonisolated 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 } - - if let sb = Self.createSampleBuffer(from: pb, time: relativeTime) { - if input.append(sb) { - captureFrameCount += 1 - } - } - } - - nonisolated 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() durationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in @@ -740,12 +103,12 @@ final class PlayerRecorder: NSObject { } } } - + private func stopDurationTimer() { durationTimer?.invalidate() durationTimer = nil } - + private func updateDuration() { guard let start = startDate else { return } let elapsed = Int(Date().timeIntervalSince(start)) @@ -753,126 +116,37 @@ final class PlayerRecorder: NSObject { let sec = elapsed % 60 durationText = String(format: "%02d:%02d", min, sec) } - - // MARK: - 诊断报告 - - /// 录制完成后生成诊断报告文件(.txt),与 mp4 同目录 - private func writeDiagnosticReport(stats: AudioDiagStats, videoURL: URL) { - let reportURL = videoURL.deletingPathExtension().appendingPathExtension("txt") - - let videoDuration = Double(stats.videoFrames) / 30.0 // 30fps - let audioDuration = stats.sampleRate > 0 ? Double(stats.totalFramesWritten) / stats.sampleRate : 0 - - let peakDB = stats.peakAmplitude > 0 ? 20 * log10(stats.peakAmplitude) : -Float.infinity - - var verdict: String - if stats.processCallCount == 0 { - verdict = "FAIL: tap never called — audioMix not attached or wrong trackID" - } else if stats.activeCallCount == 0 && stats.silentCallCount > 0 { - verdict = "FAIL: tap called \(stats.processCallCount)x but ALL silent — source is muted or tap bound to wrong track" - } else if stats.appendCount == 0 { - verdict = "FAIL: tap active (\(stats.activeCallCount) calls) but 0 appends — writerInput issue (skippedNotRecording=\(stats.skippedNotRecording), appendFail=\(stats.appendFailCount), notReady=\(stats.notReadyCount))" - } else if stats.activeCallCount > 0 && audioDuration < videoDuration * 0.5 { - verdict = "WARN: audio \(String(format: "%.1f", audioDuration))s < video \(String(format: "%.1f", videoDuration))s — tap stopped mid-recording (HLS variant switch?)" - } else if stats.peakAmplitude < 0.001 { - verdict = "FAIL: peak \(String(format: "%.6f", stats.peakAmplitude)) (\(String(format: "%.1f", peakDB))dB) — effectively silent" - } else { - verdict = "OK" - } - - let lines = [ - "=== MiniPlayer Recording Diagnostic Report ===", - "Generated: \(Date())", - "Video file: \(videoURL.lastPathComponent)", - "", - "--- Verdict ---", - verdict, - "", - "--- Video ---", - "Frames: \(stats.videoFrames)", - "Duration: \(String(format: "%.2f", videoDuration))s (@ 30fps)", - "", - "--- Audio Tap Stats ---", - "processCallCount: \(stats.processCallCount) (total tap callbacks)", - "activeCallCount: \(stats.activeCallCount) (had audio signal, peak > -60dB)", - "silentCallCount: \(stats.silentCallCount) (silence detected)", - "appendCount: \(stats.appendCount) (samples written to AVAssetWriter)", - "totalFramesWritten: \(stats.totalFramesWritten)", - "audioDuration: \(String(format: "%.2f", audioDuration))s", - "lastActiveAt: \(String(format: "%.2f", stats.lastActiveTimestamp))s (time from record start)", - "", - "--- Audio Format ---", - "sampleRate: \(String(format: "%.0f", stats.sampleRate)) Hz", - "channelsPerFrame: \(stats.channelsPerFrame)", - "mixTrackID: \(stats.mixTrackID) (bound in audioMix, -1=not set)", - "peakAmplitude: \(String(format: "%.6f", stats.peakAmplitude)) (\(String(format: "%.1f", peakDB)) dB)", - "", - "--- Skip/Error Counts ---", - "skippedNotRecording: \(stats.skippedNotRecording) (isRecording was false)", - "skippedNoFormat: \(stats.skippedNoFormat) (formatDescription was nil)", - "skippedNoData: \(stats.skippedNoData) (buffer had no data)", - "appendFailCount: \(stats.appendFailCount) (writerInput.append returned false)", - "notReadyCount: \(stats.notReadyCount) (writerInput not ready for data)", - "", - "--- How to Read ---", - "processCallCount=0 → audioMix never reached the tap (check trackID / variant switch)", - "activeCallCount=0 + silentCallCount>0 → tap gets data but it's all zeros (wrong track / muted source)", - "appendCount=0 + activeCallCount>0 → data captured but writer rejected it (format mismatch)", - "audioDuration << videoDuration → tap worked initially then stopped (HLS variant switch mid-recording)", - "peakAmplitude < 0.001 → effectively silent output", - ] - - let report = lines.joined(separator: "\n") - do { - try report.write(to: reportURL, atomically: true, encoding: .utf8) - NSLog("[Recorder] ✓ Diagnostic report: %@", reportURL.path) - print(report) // 也输出到终端 - } catch { - NSLog("[Recorder] ⚠️ Failed to write diagnostic report: %@", error.localizedDescription) - } - } - + // MARK: - 保存对话框 - + private func showSaveDialog(tempURL: URL) { - let reportURL = tempURL.deletingPathExtension().appendingPathExtension("txt") let panel = NSSavePanel() panel.allowedContentTypes = [.mpeg4Movie] panel.nameFieldStringValue = tempURL.lastPathComponent panel.directoryURL = saveDirectory - + panel.begin { [weak self] response in - if response == .OK, let url = panel.url { - do { - if FileManager.default.fileExists(atPath: url.path) { - try FileManager.default.removeItem(at: url) - } - try FileManager.default.moveItem(at: tempURL, to: url) - NSLog("[Recorder] ✓ Saved: %@", url.path) - - // 诊断报告也移到同目录 - if FileManager.default.fileExists(atPath: reportURL.path) { - let reportDest = url.deletingPathExtension().appendingPathExtension("txt") - if FileManager.default.fileExists(atPath: reportDest.path) { - try FileManager.default.removeItem(at: reportDest) + Task { @MainActor in + if response == .OK, let url = panel.url { + do { + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) } - try FileManager.default.moveItem(at: reportURL, to: reportDest) - NSLog("[Recorder] ✓ Diagnostic report saved: %@", reportDest.path) + try FileManager.default.moveItem(at: tempURL, to: url) + NSLog("[Recorder] ✓ Saved: %@", url.path) + self?.onRecordingSaved?(url) + } catch { + NSLog("[Recorder] ✗ Save failed: %@", error.localizedDescription) + self?.onError?("Save failed: \(error.localizedDescription)") } - - self?.onRecordingSaved?(url) - } catch { - NSLog("[Recorder] ✗ Save failed: %@", error.localizedDescription) - self?.onError?("Save failed: \(error.localizedDescription)") + } else { + try? FileManager.default.removeItem(at: tempURL) } - } else { - try? FileManager.default.removeItem(at: tempURL) - try? FileManager.default.removeItem(at: reportURL) + self?.checkPendingTerminate() } - self?.checkPendingTerminate() } } - + private func checkPendingTerminate() { if RecorderState.pendingTerminate { RecorderState.pendingTerminate = false