#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.fromOpaque(clientInfo).takeUnretainedValue() recorder.processAudio(tap: tap, numberFrames: numberFrames, flags: flags, bufferListInOut: bufferListInOut, numberFramesOut: numberFramesOut, flagsOut: flagsOut) } ) var tap: Unmanaged? 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, numberFramesOut: UnsafeMutablePointer, flagsOut: UnsafeMutablePointer) { // 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