From bfe81cbc82adf23f3f7b0f963fe5a98c5276ea73 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Fri, 17 Jul 2026 21:40:41 +0800 Subject: [PATCH] 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) --- Sources/HLSRecorder.swift | 486 +++++++++++++------------------------ Sources/PlayerBridge.swift | 2 +- 2 files changed, 173 insertions(+), 315 deletions(-) diff --git a/Sources/HLSRecorder.swift b/Sources/HLSRecorder.swift index 9749a06..d78fc55 100644 --- a/Sources/HLSRecorder.swift +++ b/Sources/HLSRecorder.swift @@ -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? + + 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) - } - - // 判断是否为 HLS(m3u8 扩展名或 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 = [] - 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 diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 7ed14a4..945c719 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -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 }