MiniPlayer/Sources/HLSRecorder.swift
yumoqing bfe81cbc82 feat(iOS): replace HLS segment download with AVPlayer screen capture
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)
2026-07-17 21:40:41 +08:00

209 lines
8.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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