- MTAudioProcessingTap captures audio samples from AVPlayer's audio track - Audio samples written to AVAssetWriter alongside video frames - First frame PTS used as session start (fixes duration stretch) - Cleaned up unused properties
306 lines
13 KiB
Swift
306 lines
13 KiB
Swift
#if os(iOS)
|
||
import Foundation
|
||
import AVFoundation
|
||
import UIKit
|
||
import MediaToolbox
|
||
|
||
/// iOS 录制器 — 从 AVPlayer 播放输出捕获视频帧 + 音频,写入 MP4
|
||
@MainActor
|
||
final class HLSRecorder: ObservableObject {
|
||
@Published var isRecording = false
|
||
@Published var durationText = "00:00"
|
||
|
||
var onRecordingSaved: ((URL) -> Void)?
|
||
var onError: ((String) -> Void)?
|
||
|
||
private var assetWriter: AVAssetWriter?
|
||
private var videoInput: AVAssetWriterInput?
|
||
private var audioInput: AVAssetWriterInput?
|
||
private var videoOutput: AVPlayerItemVideoOutput?
|
||
private var displayLink: CADisplayLink?
|
||
private var startTime: Date?
|
||
private var timer: Timer?
|
||
private var outputURL: URL?
|
||
private var firstPTS: CMTime?
|
||
private var audioTapID: MTAudioProcessingTapID?
|
||
|
||
func startRecording(url: URL, player: AVPlayer) {
|
||
guard !isRecording, let item = player.currentItem else { return }
|
||
isRecording = true
|
||
startTime = Date()
|
||
firstPTS = nil
|
||
|
||
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||
Task { @MainActor in
|
||
guard let self, let start = self.startTime else { return }
|
||
let elapsed = Int(Date().timeIntervalSince(start))
|
||
self.durationText = String(format: "%02d:%02d:%02d", elapsed / 3600, (elapsed % 3600) / 60, elapsed % 60)
|
||
}
|
||
}
|
||
|
||
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||
outputURL = docs.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).mp4")
|
||
|
||
Task { await startCapture(item: item, player: player) }
|
||
}
|
||
|
||
func stopRecording() {
|
||
guard isRecording else { return }
|
||
isRecording = false
|
||
timer?.invalidate()
|
||
displayLink?.invalidate()
|
||
videoOutput = nil
|
||
|
||
videoInput?.markAsFinished()
|
||
audioInput?.markAsFinished()
|
||
|
||
assetWriter?.finishWriting { [weak self] in
|
||
DispatchQueue.main.async {
|
||
guard let self, let url = self.outputURL else { return }
|
||
let status = self.assetWriter?.status ?? .unknown
|
||
NSLog("[MiniPlayer] AVAssetWriter finished: %d", status.rawValue)
|
||
|
||
if status == .completed {
|
||
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
|
||
let size = (attrs?[.size] as? Int64) ?? 0
|
||
NSLog("[MiniPlayer] Recorded: %lld bytes", size)
|
||
self.onRecordingSaved?(url)
|
||
} else {
|
||
let err = self.assetWriter?.error?.localizedDescription ?? "unknown"
|
||
self.onError?("Export: \(err)")
|
||
try? FileManager.default.removeItem(at: url)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Capture setup
|
||
|
||
private func startCapture(item: AVPlayerItem, player: AVPlayer) async {
|
||
guard let outputURL else { return }
|
||
|
||
do {
|
||
let writer = try AVAssetWriter(url: outputURL, fileType: .mp4)
|
||
|
||
// Natural size from asset
|
||
var size = CGSize(width: 1280, height: 720)
|
||
if let tracks = try? await item.asset.load(.tracks),
|
||
let videoTrack = tracks.first(where: { $0.mediaType == .video }) {
|
||
let ns = try? await videoTrack.load(.naturalSize)
|
||
if let ns, ns.width > 0 { size = ns }
|
||
}
|
||
|
||
// Video input
|
||
let videoSettings: [String: Any] = [
|
||
AVVideoCodecKey: AVVideoCodecType.h264,
|
||
AVVideoWidthKey: size.width,
|
||
AVVideoHeightKey: size.height,
|
||
AVVideoCompressionPropertiesKey: [
|
||
AVVideoAverageBitRateKey: 2_000_000,
|
||
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel
|
||
]
|
||
]
|
||
let vidInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
|
||
vidInput.expectsMediaDataInRealTime = true
|
||
guard writer.canAdd(vidInput) else {
|
||
throw NSError(domain: "Rec", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot add video input"])
|
||
}
|
||
writer.add(vidInput)
|
||
self.videoInput = vidInput
|
||
|
||
// Audio input
|
||
let audioSettings: [String: Any] = [
|
||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||
AVSampleRateKey: 44100,
|
||
AVNumberOfChannelsKey: 2,
|
||
AVEncoderBitRateKey: 128_000
|
||
]
|
||
let audInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
|
||
audInput.expectsMediaDataInRealTime = true
|
||
guard writer.canAdd(audInput) else {
|
||
throw NSError(domain: "Rec", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add audio input"])
|
||
}
|
||
writer.add(audInput)
|
||
self.audioInput = audInput
|
||
|
||
// MTAudioProcessingTap on the player item's audio track
|
||
installAudioTap(on: item)
|
||
|
||
// Video output from player
|
||
let pixAttrs: [String: Any] = [
|
||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||
kCVPixelBufferWidthKey as String: size.width,
|
||
kCVPixelBufferHeightKey as String: size.height
|
||
]
|
||
let vo = AVPlayerItemVideoOutput(pixelBufferAttributes: pixAttrs)
|
||
vo.suppressesPlayerRendering = false
|
||
item.add(vo)
|
||
self.videoOutput = vo
|
||
|
||
writer.startWriting()
|
||
// session start will be set on first frame
|
||
self.assetWriter = writer
|
||
|
||
displayLink = CADisplayLink(target: self, selector: #selector(captureFrame))
|
||
displayLink?.preferredFrameRateRange = CAFrameRateRange(minimum: 15, maximum: 30, preferred: 30)
|
||
displayLink?.add(to: .main, forMode: .common)
|
||
|
||
NSLog("[MiniPlayer] Capture started: %@", outputURL.path)
|
||
|
||
} catch {
|
||
await MainActor.run {
|
||
self.isRecording = false
|
||
self.timer?.invalidate()
|
||
self.onError?(error.localizedDescription)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Audio tap
|
||
|
||
private func installAudioTap(on item: AVPlayerItem) {
|
||
guard let audioTrack = item.asset.tracks(withMediaType: .audio).first else {
|
||
NSLog("[MiniPlayer] No audio track found")
|
||
return
|
||
}
|
||
|
||
var callbacks = MTAudioProcessingTapCallbacks(
|
||
version: kMTAudioProcessingTapCallbacksVersion_0,
|
||
clientInfo: Unmanaged.passUnretained(self).toOpaque(),
|
||
init: { tap, clientInfo, tapStorageOut in
|
||
tapStorageOut?.pointee = clientInfo
|
||
},
|
||
finalize: { _ in },
|
||
prepare: { _, _, _ in },
|
||
unprepare: { _, _, _ in },
|
||
process: { tap, numberFrames, flags, bufferListInOut, numberFramesOut, flagsOut in
|
||
guard let clientInfo = MTAudioProcessingTapGetStorage(tap) else {
|
||
numberFramesOut.pointee = 0
|
||
return
|
||
}
|
||
let recorder = Unmanaged<HLSRecorder>.fromOpaque(clientInfo).takeUnretainedValue()
|
||
recorder.processAudio(tap: tap, numberFrames: numberFrames, flags: flags,
|
||
bufferListInOut: bufferListInOut,
|
||
numberFramesOut: numberFramesOut, flagsOut: flagsOut)
|
||
}
|
||
)
|
||
|
||
var tap: Unmanaged<MTAudioProcessingTap>?
|
||
let status = MTAudioProcessingTapCreate(kCFAllocatorDefault, &callbacks,
|
||
kMTAudioProcessingTapCreationFlag_PreEffects, &tap)
|
||
guard status == noErr, let audioTap = tap?.takeRetainedValue() else {
|
||
NSLog("[MiniPlayer] MTAudioProcessingTapCreate failed: %d", status)
|
||
return
|
||
}
|
||
|
||
let inputParams = AVMutableAudioMixInputParameters(track: audioTrack)
|
||
inputParams.audioTapProcessor = audioTap
|
||
|
||
let audioMix = AVMutableAudioMix()
|
||
audioMix.inputParameters = [inputParams]
|
||
item.audioMix = audioMix
|
||
|
||
NSLog("[MiniPlayer] Audio tap installed")
|
||
}
|
||
|
||
private func processAudio(tap: MTAudioProcessingTap, numberFrames: CMItemCount, flags: MTAudioProcessingTapFlags,
|
||
bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
|
||
numberFramesOut: UnsafeMutablePointer<CMItemCount>,
|
||
flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>) {
|
||
// Get source audio
|
||
var sourceFlags = flags
|
||
var timeRange = CMTimeRange()
|
||
let status = MTAudioProcessingTapGetSourceAudio(tap, numberFrames, bufferListInOut,
|
||
flagsOut, &timeRange, numberFramesOut)
|
||
guard status == noErr, let input = audioInput, input.isReadyForMoreMediaData else {
|
||
flagsOut.pointee = flags
|
||
return
|
||
}
|
||
|
||
// Create CMSampleBuffer from the audio buffer list
|
||
let pts = timeRange.start
|
||
var asbd = AudioStreamBasicDescription()
|
||
MTAudioProcessingTapGetStreamDescription(tap, &asbd)
|
||
|
||
var formatDesc: CMAudioFormatDescription?
|
||
CMAudioFormatDescriptionCreate(allocator: kCFAllocatorDefault, asbd: &asbd,
|
||
layoutSize: 0, layout: nil, 0, nil, nil,
|
||
formatDescriptionOut: &formatDesc)
|
||
guard let fd = formatDesc else { return }
|
||
|
||
var sampleBuffer: CMSampleBuffer?
|
||
let numSamples = numberFramesOut.pointee
|
||
|
||
var timing = CMSampleTimingInfo(duration: CMTime(value: CMTimeValue(numSamples), timescale: CMTimeScale(asbd.mSampleRate)),
|
||
presentationTimeStamp: pts, decodeTimeStamp: .invalid)
|
||
|
||
CMSampleBufferCreate(allocator: kCFAllocatorDefault, dataBuffer: nil, dataReady: false,
|
||
makeDataReadyCallback: nil, refcon: nil, formatDescription: fd,
|
||
sampleCount: numSamples, sampleTimingEntryCount: 1,
|
||
sampleTimingArray: &timing, sampleSizeEntryCount: 0,
|
||
sampleSizeArray: nil, sampleBufferOut: &sampleBuffer)
|
||
|
||
guard var sb = sampleBuffer else { return }
|
||
|
||
// Copy audio data into the sample buffer
|
||
let bufList = bufferListInOut.pointee
|
||
var blockBuffer: CMBlockBuffer?
|
||
CMBlockBufferCreateWithMemoryBlock(
|
||
allocator: kCFAllocatorDefault,
|
||
memoryBlock: nil,
|
||
blockLength: Int(bufList.mBuffers.mDataByteSize),
|
||
blockAllocator: kCFAllocatorDefault,
|
||
customBlockSource: nil,
|
||
offsetToData: 0,
|
||
dataLength: Int(bufList.mBuffers.mDataByteSize),
|
||
flags: 0,
|
||
blockBufferOut: &blockBuffer
|
||
)
|
||
if let bb = blockBuffer {
|
||
CMBlockBufferReplaceDataBytes(with: bufList.mBuffers.mData!, blockBuffer: bb,
|
||
offsetIntoDestination: 0, dataLength: Int(bufList.mBuffers.mDataByteSize))
|
||
CMSampleBufferSetDataBuffer(&sb, newValue: bb)
|
||
input.append(sb)
|
||
}
|
||
}
|
||
|
||
// MARK: - Video frame capture
|
||
|
||
@objc private func captureFrame() {
|
||
guard let writer = assetWriter, let vo = videoOutput, let vidInput = videoInput else { return }
|
||
|
||
let itemTime = vo.itemTime(forHostTime: CACurrentMediaTime())
|
||
guard vo.hasNewPixelBuffer(forItemTime: itemTime),
|
||
let buf = vo.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil)
|
||
else { return }
|
||
|
||
// Start session on first frame
|
||
if writer.status == .unknown {
|
||
writer.startSession(atSourceTime: itemTime)
|
||
firstPTS = itemTime
|
||
NSLog("[MiniPlayer] Session started at PTS: %.3f", itemTime.seconds)
|
||
}
|
||
|
||
guard vidInput.isReadyForMoreMediaData else { return }
|
||
|
||
var timing = CMSampleTimingInfo(
|
||
duration: CMTime(value: 1, timescale: 30),
|
||
presentationTimeStamp: itemTime,
|
||
decodeTimeStamp: .invalid
|
||
)
|
||
var formatDesc: CMFormatDescription?
|
||
CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault,
|
||
imageBuffer: buf, formatDescriptionOut: &formatDesc)
|
||
guard let fd = formatDesc else { return }
|
||
|
||
var sampleBuffer: CMSampleBuffer?
|
||
CMSampleBufferCreateReadyWithImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: buf,
|
||
formatDescription: fd, sampleTiming: &timing,
|
||
sampleBufferOut: &sampleBuffer)
|
||
if let sb = sampleBuffer {
|
||
vidInput.append(sb)
|
||
}
|
||
}
|
||
}
|
||
#endif
|