refactor: replace AVAssetWriter+Tap recording with StreamRecorderKit FFmpegRecorder
- 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
This commit is contained in:
parent
70337add95
commit
221075ce8a
@ -9,13 +9,15 @@ let package = Package(
|
|||||||
.macOS(.v14)
|
.macOS(.v14)
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(path: "../swiftbricks")
|
.package(path: "../swiftbricks"),
|
||||||
|
.package(path: "../StreamRecorder")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "MiniPlayer",
|
name: "MiniPlayer",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "SwiftBricks", package: "swiftbricks")
|
.product(name: "SwiftBricks", package: "swiftbricks"),
|
||||||
|
.product(name: "StreamRecorderKit", package: "StreamRecorder")
|
||||||
],
|
],
|
||||||
resources: [.process("Resources")]
|
resources: [.process("Resources")]
|
||||||
)
|
)
|
||||||
|
|||||||
@ -85,7 +85,6 @@ final class PlayerBridge: ObservableObject {
|
|||||||
private var playlistWindow: NSWindow?
|
private var playlistWindow: NSWindow?
|
||||||
private var fullscreenEventMonitor: Any?
|
private var fullscreenEventMonitor: Any?
|
||||||
let screenRecorder = PlayerRecorder()
|
let screenRecorder = PlayerRecorder()
|
||||||
var videoOutput: AVPlayerItemVideoOutput?
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - 初始化
|
// MARK: - 初始化
|
||||||
@ -116,17 +115,12 @@ final class PlayerBridge: ObservableObject {
|
|||||||
if screenRecorder.isRecording {
|
if screenRecorder.isRecording {
|
||||||
screenRecorder.stopRecording()
|
screenRecorder.stopRecording()
|
||||||
} else {
|
} else {
|
||||||
guard let output = videoOutput else {
|
guard !queue.isEmpty, currentIndex >= 0, currentIndex < queue.count else {
|
||||||
showToast("No video playing")
|
showToast("No media playing")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard !queue.isEmpty else { return }
|
let item = queue[currentIndex]
|
||||||
let startTime = player.currentTime()
|
screenRecorder.startRecording(url: item.url)
|
||||||
guard let playerItem = player.currentItem else {
|
|
||||||
showToast("No player item")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
screenRecorder.startRecording(from: output, playerItem: playerItem, startTime: startTime)
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@ -178,16 +172,6 @@ final class PlayerBridge: ObservableObject {
|
|||||||
let asset = AVURLAsset(url: item.url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
|
let asset = AVURLAsset(url: item.url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
|
||||||
let playerItem = AVPlayerItem(asset: asset)
|
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.replaceCurrentItem(with: playerItem)
|
||||||
player.play()
|
player.play()
|
||||||
isPlaying = true
|
isPlaying = true
|
||||||
|
|||||||
@ -1,306 +1,30 @@
|
|||||||
import AVFoundation
|
|
||||||
import CoreMedia
|
|
||||||
import MediaToolbox
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import StreamRecorderKit
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
// MARK: - 全局音频 Tap 上下文(C 回调无法访问 Swift 对象)
|
// MARK: - RecorderState(应用退出保护)
|
||||||
|
|
||||||
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<UnsafeMutableRawPointer?>
|
|
||||||
) {
|
|
||||||
tapStorageOut.pointee = clientInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
private func tapFinalize(_ tap: MTAudioProcessingTap) {
|
|
||||||
// Context lifecycle managed elsewhere
|
|
||||||
}
|
|
||||||
|
|
||||||
private func tapPrepare(
|
|
||||||
_ tap: MTAudioProcessingTap,
|
|
||||||
_ maxFrames: CMItemCount,
|
|
||||||
_ processingFormat: UnsafePointer<AudioStreamBasicDescription>
|
|
||||||
) {
|
|
||||||
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<AudioTapContext>.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<AudioBufferList>,
|
|
||||||
_ bufferListSizeOut: UnsafeMutablePointer<Int>,
|
|
||||||
_ flagsOut: UnsafeMutablePointer<UInt32>
|
|
||||||
) {
|
|
||||||
bufferListSizeOut.pointee = 1
|
|
||||||
flagsOut.pointee = flags
|
|
||||||
|
|
||||||
let storage = MTAudioProcessingTapGetStorage(tap)
|
|
||||||
let ctx = Unmanaged<AudioTapContext>.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..<ablPtr.count {
|
|
||||||
if ablPtr[i].mData != nil && ablPtr[i].mDataByteSize > 0 {
|
|
||||||
totalSize += Int(ablPtr[i].mDataByteSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard totalSize > 0 else { ctx.skippedNoData += 1; return }
|
|
||||||
|
|
||||||
// === 诊断: 计算峰值音量 ===
|
|
||||||
var bufferPeak: Float = 0
|
|
||||||
for i in 0..<ablPtr.count {
|
|
||||||
guard let data = ablPtr[i].mData, ablPtr[i].mDataByteSize > 0 else { continue }
|
|
||||||
let floatPtr = data.assumingMemoryBound(to: Float.self)
|
|
||||||
let sampleCount = Int(ablPtr[i].mDataByteSize) / MemoryLayout<Float>.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..<ablPtr.count {
|
|
||||||
if let src = ablPtr[i].mData, ablPtr[i].mDataByteSize > 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
|
|
||||||
|
|
||||||
enum RecorderState {
|
enum RecorderState {
|
||||||
static nonisolated(unsafe) var isFinalizing: Bool = false
|
static nonisolated(unsafe) var isFinalizing: Bool = false
|
||||||
static nonisolated(unsafe) var pendingTerminate: Bool = false
|
static nonisolated(unsafe) var pendingTerminate: Bool = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 诊断统计快照
|
// MARK: - PlayerRecorder(基于 StreamRecorderKit FFmpegRecorder)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
/// 使用 FFmpeg 直接从流 URL 下载录制,不再使用 AVAssetWriter + MTAudioProcessingTap 实时捕获方案。
|
||||||
|
/// 优势:
|
||||||
|
/// - 直接从源流复制/转码,质量更好
|
||||||
|
/// - 代码量从 883 行降至 ~150 行
|
||||||
|
/// - 不再依赖 AVPlayerItemVideoOutput / copyPixelBuffer
|
||||||
|
/// - 录制与播放完全解耦(暂停播放不影响录制)
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PlayerRecorder: NSObject {
|
final class PlayerRecorder: NSObject {
|
||||||
|
|
||||||
private var writer: AVAssetWriter?
|
private var recorder: FFmpegRecorder?
|
||||||
nonisolated(unsafe) private var videoInput: AVAssetWriterInput?
|
private var recordingTask: Task<Void, Never>?
|
||||||
private var audioInput: AVAssetWriterInput?
|
|
||||||
private var captureTimer: DispatchSourceTimer?
|
|
||||||
private let captureQueue = DispatchQueue(label: "miniplayer.videoCapture", qos: .userInteractive)
|
|
||||||
private var durationTimer: Timer?
|
private var durationTimer: Timer?
|
||||||
private var startDate: Date?
|
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 isRecording = false
|
||||||
@Published var durationText = "00:00"
|
@Published var durationText = "00:00"
|
||||||
@ -308,8 +32,6 @@ final class PlayerRecorder: NSObject {
|
|||||||
var onRecordingSaved: ((URL) -> Void)?
|
var onRecordingSaved: ((URL) -> Void)?
|
||||||
var onError: ((String) -> Void)?
|
var onError: ((String) -> Void)?
|
||||||
|
|
||||||
private var tempURL: URL?
|
|
||||||
|
|
||||||
private var saveDirectory: URL {
|
private var saveDirectory: URL {
|
||||||
let movies = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first!
|
let movies = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first!
|
||||||
let dir = movies.appendingPathComponent("MiniPlayer", isDirectory: true)
|
let dir = movies.appendingPathComponent("MiniPlayer", isDirectory: true)
|
||||||
@ -317,237 +39,46 @@ final class PlayerRecorder: NSObject {
|
|||||||
return dir
|
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: - 开始录制
|
// MARK: - 开始录制
|
||||||
|
|
||||||
func startRecording(from output: AVPlayerItemVideoOutput, playerItem: AVPlayerItem, startTime: CMTime) {
|
func startRecording(url: URL) {
|
||||||
guard !isRecording else { return }
|
guard !isRecording else { return }
|
||||||
|
|
||||||
weakOutput = output
|
let recorder = FFmpegRecorder()
|
||||||
currentPlayerItem = playerItem
|
self.recorder = recorder
|
||||||
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 tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4"
|
let tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4"
|
||||||
let url = saveDirectory.appendingPathComponent(tempFilename)
|
let tempURL = saveDirectory.appendingPathComponent(tempFilename)
|
||||||
try? FileManager.default.removeItem(at: url)
|
try? FileManager.default.removeItem(at: tempURL)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
isRecording = true
|
||||||
isRunning = true
|
startDate = Date()
|
||||||
startCaptureLoop()
|
|
||||||
startDurationTimer()
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 停止录制
|
// MARK: - 停止录制
|
||||||
@ -555,179 +86,11 @@ final class PlayerRecorder: NSObject {
|
|||||||
func stopRecording() {
|
func stopRecording() {
|
||||||
guard isRecording else { return }
|
guard isRecording else { return }
|
||||||
isRecording = false
|
isRecording = false
|
||||||
isRunning = false
|
|
||||||
RecorderState.isFinalizing = true
|
RecorderState.isFinalizing = true
|
||||||
captureTimer?.cancel()
|
|
||||||
captureTimer = nil
|
|
||||||
stopDurationTimer()
|
stopDurationTimer()
|
||||||
|
|
||||||
// 停止音频 tap 写入
|
NSLog("[Recorder] Stopping recording...")
|
||||||
tapContext?.isRecording = false
|
recorder?.stop()
|
||||||
|
|
||||||
// 快照诊断数据(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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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: - 录制时长显示
|
// MARK: - 录制时长显示
|
||||||
@ -754,122 +117,33 @@ final class PlayerRecorder: NSObject {
|
|||||||
durationText = String(format: "%02d:%02d", min, sec)
|
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: - 保存对话框
|
// MARK: - 保存对话框
|
||||||
|
|
||||||
private func showSaveDialog(tempURL: URL) {
|
private func showSaveDialog(tempURL: URL) {
|
||||||
let reportURL = tempURL.deletingPathExtension().appendingPathExtension("txt")
|
|
||||||
let panel = NSSavePanel()
|
let panel = NSSavePanel()
|
||||||
panel.allowedContentTypes = [.mpeg4Movie]
|
panel.allowedContentTypes = [.mpeg4Movie]
|
||||||
panel.nameFieldStringValue = tempURL.lastPathComponent
|
panel.nameFieldStringValue = tempURL.lastPathComponent
|
||||||
panel.directoryURL = saveDirectory
|
panel.directoryURL = saveDirectory
|
||||||
|
|
||||||
panel.begin { [weak self] response in
|
panel.begin { [weak self] response in
|
||||||
if response == .OK, let url = panel.url {
|
Task { @MainActor in
|
||||||
do {
|
if response == .OK, let url = panel.url {
|
||||||
if FileManager.default.fileExists(atPath: url.path) {
|
do {
|
||||||
try FileManager.default.removeItem(at: url)
|
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)
|
|
||||||
}
|
}
|
||||||
try FileManager.default.moveItem(at: reportURL, to: reportDest)
|
try FileManager.default.moveItem(at: tempURL, to: url)
|
||||||
NSLog("[Recorder] ✓ Diagnostic report saved: %@", reportDest.path)
|
NSLog("[Recorder] ✓ Saved: %@", url.path)
|
||||||
|
self?.onRecordingSaved?(url)
|
||||||
|
} catch {
|
||||||
|
NSLog("[Recorder] ✗ Save failed: %@", error.localizedDescription)
|
||||||
|
self?.onError?("Save failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
self?.onRecordingSaved?(url)
|
try? FileManager.default.removeItem(at: tempURL)
|
||||||
} catch {
|
|
||||||
NSLog("[Recorder] ✗ Save failed: %@", error.localizedDescription)
|
|
||||||
self?.onError?("Save failed: \(error.localizedDescription)")
|
|
||||||
}
|
}
|
||||||
} else {
|
self?.checkPendingTerminate()
|
||||||
try? FileManager.default.removeItem(at: tempURL)
|
|
||||||
try? FileManager.default.removeItem(at: reportURL)
|
|
||||||
}
|
}
|
||||||
self?.checkPendingTerminate()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user