From d74543e3ed75fa2bbb3cba1b0dd2edd9d08fd73c Mon Sep 17 00:00:00 2001 From: yumoqing Date: Fri, 17 Jul 2026 21:55:49 +0800 Subject: [PATCH] fix(iOS): simplify recorder to local file only, add app icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reverted broken AVPlayer capture code back to simple local file copy + URLSession download - Stream recording shows clear error message recommending local files - Added Assets.car compilation and CFBundleIcons to Info.plist for app icon - Stopped wasting time on iOS stream capture — needs ffmpeg iOS static lib --- Sources/HLSRecorder.swift | 317 ++++++++------------------------------ SourcesiOS/Info.plist | 121 +++++++-------- 2 files changed, 121 insertions(+), 317 deletions(-) diff --git a/Sources/HLSRecorder.swift b/Sources/HLSRecorder.swift index 8580178..7a32f76 100644 --- a/Sources/HLSRecorder.swift +++ b/Sources/HLSRecorder.swift @@ -2,9 +2,8 @@ import Foundation import AVFoundation import UIKit -import MediaToolbox -/// iOS 录制器 — 从 AVPlayer 播放输出捕获视频帧 + 音频,写入 MP4 +/// iOS 录制器 — 录制本地文件,流媒体暂不支持(CBS 有 DRM 保护,需 ffmpeg iOS) @MainActor final class HLSRecorder: ObservableObject { @Published var isRecording = false @@ -13,22 +12,14 @@ final class HLSRecorder: ObservableObject { 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 recordTask: Task? private var startTime: Date? private var timer: Timer? - private var outputURL: URL? - private var firstPTS: CMTime? - private var audioTapID: MTAudioProcessingTapID? func startRecording(url: URL, player: AVPlayer) { - guard !isRecording, let item = player.currentItem else { return } + guard !isRecording else { return } isRecording = true startTime = Date() - firstPTS = nil timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in Task { @MainActor in @@ -38,268 +29,80 @@ final class HLSRecorder: ObservableObject { } } - let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - outputURL = docs.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).mp4") + recordTask = Task { [weak self] in + guard let self else { return } + do { + let outputURL: URL + if url.isFileURL { + outputURL = try await self.copyLocalFile(url: url) + } else { + // 流媒体:当前 iOS 不支持直接录制 HLS 流 + // CBS 等有 DRM/CDN 保护,需 ffmpeg iOS 静态库 + // 先用 URLSession 下载尝试(仅对无保护的 HTTP 流有效) + outputURL = try await self.downloadFile(url: url) + } - Task { await startCapture(item: item, player: player) } + let attrs = try? FileManager.default.attributesOfItem(atPath: outputURL.path) + let size = (attrs?[.size] as? Int64) ?? 0 + NSLog("[MiniPlayer] Recording complete: %@ (%lld bytes)", outputURL.path, size) + + await MainActor.run { + self.isRecording = false + self.timer?.invalidate() + if size > 1024 { + self.onRecordingSaved?(outputURL) + } else { + self.onError?("Recording too small (\(size) bytes) — stream may be DRM-protected. Try recording a local file.") + try? FileManager.default.removeItem(at: outputURL) + } + } + } catch { + NSLog("[MiniPlayer] Recording error: %@", error.localizedDescription) + await MainActor.run { + self.isRecording = false + self.timer?.invalidate() + self.onError?(error.localizedDescription) + } + } + } } func stopRecording() { guard isRecording else { return } + // 对于本地文件复制,立即完成;对于网络下载,取消任务 + recordTask?.cancel() isRecording = false timer?.invalidate() - displayLink?.invalidate() - videoOutput = nil - - 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", status.rawValue) - - if status == .completed { - let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) - let size = (attrs?[.size] as? Int64) ?? 0 - NSLog("[MiniPlayer] Recorded: %lld bytes", size) - self.onRecordingSaved?(url) - } else { - let err = self.assetWriter?.error?.localizedDescription ?? "unknown" - self.onError?("Export: \(err)") - try? FileManager.default.removeItem(at: url) - } - } - } } - // MARK: - Capture setup + // MARK: - 本地文件复制 - private func startCapture(item: AVPlayerItem, player: AVPlayer) async { - guard let outputURL else { return } + 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)") - do { - let writer = try AVAssetWriter(url: outputURL, fileType: .mp4) - - // Natural size from asset - var size = CGSize(width: 1280, height: 720) - if let tracks = try? await item.asset.load(.tracks), - let videoTrack = tracks.first(where: { $0.mediaType == .video }) { - let ns = try? await videoTrack.load(.naturalSize) - if let ns, ns.width > 0 { size = ns } - } - - // Video input - let videoSettings: [String: Any] = [ - 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 - guard writer.canAdd(vidInput) else { - throw NSError(domain: "Rec", 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: "Rec", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add audio input"]) - } - writer.add(audInput) - self.audioInput = audInput - - // MTAudioProcessingTap on the player item's audio track - installAudioTap(on: item) - - // Video output from player - let pixAttrs: [String: Any] = [ - kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, - kCVPixelBufferWidthKey as String: size.width, - kCVPixelBufferHeightKey as String: size.height - ] - let vo = AVPlayerItemVideoOutput(pixelBufferAttributes: pixAttrs) - vo.suppressesPlayerRendering = false - item.add(vo) - self.videoOutput = vo - - writer.startWriting() - // session start will be set on first frame - self.assetWriter = writer - - displayLink = CADisplayLink(target: self, selector: #selector(captureFrame)) - displayLink?.preferredFrameRateRange = CAFrameRateRange(minimum: 15, maximum: 30, preferred: 30) - displayLink?.add(to: .main, forMode: .common) - - NSLog("[MiniPlayer] Capture started: %@", outputURL.path) - - } catch { - await MainActor.run { - self.isRecording = false - self.timer?.invalidate() - self.onError?(error.localizedDescription) - } + if FileManager.default.fileExists(atPath: outputURL.path) { + try FileManager.default.removeItem(at: outputURL) } + try FileManager.default.copyItem(at: url, to: outputURL) + return outputURL } - // MARK: - Audio tap + // MARK: - 网络流下载(仅对无保护 HTTP 流有效) - private func installAudioTap(on item: AVPlayerItem) { - guard let audioTrack = item.asset.tracks(withMediaType: .audio).first else { - NSLog("[MiniPlayer] No audio track found") - return + 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)") + + if FileManager.default.fileExists(atPath: outputURL.path) { + try FileManager.default.removeItem(at: outputURL) } - var callbacks = MTAudioProcessingTapCallbacks( - version: kMTAudioProcessingTapCallbacksVersion_0, - clientInfo: Unmanaged.passUnretained(self).toOpaque(), - init: { tap, clientInfo, tapStorageOut in - tapStorageOut?.pointee = clientInfo - }, - finalize: { _ in }, - prepare: { _, _, _ in }, - unprepare: { _, _, _ in }, - process: { tap, numberFrames, flags, bufferListInOut, numberFramesOut, flagsOut in - guard let clientInfo = MTAudioProcessingTapGetStorage(tap) else { - numberFramesOut.pointee = 0 - return - } - let recorder = Unmanaged.fromOpaque(clientInfo).takeUnretainedValue() - recorder.processAudio(tap: tap, numberFrames: numberFrames, flags: flags, - bufferListInOut: bufferListInOut, - numberFramesOut: numberFramesOut, flagsOut: flagsOut) - } - ) - - var tap: Unmanaged? - let status = MTAudioProcessingTapCreate(kCFAllocatorDefault, &callbacks, - kMTAudioProcessingTapCreationFlag_PreEffects, &tap) - guard status == noErr, let audioTap = tap?.takeRetainedValue() else { - NSLog("[MiniPlayer] MTAudioProcessingTapCreate failed: %d", status) - return - } - - let inputParams = AVMutableAudioMixInputParameters(track: audioTrack) - inputParams.audioTapProcessor = audioTap - - let audioMix = AVMutableAudioMix() - audioMix.inputParameters = [inputParams] - item.audioMix = audioMix - - NSLog("[MiniPlayer] Audio tap installed") - } - - private func processAudio(tap: MTAudioProcessingTap, numberFrames: CMItemCount, flags: MTAudioProcessingTapFlags, - bufferListInOut: UnsafeMutablePointer, - numberFramesOut: UnsafeMutablePointer, - flagsOut: UnsafeMutablePointer) { - // Get source audio - var sourceFlags = flags - var timeRange = CMTimeRange() - let status = MTAudioProcessingTapGetSourceAudio(tap, numberFrames, bufferListInOut, - flagsOut, &timeRange, numberFramesOut) - guard status == noErr, let input = audioInput, input.isReadyForMoreMediaData else { - flagsOut.pointee = flags - return - } - - // Create CMSampleBuffer from the audio buffer list - let pts = timeRange.start - var asbd = AudioStreamBasicDescription() - MTAudioProcessingTapGetStreamDescription(tap, &asbd) - - var formatDesc: CMAudioFormatDescription? - CMAudioFormatDescriptionCreate(allocator: kCFAllocatorDefault, asbd: &asbd, - layoutSize: 0, layout: nil, 0, nil, nil, - formatDescriptionOut: &formatDesc) - guard let fd = formatDesc else { return } - - var sampleBuffer: CMSampleBuffer? - let numSamples = numberFramesOut.pointee - - var timing = CMSampleTimingInfo(duration: CMTime(value: CMTimeValue(numSamples), timescale: CMTimeScale(asbd.mSampleRate)), - presentationTimeStamp: pts, decodeTimeStamp: .invalid) - - CMSampleBufferCreate(allocator: kCFAllocatorDefault, dataBuffer: nil, dataReady: false, - makeDataReadyCallback: nil, refcon: nil, formatDescription: fd, - sampleCount: numSamples, sampleTimingEntryCount: 1, - sampleTimingArray: &timing, sampleSizeEntryCount: 0, - sampleSizeArray: nil, sampleBufferOut: &sampleBuffer) - - guard var sb = sampleBuffer else { return } - - // Copy audio data into the sample buffer - let bufList = bufferListInOut.pointee - var blockBuffer: CMBlockBuffer? - CMBlockBufferCreateWithMemoryBlock( - allocator: kCFAllocatorDefault, - memoryBlock: nil, - blockLength: Int(bufList.mBuffers.mDataByteSize), - blockAllocator: kCFAllocatorDefault, - customBlockSource: nil, - offsetToData: 0, - dataLength: Int(bufList.mBuffers.mDataByteSize), - flags: 0, - blockBufferOut: &blockBuffer - ) - if let bb = blockBuffer { - CMBlockBufferReplaceDataBytes(with: bufList.mBuffers.mData!, blockBuffer: bb, - offsetIntoDestination: 0, dataLength: Int(bufList.mBuffers.mDataByteSize)) - CMSampleBufferSetDataBuffer(&sb, newValue: bb) - input.append(sb) - } - } - - // MARK: - Video frame capture - - @objc private func captureFrame() { - guard let writer = assetWriter, let vo = videoOutput, let vidInput = videoInput else { return } - - let itemTime = vo.itemTime(forHostTime: CACurrentMediaTime()) - guard vo.hasNewPixelBuffer(forItemTime: itemTime), - let buf = vo.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) - else { return } - - // Start session on first frame - if writer.status == .unknown { - writer.startSession(atSourceTime: itemTime) - firstPTS = itemTime - NSLog("[MiniPlayer] Session started at PTS: %.3f", itemTime.seconds) - } - - guard vidInput.isReadyForMoreMediaData else { return } - - var timing = CMSampleTimingInfo( - duration: CMTime(value: 1, timescale: 30), - presentationTimeStamp: itemTime, - decodeTimeStamp: .invalid - ) - var formatDesc: CMFormatDescription? - CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault, - imageBuffer: buf, formatDescriptionOut: &formatDesc) - guard let fd = formatDesc else { return } - - var sampleBuffer: CMSampleBuffer? - CMSampleBufferCreateReadyWithImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: buf, - formatDescription: fd, sampleTiming: &timing, - sampleBufferOut: &sampleBuffer) - if let sb = sampleBuffer { - vidInput.append(sb) - } + let (tempURL, _) = try await URLSession.shared.download(from: url) + try FileManager.default.moveItem(at: tempURL, to: outputURL) + return outputURL } } #endif diff --git a/SourcesiOS/Info.plist b/SourcesiOS/Info.plist index 328aff7..f3c7f70 100644 --- a/SourcesiOS/Info.plist +++ b/SourcesiOS/Info.plist @@ -2,65 +2,66 @@ - CFBundleDevelopmentRegion - en - CFBundleDisplayName - MiniPlayer - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSRequiresIPhoneOS - - UIBackgroundModes - - audio - - UILaunchScreen - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSPhotoLibraryAddUsageDescription - Save recorded videos to your photo library - NSPhotoLibraryUsageDescription - Access photo library to save recordings - CFBundleIcons - - CFBundlePrimaryIcon - - CFBundleIconFiles - - AppIcon60x60 - - CFBundleIconName - AppIcon - - + CFBundleDevelopmentRegion + en + CFBundleDisplayName + MiniPlayer + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIcons + + CFBundlePrimaryIcon + + CFBundleIconFiles + + AppIcon60x60 + AppIcon60x60 + + CFBundleIconName + AppIcon + + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSPhotoLibraryAddUsageDescription + Save recorded videos to your photo library + NSPhotoLibraryUsageDescription + Access photo library to save recordings + UIBackgroundModes + + audio + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight +