Manual HLS segment download fails for CBS streams (DRM/CDN protection returns 438-byte placeholder responses). New approach captures video frames directly from AVPlayer via AVPlayerItemVideoOutput + AVAssetWriter. - AVPlayerItemVideoOutput captures pixel buffers during playback - AVAssetWriter encodes to H.264/AAC MP4 - CADisplayLink drives capture at 30fps - Works with any content AVPlayer can play (HLS, local files, SMB)
209 lines
8.2 KiB
Swift
209 lines
8.2 KiB
Swift
#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
|