fix: HLS音频录制+边录边写+结束仅改名移动
1. 修复HLS无声音: - 统一回调函数签名,删除重复定义 - 回调函数名改为 tapInitCallback/tapFinalizeCallback 等避免歧义 - 添加NSLog诊断日志 2. 边录边写文件: - AVAssetWriter直接写入临时文件(边录边写,不缓存内存) - 临时文件名用时间戳,避免冲突 3. 录制结束仅改名移动: - finishWriting完成后直接moveItem到最终文件名 - 不再弹NSSavePanel - 最终文件名格式:Recording_yyyy-MM-dd_HH-mm-ss.mp4 - 保存到 ~/Movies/MiniPlayer/
This commit is contained in:
parent
d9f12ced06
commit
ff20ffe07f
@ -1,61 +1,75 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import CoreMedia
|
import CoreMedia
|
||||||
|
import MediaToolbox
|
||||||
|
import Foundation
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
// MARK: - 音频捕获上下文
|
// MARK: - 全局音频上下文(C 回调无法访问 Swift 对象,用全局变量)
|
||||||
|
private nonisolated(unsafe) var gAudioContext: AudioCaptureContext?
|
||||||
|
|
||||||
class AudioCaptureContext {
|
class AudioCaptureContext {
|
||||||
var writerInput: AVAssetWriterInput?
|
var writerInput: AVAssetWriterInput?
|
||||||
nonisolated(unsafe) var isRunning: Bool = true
|
nonisolated(unsafe) var isRunning: Bool = true
|
||||||
nonisolated(unsafe) var appendCount: Int = 0
|
|
||||||
nonisolated(unsafe) var formatDescription: CMAudioFormatDescription?
|
nonisolated(unsafe) var formatDescription: CMAudioFormatDescription?
|
||||||
|
nonisolated(unsafe) var appendCount: Int = 0
|
||||||
nonisolated(unsafe) var firstAppendLogged: Bool = false
|
nonisolated(unsafe) var firstAppendLogged: Bool = false
|
||||||
|
nonisolated(unsafe) var processCallCount: Int = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 全局引用,供 C 回调访问(单实例录制,线程安全由 isRunning 保护)
|
|
||||||
private nonisolated(unsafe) var gAudioContext: AudioCaptureContext?
|
|
||||||
|
|
||||||
// MARK: - MTAudioProcessingTap C 回调
|
// MARK: - MTAudioProcessingTap C 回调
|
||||||
private func tapPrepare(_ tap: MTAudioProcessingTap,
|
|
||||||
_ maxFramesPerSlice: CMItemCount,
|
private func tapInitCallback(
|
||||||
_ processingFormat: UnsafePointer<AudioStreamBasicDescription>) {
|
_ tap: MTAudioProcessingTap,
|
||||||
print("[Recorder] tapPrepare called, maxFrames=\(maxFramesPerSlice)")
|
_ clientInfo: UnsafeMutableRawPointer?,
|
||||||
guard let ctx = gAudioContext else {
|
_ tapStorageOut: UnsafeMutablePointer<UnsafeMutableRawPointer?>
|
||||||
print("[Recorder] tapPrepare: gAudioContext is nil!")
|
) {
|
||||||
return
|
// tapStorage 不需要额外分配,context 通过全局变量访问
|
||||||
}
|
tapStorageOut.pointee = clientInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tapFinalizeCallback(_ tap: MTAudioProcessingTap) {
|
||||||
|
// 清理由 stopRecording 负责
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tapPrepareCallback(
|
||||||
|
_ tap: MTAudioProcessingTap,
|
||||||
|
_ maxFrames: CMItemCount,
|
||||||
|
_ processingFormat: UnsafePointer<AudioStreamBasicDescription>
|
||||||
|
) {
|
||||||
|
guard let ctx = gAudioContext else { return }
|
||||||
var asbd = processingFormat.pointee
|
var asbd = processingFormat.pointee
|
||||||
print("[Recorder] tapPrepare: sampleRate=\(asbd.mSampleRate), channels=\(asbd.mChannelsPerFrame)")
|
var fmtDesc: CMAudioFormatDescription?
|
||||||
CMAudioFormatDescriptionCreate(allocator: kCFAllocatorDefault,
|
CMAudioFormatDescriptionCreate(
|
||||||
asbd: &asbd,
|
allocator: kCFAllocatorDefault,
|
||||||
layoutSize: 0, layout: nil,
|
asbd: &asbd,
|
||||||
magicCookieSize: 0, magicCookie: nil,
|
layoutSize: 0, layout: nil,
|
||||||
extensions: nil,
|
magicCookieSize: 0, magicCookie: nil,
|
||||||
formatDescriptionOut: &ctx.formatDescription)
|
extensions: nil,
|
||||||
|
formatDescriptionOut: &fmtDesc
|
||||||
|
)
|
||||||
|
ctx.formatDescription = fmtDesc
|
||||||
|
NSLog("[Recorder] tapPrepare: %.1fHz, %uch", asbd.mSampleRate, asbd.mChannelsPerFrame)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tapInit(_ tap: MTAudioProcessingTap,
|
private func tapUnprepareCallback(_ tap: MTAudioProcessingTap) {
|
||||||
_ clientInfo: UnsafeMutableRawPointer?,
|
|
||||||
_ tapStorageOut: UnsafeMutablePointer<UnsafeMutableRawPointer?>) {
|
|
||||||
// 初始化 tap
|
|
||||||
}
|
|
||||||
|
|
||||||
private func tapUnprepare(_ tap: MTAudioProcessingTap) {
|
|
||||||
// Nothing to clean up
|
// Nothing to clean up
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tapProcess(_ tap: MTAudioProcessingTap,
|
private func tapProcessCallback(
|
||||||
_ numberFrames: CMItemCount,
|
_ tap: MTAudioProcessingTap,
|
||||||
_ flags: MTAudioProcessingTapFlags,
|
_ numberFrames: CMItemCount,
|
||||||
_ bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
|
_ flags: MTAudioProcessingTapFlags,
|
||||||
_ numberFramesOut: UnsafeMutablePointer<CMItemCount>,
|
_ bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
|
||||||
_ flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>) {
|
_ numberFramesOut: UnsafeMutablePointer<CMItemCount>,
|
||||||
|
_ flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>
|
||||||
|
) {
|
||||||
numberFramesOut.pointee = 0
|
numberFramesOut.pointee = 0
|
||||||
flagsOut.pointee = 0
|
flagsOut.pointee = 0
|
||||||
|
|
||||||
guard numberFrames > 0 else { return }
|
guard numberFrames > 0 else { return }
|
||||||
|
|
||||||
// 先获取源音频数据
|
// 获取源音频数据 + 时间范围
|
||||||
var timeRange = CMTimeRange()
|
var timeRange = CMTimeRange()
|
||||||
var srcFlags: MTAudioProcessingTapFlags = 0
|
var srcFlags: MTAudioProcessingTapFlags = 0
|
||||||
var actualFrames: CMItemCount = 0
|
var actualFrames: CMItemCount = 0
|
||||||
@ -75,6 +89,8 @@ private func tapProcess(_ tap: MTAudioProcessingTap,
|
|||||||
let writerInput = ctx.writerInput,
|
let writerInput = ctx.writerInput,
|
||||||
writerInput.isReadyForMoreMediaData else { return }
|
writerInput.isReadyForMoreMediaData else { return }
|
||||||
|
|
||||||
|
ctx.processCallCount += 1
|
||||||
|
|
||||||
let bufferList = bufferListInOut.pointee
|
let bufferList = bufferListInOut.pointee
|
||||||
guard bufferList.mNumberBuffers > 0 else { return }
|
guard bufferList.mNumberBuffers > 0 else { return }
|
||||||
|
|
||||||
@ -103,7 +119,7 @@ private func tapProcess(_ tap: MTAudioProcessingTap,
|
|||||||
// 获取 format description
|
// 获取 format description
|
||||||
guard let fd = ctx.formatDescription else { return }
|
guard let fd = ctx.formatDescription else { return }
|
||||||
|
|
||||||
// 用累积帧数计算 PTS
|
// PTS 基于累积帧数
|
||||||
let sampleRate = Double(timeRange.duration.timescale) > 0
|
let sampleRate = Double(timeRange.duration.timescale) > 0
|
||||||
? Double(timeRange.duration.timescale) : 44100.0
|
? Double(timeRange.duration.timescale) : 44100.0
|
||||||
let ptsValue = Double(ctx.appendCount) / sampleRate
|
let ptsValue = Double(ctx.appendCount) / sampleRate
|
||||||
@ -125,28 +141,28 @@ private func tapProcess(_ tap: MTAudioProcessingTap,
|
|||||||
ctx.appendCount += actualFrames
|
ctx.appendCount += actualFrames
|
||||||
if !ctx.firstAppendLogged {
|
if !ctx.firstAppendLogged {
|
||||||
ctx.firstAppendLogged = true
|
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 回调
|
// MARK: - PlayerRecorder
|
||||||
private func tapInit(_ tap: MTAudioProcessingTap) {}
|
|
||||||
private func tapFinalize(_ tap: MTAudioProcessingTap) {}
|
|
||||||
|
|
||||||
/// 视频源录制器:从 AVPlayerItemVideoOutput 抓帧 + MTAudioProcessingTap 捕获音频,写入 mp4
|
/// 视频源录制器:AVPlayerItemVideoOutput 抓帧 + MTAudioProcessingTap 捕获音频
|
||||||
|
/// 边录边写文件,录制结束后仅改名和移动
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PlayerRecorder: NSObject {
|
final class PlayerRecorder: NSObject {
|
||||||
|
|
||||||
private var writer: AVAssetWriter?
|
private var writer: AVAssetWriter?
|
||||||
private var videoInput: AVAssetWriterInput?
|
private var videoInput: AVAssetWriterInput?
|
||||||
private var audioInput: AVAssetWriterInput?
|
private var audioInput: AVAssetWriterInput?
|
||||||
private var timer: Timer?
|
private var captureTimer: Timer?
|
||||||
|
private var durationTimer: Timer?
|
||||||
private var startDate: Date?
|
private var startDate: Date?
|
||||||
private var weakOutput: AVPlayerItemVideoOutput?
|
private weak var weakOutput: AVPlayerItemVideoOutput?
|
||||||
nonisolated(unsafe) private var isRunning = false
|
nonisolated(unsafe) private var isRunning = false
|
||||||
|
|
||||||
// 视频:缓存上一帧,确保静态场景也能持续写入
|
// 视频:缓存上一帧(处理静态画面)
|
||||||
private var lastPixelBuffer: CVPixelBuffer?
|
private var lastPixelBuffer: CVPixelBuffer?
|
||||||
private var captureFrameCount: Int = 0
|
private var captureFrameCount: Int = 0
|
||||||
|
|
||||||
@ -155,6 +171,7 @@ final class PlayerRecorder: NSObject {
|
|||||||
private var audioContext: AudioCaptureContext?
|
private var audioContext: AudioCaptureContext?
|
||||||
private weak var currentPlayerItem: AVPlayerItem?
|
private weak var currentPlayerItem: AVPlayerItem?
|
||||||
|
|
||||||
|
// 录制起始时间(用于计算视频相对时间戳)
|
||||||
nonisolated(unsafe) private var recordStartTime: CMTime = .zero
|
nonisolated(unsafe) private var recordStartTime: CMTime = .zero
|
||||||
|
|
||||||
@Published var isRecording = false
|
@Published var isRecording = false
|
||||||
@ -163,8 +180,10 @@ 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 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)
|
||||||
@ -173,6 +192,7 @@ final class PlayerRecorder: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 开始录制
|
// MARK: - 开始录制
|
||||||
|
|
||||||
func startRecording(from output: AVPlayerItemVideoOutput, playerItem: AVPlayerItem, startTime: CMTime) {
|
func startRecording(from output: AVPlayerItemVideoOutput, playerItem: AVPlayerItem, startTime: CMTime) {
|
||||||
guard !isRecording else { return }
|
guard !isRecording else { return }
|
||||||
|
|
||||||
@ -183,11 +203,11 @@ final class PlayerRecorder: NSObject {
|
|||||||
isRunning = false
|
isRunning = false
|
||||||
recordStartTime = startTime
|
recordStartTime = startTime
|
||||||
|
|
||||||
// 临时文件
|
// 临时文件(边录边写,结束仅改名移动)
|
||||||
let formatter = DateFormatter()
|
let tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4"
|
||||||
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
|
let url = saveDirectory.appendingPathComponent(tempFilename)
|
||||||
let filename = "Recording_\(formatter.string(from: Date())).mp4"
|
// 删除旧文件
|
||||||
let url = saveDirectory.appendingPathComponent(filename)
|
try? FileManager.default.removeItem(at: url)
|
||||||
tempURL = url
|
tempURL = url
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@ -199,7 +219,7 @@ final class PlayerRecorder: NSObject {
|
|||||||
|
|
||||||
guard let writer = writer else { return }
|
guard let writer = writer else { return }
|
||||||
|
|
||||||
// 视频输入
|
// 视频输入(H264)
|
||||||
let videoSettings: [String: Any] = [
|
let videoSettings: [String: Any] = [
|
||||||
AVVideoCodecKey: AVVideoCodecType.h264,
|
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||||
AVVideoWidthKey: 1920,
|
AVVideoWidthKey: 1920,
|
||||||
@ -212,7 +232,7 @@ final class PlayerRecorder: NSObject {
|
|||||||
videoInput = vInput
|
videoInput = vInput
|
||||||
}
|
}
|
||||||
|
|
||||||
// 音频输入
|
// 音频输入(AAC)
|
||||||
let audioSettings: [String: Any] = [
|
let audioSettings: [String: Any] = [
|
||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
AVSampleRateKey: 44100,
|
AVSampleRateKey: 44100,
|
||||||
@ -226,39 +246,44 @@ final class PlayerRecorder: NSObject {
|
|||||||
audioInput = aInput
|
audioInput = aInput
|
||||||
}
|
}
|
||||||
|
|
||||||
let success = writer.startWriting()
|
guard writer.startWriting() else {
|
||||||
if !success {
|
NSLog("[Recorder] startWriting failed: %@", writer.error?.localizedDescription ?? "unknown")
|
||||||
print("[Recorder] startWriting failed: \(writer.error?.localizedDescription ?? "unknown")")
|
|
||||||
onError?("Cannot start writer: \(writer.error?.localizedDescription ?? "unknown")")
|
onError?("Cannot start writer: \(writer.error?.localizedDescription ?? "unknown")")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// session 起点为 0,视频和音频都用相对时间戳
|
||||||
writer.startSession(atSourceTime: .zero)
|
writer.startSession(atSourceTime: .zero)
|
||||||
print("[Recorder] Writer started, session at .zero")
|
NSLog("[Recorder] Writer started, writing to: %@", url.lastPathComponent)
|
||||||
|
|
||||||
// 启动视频抓帧
|
// 启动视频抓帧(30fps)
|
||||||
isRecording = true
|
isRecording = true
|
||||||
isRunning = true
|
isRunning = true
|
||||||
startTimer()
|
|
||||||
startCaptureLoop()
|
startCaptureLoop()
|
||||||
|
startDurationTimer()
|
||||||
|
|
||||||
// 启动音频捕获(MTAudioProcessingTap)
|
// 启动音频捕获(MTAudioProcessingTap)
|
||||||
setupAudioTap(playerItem: playerItem, audioWriterInput: aInput)
|
setupAudioTap(playerItem: playerItem, audioWriterInput: aInput)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 停止录制
|
// MARK: - 停止录制
|
||||||
|
|
||||||
func stopRecording() {
|
func stopRecording() {
|
||||||
guard isRecording else { return }
|
guard isRecording else { return }
|
||||||
isRecording = false
|
isRecording = false
|
||||||
isRunning = false
|
isRunning = false
|
||||||
captureTimer?.invalidate()
|
captureTimer?.invalidate()
|
||||||
captureTimer = nil
|
captureTimer = nil
|
||||||
stopTimer()
|
stopDurationTimer()
|
||||||
|
|
||||||
// 停止音频 tap
|
// 停止音频 tap
|
||||||
audioContext?.isRunning = false
|
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?.audioMix = nil
|
||||||
currentPlayerItem = nil
|
currentPlayerItem = nil
|
||||||
|
|
||||||
@ -273,10 +298,23 @@ final class PlayerRecorder: NSObject {
|
|||||||
writer?.finishWriting { [weak self] in
|
writer?.finishWriting { [weak self] in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let self = self, let w = self.writer else { return }
|
guard let self = self, let w = self.writer else { return }
|
||||||
let count = self.captureFrameCount
|
let vFrames = self.captureFrameCount
|
||||||
print("[Recorder] finishWriting status: \(w.status.rawValue), error: \(w.error?.localizedDescription ?? "none"), video frames: \(count)")
|
NSLog("[Recorder] finishWriting: status=%d, error=%@, videoFrames=%d, audioFrames=%d",
|
||||||
if w.status == .completed, let url = self.tempURL {
|
w.status.rawValue,
|
||||||
self.showSaveDialog(tempURL: url)
|
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 {
|
} else {
|
||||||
self.onError?("Recording failed: \(w.error?.localizedDescription ?? "unknown (status=\(w.status.rawValue))")")
|
self.onError?("Recording failed: \(w.error?.localizedDescription ?? "unknown (status=\(w.status.rawValue))")")
|
||||||
if let url = self.tempURL {
|
if let url = self.tempURL {
|
||||||
@ -290,6 +328,7 @@ final class PlayerRecorder: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 音频 Tap 设置
|
// MARK: - 音频 Tap 设置
|
||||||
|
|
||||||
private func setupAudioTap(playerItem: AVPlayerItem, audioWriterInput: AVAssetWriterInput) {
|
private func setupAudioTap(playerItem: AVPlayerItem, audioWriterInput: AVAssetWriterInput) {
|
||||||
let asset = playerItem.asset
|
let asset = playerItem.asset
|
||||||
|
|
||||||
@ -297,15 +336,15 @@ final class PlayerRecorder: NSObject {
|
|||||||
do {
|
do {
|
||||||
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
|
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
|
||||||
guard let audioTrack = audioTracks.first else {
|
guard let audioTrack = audioTracks.first else {
|
||||||
print("[Recorder] ❌ No audio track for tap")
|
NSLog("[Recorder] ❌ No audio track for tap")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard self.isRunning else {
|
guard self.isRunning else {
|
||||||
print("[Recorder] ❌ isRunning=false, aborting audio tap setup")
|
NSLog("[Recorder] ❌ isRunning=false, aborting audio tap setup")
|
||||||
return
|
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()
|
let context = AudioCaptureContext()
|
||||||
@ -313,18 +352,18 @@ final class PlayerRecorder: NSObject {
|
|||||||
context.isRunning = true
|
context.isRunning = true
|
||||||
self.audioContext = context
|
self.audioContext = context
|
||||||
|
|
||||||
// ★ 设置全局引用,供 C 回调访问
|
// 全局引用,供 C 回调访问
|
||||||
gAudioContext = context
|
gAudioContext = context
|
||||||
|
|
||||||
// 创建 MTAudioProcessingTap
|
// 创建 MTAudioProcessingTap 回调结构体
|
||||||
var callbacks = MTAudioProcessingTapCallbacks(
|
var callbacks = MTAudioProcessingTapCallbacks(
|
||||||
version: kMTAudioProcessingTapCallbacksVersion_0,
|
version: kMTAudioProcessingTapCallbacksVersion_0,
|
||||||
clientInfo: nil,
|
clientInfo: nil,
|
||||||
init: tapInit,
|
init: tapInitCallback,
|
||||||
finalize: tapFinalize,
|
finalize: tapFinalizeCallback,
|
||||||
prepare: tapPrepare,
|
prepare: tapPrepareCallback,
|
||||||
unprepare: tapUnprepare,
|
unprepare: tapUnprepareCallback,
|
||||||
process: tapProcess
|
process: tapProcessCallback
|
||||||
)
|
)
|
||||||
|
|
||||||
var tap: MTAudioProcessingTap?
|
var tap: MTAudioProcessingTap?
|
||||||
@ -336,7 +375,7 @@ final class PlayerRecorder: NSObject {
|
|||||||
)
|
)
|
||||||
|
|
||||||
guard status == noErr, let audioTap = tap else {
|
guard status == noErr, let audioTap = tap else {
|
||||||
print("[Recorder] ❌ MTAudioProcessingTapCreate failed: \(status)")
|
NSLog("[Recorder] ❌ MTAudioProcessingTapCreate failed: %d", status)
|
||||||
gAudioContext = nil
|
gAudioContext = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -350,15 +389,14 @@ final class PlayerRecorder: NSObject {
|
|||||||
audioMix.inputParameters = [params]
|
audioMix.inputParameters = [params]
|
||||||
|
|
||||||
playerItem.audioMix = audioMix
|
playerItem.audioMix = audioMix
|
||||||
print("[Recorder] ✓ Audio tap installed on player item")
|
NSLog("[Recorder] ✓ Audio tap installed on playerItem")
|
||||||
} catch {
|
} catch {
|
||||||
print("[Recorder] ❌ Audio tap setup failed: \(error)")
|
NSLog("[Recorder] Audio tap setup error: %@", error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 视频抓帧 (30fps Timer)
|
// MARK: - 视频抓帧 (30fps Timer)
|
||||||
private var captureTimer: Timer?
|
|
||||||
|
|
||||||
private func startCaptureLoop() {
|
private func startCaptureLoop() {
|
||||||
captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
|
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 hostTime = CACurrentMediaTime()
|
||||||
let itemTime = output.itemTime(forHostTime: hostTime)
|
let itemTime = output.itemTime(forHostTime: hostTime)
|
||||||
|
|
||||||
// 尝试获取新帧,否则复用上一帧
|
// 尝试获取新帧
|
||||||
if let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) {
|
if let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) {
|
||||||
lastPixelBuffer = pb
|
lastPixelBuffer = pb
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用缓存帧(处理静态画面)
|
||||||
guard let pb = lastPixelBuffer else { return }
|
guard let pb = lastPixelBuffer else { return }
|
||||||
|
|
||||||
// 相对时间戳
|
// 计算相对时间戳
|
||||||
let relativeTime = CMTimeSubtract(itemTime, recordStartTime)
|
let relativeTime = CMTimeSubtract(itemTime, recordStartTime)
|
||||||
guard relativeTime.seconds >= 0 else { return }
|
guard relativeTime.seconds >= 0 else { return }
|
||||||
|
|
||||||
let sampleBuffer = Self.createSampleBuffer(from: pb, time: relativeTime)
|
// 创建 sample buffer 并写入
|
||||||
if let sb = sampleBuffer {
|
if let sb = Self.createSampleBuffer(from: pb, time: relativeTime) {
|
||||||
if input.append(sb) {
|
if input.append(sb) {
|
||||||
captureFrameCount += 1
|
captureFrameCount += 1
|
||||||
}
|
}
|
||||||
@ -401,20 +440,31 @@ final class PlayerRecorder: NSObject {
|
|||||||
info.decodeTimeStamp = .invalid
|
info.decodeTimeStamp = .invalid
|
||||||
|
|
||||||
var formatDescription: CMFormatDescription?
|
var formatDescription: CMFormatDescription?
|
||||||
CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &formatDescription)
|
CMVideoFormatDescriptionCreateForImageBuffer(
|
||||||
|
allocator: kCFAllocatorDefault,
|
||||||
|
imageBuffer: pixelBuffer,
|
||||||
|
formatDescriptionOut: &formatDescription
|
||||||
|
)
|
||||||
|
|
||||||
guard let fd = formatDescription else { return nil }
|
guard let fd = formatDescription else { return nil }
|
||||||
|
|
||||||
var sampleBuffer: CMSampleBuffer?
|
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
|
return sampleBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Timer (显示录制时长)
|
// MARK: - 计时器
|
||||||
private func startTimer() {
|
|
||||||
|
private func startDurationTimer() {
|
||||||
startDate = Date()
|
startDate = Date()
|
||||||
durationText = "00:00"
|
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
|
Task { @MainActor in
|
||||||
guard let self = self, let start = self.startDate else { return }
|
guard let self = self, let start = self.startDate else { return }
|
||||||
let elapsed = Int(Date().timeIntervalSince(start))
|
let elapsed = Int(Date().timeIntervalSince(start))
|
||||||
@ -430,37 +480,19 @@ final class PlayerRecorder: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopTimer() {
|
private func stopDurationTimer() {
|
||||||
timer?.invalidate()
|
durationTimer?.invalidate()
|
||||||
timer = nil
|
durationTimer = nil
|
||||||
startDate = nil
|
startDate = nil
|
||||||
durationText = "00:00"
|
durationText = "00:00"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 保存对话框
|
// 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
|
private func generateFinalFilename() -> String {
|
||||||
guard let self = self else { return }
|
let formatter = DateFormatter()
|
||||||
if response == .OK, let destURL = panel.url {
|
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
|
||||||
do {
|
return "Recording_\(formatter.string(from: Date())).mp4"
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user