#if os(iOS) import Foundation import AVFoundation import UIKit import Photos /// iOS 录制器 — 从 AVPlayer 播放输出捕获视频帧和音频,写入 MP4 /// 替代手动下载 HLS 片段的方案(对 CBS 等有 DRM/CDN 保护的流无效) @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 lastVideoSampleTime: CMTime? private var audioEngine: AVAudioEngine? private var audioFile: AVAudioFile? private var audioTapInstalled = false func startRecording(url: URL, player: AVPlayer) { guard !isRecording, let item = player.currentItem else { return } isRecording = true startTime = Date() lastVideoSampleTime = nil // Timer for duration display 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) } } // Output file 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() // Finalize the writer 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, file: %@", status.rawValue, url.path) if status == .completed { let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) let size = (attrs?[.size] as? Int64) ?? 0 NSLog("[MiniPlayer] Recorded file size: %lld bytes", size) self.onRecordingSaved?(url) } else { let err = self.assetWriter?.error?.localizedDescription ?? "unknown" self.onError?("Export failed: \(err)") try? FileManager.default.removeItem(at: url) } } } } // MARK: - Capture private func startCapture(item: AVPlayerItem, player: AVPlayer) async { guard let outputURL else { return } do { let writer = try AVAssetWriter(url: outputURL, fileType: .mp4) // Video input: use source format from the asset when possible let videoSettings: [String: Any] let naturalSize = try? await item.asset.load(.tracks).first(where: { $0.mediaType == .video })?.naturalSize let size = naturalSize ?? CGSize(width: 1280, height: 720) videoSettings = [ 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 vidInput.transform = CGAffineTransform(translationX: 0, y: 0) guard writer.canAdd(vidInput) else { throw NSError(domain: "Recorder", 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: "Recorder", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add audio input"]) } writer.add(audInput) self.audioInput = audInput // Start writing writer.startWriting() writer.startSession(atSourceTime: .zero) self.assetWriter = writer // Video output from player let vo = AVPlayerItemVideoOutput(pixelBufferAttributes: [ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, kCVPixelBufferWidthKey as String: size.width, kCVPixelBufferHeightKey as String: size.height ]) vo.suppressesPlayerRendering = false item.add(vo) self.videoOutput = vo // Capture loop via CADisplayLink displayLink = CADisplayLink(target: self, selector: #selector(captureFrame)) displayLink?.preferredFrameRateRange = CAFrameRateRange(minimum: 24, maximum: 30, preferred: 30) displayLink?.add(to: .main, forMode: .common) NSLog("[MiniPlayer] Capture started, output: %@", outputURL.path) } catch { await MainActor.run { self.isRecording = false self.timer?.invalidate() self.onError?(error.localizedDescription) } } } @objc private func captureFrame() { guard let writer = assetWriter, writer.status == .writing else { return } // Video frame capture if let vo = videoOutput, videoInput?.isReadyForMoreMediaData == true { let itemTime = vo.itemTime(forHostTime: CACurrentMediaTime()) if vo.hasNewPixelBuffer(forItemTime: itemTime), let buf = vo.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) { if lastVideoSampleTime == nil { lastVideoSampleTime = itemTime } let sampleBuf = createSampleBuffer(from: buf, pts: itemTime) if let sb = sampleBuf { videoInput?.append(sb) } } } } /// Create a CMSampleBuffer from a CVPixelBuffer (needed because AVAssetWriterInput /// expects CMSampleBuffer but AVPlayerItemVideoOutput gives CVPixelBuffer directly) private func createSampleBuffer(from pixelBuffer: CVPixelBuffer, pts: CMTime) -> CMSampleBuffer? { var sampleBuffer: CMSampleBuffer? var timingInfo = CMSampleTimingInfo( duration: CMTime(value: 1, timescale: 30), presentationTimeStamp: pts, decodeTimeStamp: .invalid ) var formatDescription: CMFormatDescription? CMVideoFormatDescriptionCreateForImageBuffer( allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &formatDescription ) guard let fd = formatDescription else { return nil } CMSampleBufferCreateReadyWithImageBuffer( allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescription: fd, sampleTiming: &timingInfo, sampleBufferOut: &sampleBuffer ) return sampleBuffer } } #endif