fix(iOS): simplify recorder to local file only, add app icon

- 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
This commit is contained in:
yumoqing 2026-07-17 21:55:49 +08:00
parent 1832672081
commit d74543e3ed
2 changed files with 121 additions and 317 deletions

View File

@ -2,9 +2,8 @@
import Foundation import Foundation
import AVFoundation import AVFoundation
import UIKit import UIKit
import MediaToolbox
/// iOS AVPlayer + MP4 /// iOS CBS DRM ffmpeg iOS
@MainActor @MainActor
final class HLSRecorder: ObservableObject { final class HLSRecorder: ObservableObject {
@Published var isRecording = false @Published var isRecording = false
@ -13,22 +12,14 @@ final class HLSRecorder: ObservableObject {
var onRecordingSaved: ((URL) -> Void)? var onRecordingSaved: ((URL) -> Void)?
var onError: ((String) -> Void)? var onError: ((String) -> Void)?
private var assetWriter: AVAssetWriter? private var recordTask: Task<Void, Never>?
private var videoInput: AVAssetWriterInput?
private var audioInput: AVAssetWriterInput?
private var videoOutput: AVPlayerItemVideoOutput?
private var displayLink: CADisplayLink?
private var startTime: Date? private var startTime: Date?
private var timer: Timer? private var timer: Timer?
private var outputURL: URL?
private var firstPTS: CMTime?
private var audioTapID: MTAudioProcessingTapID?
func startRecording(url: URL, player: AVPlayer) { func startRecording(url: URL, player: AVPlayer) {
guard !isRecording, let item = player.currentItem else { return } guard !isRecording else { return }
isRecording = true isRecording = true
startTime = Date() startTime = Date()
firstPTS = nil
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
Task { @MainActor in Task { @MainActor in
@ -38,116 +29,35 @@ final class HLSRecorder: ObservableObject {
} }
} }
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] recordTask = Task { [weak self] in
outputURL = docs.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).mp4") guard let self else { return }
Task { await startCapture(item: item, player: player) }
}
func stopRecording() {
guard isRecording else { return }
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
private func startCapture(item: AVPlayerItem, player: AVPlayer) async {
guard let outputURL else { return }
do { do {
let writer = try AVAssetWriter(url: outputURL, fileType: .mp4) let outputURL: URL
if url.isFileURL {
// Natural size from asset outputURL = try await self.copyLocalFile(url: url)
var size = CGSize(width: 1280, height: 720) } else {
if let tracks = try? await item.asset.load(.tracks), // iOS HLS
let videoTrack = tracks.first(where: { $0.mediaType == .video }) { // CBS DRM/CDN ffmpeg iOS
let ns = try? await videoTrack.load(.naturalSize) // URLSession HTTP
if let ns, ns.width > 0 { size = ns } outputURL = try await self.downloadFile(url: url)
} }
// Video input let attrs = try? FileManager.default.attributesOfItem(atPath: outputURL.path)
let videoSettings: [String: Any] = [ let size = (attrs?[.size] as? Int64) ?? 0
AVVideoCodecKey: AVVideoCodecType.h264, NSLog("[MiniPlayer] Recording complete: %@ (%lld bytes)", outputURL.path, size)
AVVideoWidthKey: size.width,
AVVideoHeightKey: size.height, await MainActor.run {
AVVideoCompressionPropertiesKey: [ self.isRecording = false
AVVideoAverageBitRateKey: 2_000_000, self.timer?.invalidate()
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel if size > 1024 {
] self.onRecordingSaved?(outputURL)
] } else {
let vidInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) self.onError?("Recording too small (\(size) bytes) — stream may be DRM-protected. Try recording a local file.")
vidInput.expectsMediaDataInRealTime = true try? FileManager.default.removeItem(at: outputURL)
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 { } catch {
NSLog("[MiniPlayer] Recording error: %@", error.localizedDescription)
await MainActor.run { await MainActor.run {
self.isRecording = false self.isRecording = false
self.timer?.invalidate() self.timer?.invalidate()
@ -155,151 +65,44 @@ final class HLSRecorder: ObservableObject {
} }
} }
} }
// MARK: - Audio tap
private func installAudioTap(on item: AVPlayerItem) {
guard let audioTrack = item.asset.tracks(withMediaType: .audio).first else {
NSLog("[MiniPlayer] No audio track found")
return
} }
var callbacks = MTAudioProcessingTapCallbacks( func stopRecording() {
version: kMTAudioProcessingTapCallbacksVersion_0, guard isRecording else { return }
clientInfo: Unmanaged.passUnretained(self).toOpaque(), //
init: { tap, clientInfo, tapStorageOut in recordTask?.cancel()
tapStorageOut?.pointee = clientInfo isRecording = false
}, timer?.invalidate()
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<HLSRecorder>.fromOpaque(clientInfo).takeUnretainedValue()
recorder.processAudio(tap: tap, numberFrames: numberFrames, flags: flags,
bufferListInOut: bufferListInOut,
numberFramesOut: numberFramesOut, flagsOut: flagsOut)
}
)
var tap: Unmanaged<MTAudioProcessingTap>?
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) // MARK: -
inputParams.audioTapProcessor = audioTap
let audioMix = AVMutableAudioMix() private func copyLocalFile(url: URL) async throws -> URL {
audioMix.inputParameters = [inputParams] let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
item.audioMix = audioMix let outputURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).\(ext)")
NSLog("[MiniPlayer] Audio tap installed") if FileManager.default.fileExists(atPath: outputURL.path) {
try FileManager.default.removeItem(at: outputURL)
}
try FileManager.default.copyItem(at: url, to: outputURL)
return outputURL
} }
private func processAudio(tap: MTAudioProcessingTap, numberFrames: CMItemCount, flags: MTAudioProcessingTapFlags, // MARK: - HTTP
bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
numberFramesOut: UnsafeMutablePointer<CMItemCount>, private func downloadFile(url: URL) async throws -> URL {
flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>) { let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
// Get source audio let outputURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
var sourceFlags = flags .appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).\(ext)")
var timeRange = CMTimeRange()
let status = MTAudioProcessingTapGetSourceAudio(tap, numberFrames, bufferListInOut, if FileManager.default.fileExists(atPath: outputURL.path) {
flagsOut, &timeRange, numberFramesOut) try FileManager.default.removeItem(at: outputURL)
guard status == noErr, let input = audioInput, input.isReadyForMoreMediaData else {
flagsOut.pointee = flags
return
} }
// Create CMSampleBuffer from the audio buffer list let (tempURL, _) = try await URLSession.shared.download(from: url)
let pts = timeRange.start try FileManager.default.moveItem(at: tempURL, to: outputURL)
var asbd = AudioStreamBasicDescription() return outputURL
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)
}
} }
} }
#endif #endif

View File

@ -8,6 +8,19 @@
<string>MiniPlayer</string> <string>MiniPlayer</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIcons</key>
<dict>
<key>CFBundlePrimaryIcon</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon60x60</string>
<string>AppIcon60x60</string>
</array>
<key>CFBundleIconName</key>
<string>AppIcon</string>
</dict>
</dict>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
@ -22,6 +35,15 @@
<string>1</string> <string>1</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save recorded videos to your photo library</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Access photo library to save recordings</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>audio</string> <string>audio</string>
@ -41,26 +63,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save recorded videos to your photo library</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Access photo library to save recordings</string>
<key>CFBundleIcons</key>
<dict>
<key>CFBundlePrimaryIcon</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon60x60</string>
</array>
<key>CFBundleIconName</key>
<string>AppIcon</string>
</dict>
</dict>
</dict> </dict>
</plist> </plist>