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)
This commit is contained in:
yumoqing 2026-07-17 21:40:41 +08:00
parent d0cecebe69
commit bfe81cbc82
2 changed files with 173 additions and 315 deletions

View File

@ -2,349 +2,207 @@
import Foundation
import AVFoundation
import UIKit
import Combine
import Photos
/// iOS HLS /
/// 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 recordTask: Task<Void, Never>?
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 stopRequested = false
func startRecording(url: URL) {
guard !isRecording else { return }
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
stopRequested = false
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)
self.durationText = String(format: "%02d:%02d:%02d", elapsed / 3600, (elapsed % 3600) / 60, elapsed % 60)
}
}
recordTask = Task {
do {
let outputURL = try await recordStream(url: url)
NSLog("[MiniPlayer] recordStream completed: %@, exists: %d", outputURL.path, FileManager.default.fileExists(atPath: outputURL.path))
let attrs = try? FileManager.default.attributesOfItem(atPath: outputURL.path)
NSLog("[MiniPlayer] Output file size: %lld", (attrs?[.size] as? Int64) ?? 0)
await MainActor.run {
self.isRecording = false
self.timer?.invalidate()
NSLog("[MiniPlayer] Calling onRecordingSaved...")
self.onRecordingSaved?(outputURL)
}
} catch is CancellationError {
NSLog("[MiniPlayer] Recording task cancelled")
// User stopped
await MainActor.run {
self.isRecording = false
self.timer?.invalidate()
}
} catch {
NSLog("[MiniPlayer] Recording error: %@", error.localizedDescription)
await MainActor.run {
self.isRecording = false
self.timer?.invalidate()
self.onError?("Recording error: \(error.localizedDescription)")
}
}
// 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() {
NSLog("[MiniPlayer] stopRecording called, stopRequested → true")
// cancel stopRequested 退
// MP4
stopRequested = true
}
// MARK: -
private func recordStream(url: URL) async throws -> URL {
//
if url.isFileURL {
NSLog("[MiniPlayer] recordStream: local file, copying directly")
return try await copyLocalFile(url: url)
}
// HLSm3u8 URL m3u8
let ext = url.pathExtension.lowercased()
let isHLS = ext == "m3u8"
|| url.absoluteString.contains(".m3u8")
|| ext == "m3u"
if isHLS {
return try await recordHLS(url: url)
} else {
// HEAD Content-Type
var req = URLRequest(url: url)
req.httpMethod = "HEAD"
if let (_, resp) = try? await URLSession.shared.data(for: req),
let httpResp = resp as? HTTPURLResponse,
let ct = httpResp.allHeaderFields["Content-Type"] as? String,
ct.contains("mpegurl") || ct.contains("m3u8") || ct.contains("apple.mpegurl") {
return try await recordHLS(url: url)
}
return try await downloadFile(url: url)
}
}
// MARK: - HLS m3u8 +
private func recordHLS(url: URL) async throws -> URL {
let ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"
// 1. master playlist
var req = URLRequest(url: url)
req.setValue(ua, forHTTPHeaderField: "User-Agent")
let (data, _) = try await URLSession.shared.data(for: req)
guard let playlist = String(data: data, encoding: .utf8) else {
throw RecorderError.invalidPlaylist
}
// 2. master playlist variant
let mediaPlaylistURL: URL
let lines = playlist.components(separatedBy: .newlines)
var isMaster = false
var variantURL: URL?
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("#EXT-X-STREAM-INF") { isMaster = true }
if isMaster && !trimmed.hasPrefix("#") && !trimmed.isEmpty {
variantURL = resolveURL(trimmed, baseURL: url)
break
}
}
if let vURL = variantURL {
mediaPlaylistURL = vURL
} else {
mediaPlaylistURL = url
}
// 3. m3u8
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("MiniPlayer_rec_\(UUID().uuidString)")
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
var downloadedSegments: [URL] = []
var seenSegmentURLs: Set<String> = []
var segmentIndex = 0
var consecutiveErrors = 0
let maxConsecutiveErrors = 10
while !stopRequested {
// m3u8
var plReq = URLRequest(url: mediaPlaylistURL)
plReq.setValue(ua, forHTTPHeaderField: "User-Agent")
guard let (plData, _) = try? await URLSession.shared.data(for: plReq),
let plText = String(data: plData, encoding: .utf8) else {
consecutiveErrors += 1
if consecutiveErrors >= maxConsecutiveErrors {
break
}
try await Task.sleep(nanoseconds: 2_000_000_000)
continue
}
consecutiveErrors = 0
//
let plLines = plText.components(separatedBy: .newlines)
let baseURL = mediaPlaylistURL.deletingLastPathComponent()
var newSegments: [URL] = []
var isLive = true
for line in plLines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed == "#EXT-X-ENDLIST" {
isLive = false
}
if !trimmed.hasPrefix("#") && !trimmed.isEmpty {
let segURL = resolveURL(trimmed, baseURL: baseURL)
if !seenSegmentURLs.contains(segURL.absoluteString) {
seenSegmentURLs.insert(segURL.absoluteString)
newSegments.append(segURL)
}
}
}
//
for segURL in newSegments {
guard !stopRequested else { break }
do {
var req = URLRequest(url: segURL)
req.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15", forHTTPHeaderField: "User-Agent")
let (segData, resp) = try await URLSession.shared.data(for: req)
let httpResp = resp as? HTTPURLResponse
NSLog("[MiniPlayer] Segment %d: %d bytes, HTTP %d", segmentIndex, segData.count, httpResp?.statusCode ?? 0)
let localFile = tempDir.appendingPathComponent("seg_\(String(format: "%06d", segmentIndex)).ts")
try segData.write(to: localFile)
downloadedSegments.append(localFile)
segmentIndex += 1
} catch {
NSLog("[MiniPlayer] Segment download failed: %@", error.localizedDescription)
continue
}
}
// VOD stop
if !isLive && newSegments.isEmpty {
break
}
if stopRequested && !downloadedSegments.isEmpty {
break
}
// 2 HLS segment duration 2-10s
if !stopRequested {
try await Task.sleep(nanoseconds: 2_000_000_000)
// sleep stopRequested true
if stopRequested { break }
} else {
break
}
}
guard !downloadedSegments.isEmpty else {
NSLog("[MiniPlayer] Recording stopped with 0 segments")
try? FileManager.default.removeItem(at: tempDir)
throw RecorderError.noSegments
}
NSLog("[MiniPlayer] Recording loop exited: %d segments downloaded", downloadedSegments.count)
// 4. TS Data FileHandle(forWritingTo:)
let mergedTS = tempDir.appendingPathComponent("merged.ts")
var mergedData = Data()
for segFile in downloadedSegments {
mergedData.append(try Data(contentsOf: segFile))
}
try mergedData.write(to: mergedTS)
NSLog("[MiniPlayer] Merged %d segments → %@ (%d bytes)", downloadedSegments.count, mergedTS.path, mergedData.count)
// 5. TS MP4
let outputURL = try await remuxToMP4(tsURL: mergedTS)
NSLog("[MiniPlayer] Remux complete: %@", outputURL.path)
// 6.
try? FileManager.default.removeItem(at: tempDir)
return outputURL
}
// MARK: - /
guard isRecording else { return }
isRecording = false
timer?.invalidate()
displayLink?.invalidate()
private func copyLocalFile(url: URL) async throws -> URL {
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
let outputURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).\(ext)")
if FileManager.default.fileExists(atPath: outputURL.path) {
try FileManager.default.removeItem(at: outputURL)
// 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)
}
}
}
try FileManager.default.copyItem(at: url, to: outputURL)
NSLog("[MiniPlayer] copyLocalFile: %@ → %@ (%lld bytes)",
url.lastPathComponent, outputURL.lastPathComponent,
(try? FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] as? Int64) ?? 0)
return outputURL
}
private func downloadFile(url: URL) async throws -> URL {
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
let outputURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).\(ext)")
// 使 delegate
let (tempURL, _) = try await URLSession.shared.download(from: url)
// Documents
if FileManager.default.fileExists(atPath: outputURL.path) {
try FileManager.default.removeItem(at: outputURL)
}
try FileManager.default.moveItem(at: tempURL, to: outputURL)
return outputURL
}
// MARK: - Remux TS MP4
private func remuxToMP4(tsURL: URL) async throws -> URL {
let outputMP4 = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).mp4")
let asset = AVURLAsset(url: tsURL)
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else {
NSLog("[MiniPlayer] AVAssetExportSession creation failed, fallback to TS")
let tsOutput = outputMP4.deletingPathExtension().appendingPathExtension("ts")
try FileManager.default.moveItem(at: tsURL, to: tsOutput)
return tsOutput
}
exportSession.outputURL = outputMP4
exportSession.outputFileType = .mp4
NSLog("[MiniPlayer] Starting remux export...")
// TaskGroup export + 120s timer
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
await exportSession.export()
// 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"])
}
group.addTask {
try await Task.sleep(nanoseconds: 120_000_000_000)
exportSession.cancelExport()
throw RecorderError.exportFailed("Remux timed out after 120s")
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"])
}
//
try await group.next()
group.cancelAll()
}
if exportSession.status != .completed {
let errMsg = exportSession.error?.localizedDescription ?? "unknown"
NSLog("[MiniPlayer] Export failed: status=%d, error=%@", exportSession.status.rawValue, errMsg)
let tsOutput = outputMP4.deletingPathExtension().appendingPathExtension("ts")
try FileManager.default.moveItem(at: tsURL, to: tsOutput)
return tsOutput
}
NSLog("[MiniPlayer] Export completed: %@", outputMP4.lastPathComponent)
return outputMP4
}
// MARK: - Helpers
private func resolveURL(_ urlString: String, baseURL: URL) -> URL {
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
return URL(string: urlString)!
}
return baseURL.appendingPathComponent(urlString)
}
enum RecorderError: LocalizedError {
case invalidPlaylist
case noSegments
case exportFailed(String)
var errorDescription: String? {
switch self {
case .invalidPlaylist: return "Cannot parse HLS playlist"
case .noSegments: return "No segments found in playlist"
case .exportFailed(let msg): return "Export failed: \(msg)"
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

View File

@ -254,7 +254,7 @@ final class PlayerBridge: ObservableObject {
return
}
let item = queue[currentIndex]
hlsRecorder.startRecording(url: item.url)
hlsRecorder.startRecording(url: item.url, player: player)
}
#endif
}