refactor: 跨平台架构 (macOS+iOS)
- StreamRecorderKit: 跨平台库 - StreamRecorderEngine: 协议+类型 - HLSVariantResolver: HLS master 解析 - FFmpegRecorder: macOS (Process+ffmpeg) - AVFoundationRecorder: iOS (AVAssetReader+Writer) - RecorderFactory: 平台工厂 - StreamRecorder: CLI 入口 - 15秒录制测试通过
This commit is contained in:
parent
8002965848
commit
71d321b056
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
.build/
|
||||||
|
*.mp4
|
||||||
21
Package.swift
Normal file
21
Package.swift
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "StreamRecorder",
|
||||||
|
platforms: [.macOS(.v13), .iOS(.v16)],
|
||||||
|
products: [
|
||||||
|
.executable(name: "StreamRecorder", targets: ["StreamRecorder"]),
|
||||||
|
.library(name: "StreamRecorderKit", targets: ["StreamRecorderKit"])
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "StreamRecorder",
|
||||||
|
dependencies: ["StreamRecorderKit"]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "StreamRecorderKit",
|
||||||
|
dependencies: []
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
198
Sources/StreamRecorder/StreamRecorder.swift
Normal file
198
Sources/StreamRecorder/StreamRecorder.swift
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import Foundation
|
||||||
|
import StreamRecorderKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct StreamRecorderCLI {
|
||||||
|
static func main() async {
|
||||||
|
let args = CommandLine.arguments
|
||||||
|
|
||||||
|
guard args.count >= 3 else {
|
||||||
|
printUsage()
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let urlString = args[1]
|
||||||
|
guard let duration = Double(args[2]), duration > 0 else {
|
||||||
|
print("错误: 录制时长必须是正数 (秒)")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputFilename: String
|
||||||
|
if args.count >= 4 {
|
||||||
|
outputFilename = args[3]
|
||||||
|
} else {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
|
outputFilename = "recording_\(formatter.string(from: Date())).mp4"
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputPath: String
|
||||||
|
if outputFilename.hasPrefix("/") {
|
||||||
|
outputPath = outputFilename
|
||||||
|
} else {
|
||||||
|
outputPath = FileManager.default.currentDirectoryPath + "/" + outputFilename
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let url = URL(string: urlString) else {
|
||||||
|
print("错误: 无效的 URL")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("流媒体录制器 [\(RecorderFactory.platformInfo)]")
|
||||||
|
print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
print("URL: \(urlString)")
|
||||||
|
print("时长: \(Int(duration)) 秒")
|
||||||
|
print("输出: \(outputPath)")
|
||||||
|
print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||||
|
|
||||||
|
let recorder = RecorderFactory.createRecorder()
|
||||||
|
let delegate = CLIDelegate(totalDuration: duration)
|
||||||
|
recorder.delegate = delegate
|
||||||
|
|
||||||
|
print("开始录制...\n")
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await recorder.start(url: url, outputPath: outputPath, duration: duration)
|
||||||
|
} catch {
|
||||||
|
print("\n❌ \(error.localizedDescription)")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印结果
|
||||||
|
if let result = delegate.result {
|
||||||
|
printResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func printResult(_ result: RecordingResult) {
|
||||||
|
print("\n✅ 录制完成!\n")
|
||||||
|
print(" 文件: \(result.outputPath)")
|
||||||
|
print(" 时长: \(formatDuration(result.duration))")
|
||||||
|
print(" 大小: \(formatFileSize(result.fileSize))")
|
||||||
|
if result.duration > 0 {
|
||||||
|
let bps = Double(result.fileSize) * 8.0 / result.duration
|
||||||
|
print(" 码率: \(formatBitrate(bps))")
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\n 轨道信息:")
|
||||||
|
if let v = result.videoInfo {
|
||||||
|
let fpsStr = String(format: "%.2f", v.fps)
|
||||||
|
print(" [0] 视频: \(v.codec) \(v.width)x\(v.height) \(fpsStr)fps \(v.pixelFormat)")
|
||||||
|
}
|
||||||
|
if let a = result.audioInfo {
|
||||||
|
print(" [\(result.videoInfo != nil ? 1 : 0)] 音频: \(a.codec) \(a.sampleRate)Hz \(a.channels)ch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.hasVideo {
|
||||||
|
print(" ⚠️ 警告: 无视频轨道!")
|
||||||
|
}
|
||||||
|
if !result.hasAudio {
|
||||||
|
print(" ⚠️ 警告: 无音频轨道!")
|
||||||
|
}
|
||||||
|
if result.hasVideo && result.hasAudio {
|
||||||
|
print(" ✅ 音视频完整")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let level = result.audioLevel {
|
||||||
|
print("\n 音频检测:")
|
||||||
|
print(" 平均音量: \(String(format: "%.1f", level.meanVolume)) dB")
|
||||||
|
if level.isSilent {
|
||||||
|
print(" ⚠️ 音频接近静音!")
|
||||||
|
} else if level.meanVolume < -40 {
|
||||||
|
print(" ℹ️ 音频音量较低")
|
||||||
|
} else {
|
||||||
|
print(" ✅ 音频正常(有声音)")
|
||||||
|
}
|
||||||
|
print(" 峰值音量: \(String(format: "%.1f", level.maxVolume)) dB")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func formatDuration(_ seconds: Double) -> String {
|
||||||
|
let h = Int(seconds) / 3600
|
||||||
|
let m = (Int(seconds) % 3600) / 60
|
||||||
|
let s = Int(seconds) % 60
|
||||||
|
if h > 0 {
|
||||||
|
return String(format: "%d:%02d:%02d", h, m, s)
|
||||||
|
}
|
||||||
|
return String(format: "%02d:%02d", m, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func formatFileSize(_ bytes: Int64) -> String {
|
||||||
|
let units = ["B", "KB", "MB", "GB"]
|
||||||
|
var size = Double(bytes)
|
||||||
|
var i = 0
|
||||||
|
while size >= 1024 && i < units.count - 1 {
|
||||||
|
size /= 1024
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return String(format: "%.1f %@", size, units[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func formatBitrate(_ bps: Double) -> String {
|
||||||
|
if bps >= 1_000_000 {
|
||||||
|
return String(format: "%.1f Mbps", bps / 1_000_000)
|
||||||
|
}
|
||||||
|
return String(format: "%.0f Kbps", bps / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func printUsage() {
|
||||||
|
print("""
|
||||||
|
用法: StreamRecorder <流媒体URL> <录制时长(秒)> [输出文件名]
|
||||||
|
|
||||||
|
参数:
|
||||||
|
URL 流媒体地址 (HLS/m3u8, mp4, rtmp 等)
|
||||||
|
时长 录制时长,单位秒
|
||||||
|
输出文件名 可选,默认为 recording_YYYYMMDD_HHmmss.mp4
|
||||||
|
|
||||||
|
示例:
|
||||||
|
StreamRecorder https://example.com/live/stream.m3u8 60
|
||||||
|
StreamRecorder https://example.com/video.m3u8 120 output.mp4
|
||||||
|
|
||||||
|
平台: \(RecorderFactory.platformInfo)
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CLI 进度显示代理
|
||||||
|
final class CLIDelegate: StreamRecorderDelegate {
|
||||||
|
let totalDuration: Double
|
||||||
|
var result: RecordingResult?
|
||||||
|
|
||||||
|
init(totalDuration: Double) {
|
||||||
|
self.totalDuration = totalDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
func recorderDidStart(_ recorder: StreamRecorderEngine) {
|
||||||
|
// 已开始
|
||||||
|
}
|
||||||
|
|
||||||
|
func recorder(_ recorder: StreamRecorderEngine, didUpdateProgress progress: RecordingProgress) {
|
||||||
|
let pct = min(progress.currentTime / totalDuration * 100, 100)
|
||||||
|
let barWidth = 30
|
||||||
|
let filled = Int(pct / 100 * Double(barWidth))
|
||||||
|
let bar = String(repeating: "█", count: filled) +
|
||||||
|
String(repeating: "░", count: barWidth - filled)
|
||||||
|
let timeStr = formatTime(progress.currentTime)
|
||||||
|
let totalStr = formatTime(totalDuration)
|
||||||
|
print("\r[\(bar)] \(timeStr) / \(totalStr)", terminator: "")
|
||||||
|
fflush(stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recorder(_ recorder: StreamRecorderEngine, didFinishWithResult result: RecordingResult) {
|
||||||
|
self.result = result
|
||||||
|
}
|
||||||
|
|
||||||
|
func recorder(_ recorder: StreamRecorderEngine, didFailWithError error: Error) {
|
||||||
|
print("\n❌ \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTime(_ seconds: Double) -> String {
|
||||||
|
let h = Int(seconds) / 3600
|
||||||
|
let m = (Int(seconds) % 3600) / 60
|
||||||
|
let s = Int(seconds) % 60
|
||||||
|
if h > 0 {
|
||||||
|
return String(format: "%d:%02d:%02d", h, m, s)
|
||||||
|
}
|
||||||
|
return String(format: "%02d:%02d", m, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
303
Sources/StreamRecorderKit/AVFoundationRecorder.swift
Normal file
303
Sources/StreamRecorderKit/AVFoundationRecorder.swift
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
#if os(iOS)
|
||||||
|
|
||||||
|
public final class AVFoundationRecorder: StreamRecorderEngine {
|
||||||
|
public weak var delegate: StreamRecorderDelegate?
|
||||||
|
public private(set) var isRecording = false
|
||||||
|
|
||||||
|
private var assetReader: AVAssetReader?
|
||||||
|
private var assetWriter: AVAssetWriter?
|
||||||
|
private var videoOutput: AVAssetReaderVideoCompositionOutput?
|
||||||
|
private var audioOutput: AVAssetReaderAudioMixOutput?
|
||||||
|
private var videoInput: AVAssetWriterInput?
|
||||||
|
private var audioInput: AVAssetWriterInput?
|
||||||
|
|
||||||
|
private var duration: Double = 0
|
||||||
|
private var startTime: Date?
|
||||||
|
private var bytesWritten: Int64 = 0
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func start(url: URL, outputPath: String, duration: Double) async throws {
|
||||||
|
guard !isRecording else { return }
|
||||||
|
|
||||||
|
self.duration = duration
|
||||||
|
self.startTime = Date()
|
||||||
|
self.bytesWritten = 0
|
||||||
|
|
||||||
|
// 解析 HLS 变体
|
||||||
|
let resolvedURL = HLSVariantResolver.resolve(url: url, maxWidth: 1280) ?? url
|
||||||
|
|
||||||
|
let asset = AVURLAsset(url: resolvedURL, options: [
|
||||||
|
AVURLAssetPreferPreciseDurationAndTimingKey: true
|
||||||
|
])
|
||||||
|
|
||||||
|
// 加载轨道(带重试)
|
||||||
|
let videoTracks: [AVAssetTrack]
|
||||||
|
let audioTracks: [AVAssetTrack]
|
||||||
|
|
||||||
|
do {
|
||||||
|
let isPlayable = try await asset.load(.isPlayable)
|
||||||
|
if !isPlayable {
|
||||||
|
print("警告: 媒体可能无法正常播放")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw RecordingError.unknown("无法加载媒体: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var retryCount = 0
|
||||||
|
var vTracks: [AVAssetTrack] = []
|
||||||
|
var aTracks: [AVAssetTrack] = []
|
||||||
|
|
||||||
|
while vTracks.isEmpty && aTracks.isEmpty && retryCount < 10 {
|
||||||
|
do {
|
||||||
|
vTracks = try await asset.loadTracks(withMediaType: .video)
|
||||||
|
aTracks = try await asset.loadTracks(withMediaType: .audio)
|
||||||
|
} catch {
|
||||||
|
throw RecordingError.unknown("无法加载轨道: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if vTracks.isEmpty && aTracks.isEmpty {
|
||||||
|
retryCount += 1
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
videoTracks = vTracks
|
||||||
|
audioTracks = aTracks
|
||||||
|
|
||||||
|
if videoTracks.isEmpty && audioTracks.isEmpty {
|
||||||
|
throw RecordingError.noMediaTracks
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 reader
|
||||||
|
let reader = try AVAssetReader(asset: asset)
|
||||||
|
self.assetReader = reader
|
||||||
|
|
||||||
|
// 设置 writer
|
||||||
|
let outputURL = URL(fileURLWithPath: outputPath)
|
||||||
|
if FileManager.default.fileExists(atPath: outputPath) {
|
||||||
|
try FileManager.default.removeItem(atPath: outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
|
||||||
|
self.assetWriter = writer
|
||||||
|
|
||||||
|
// 配置视频
|
||||||
|
if let videoTrack = videoTracks.first {
|
||||||
|
let naturalSize = (try? await videoTrack.load(.naturalSize)) ?? CGSize(width: 1920, height: 1080)
|
||||||
|
|
||||||
|
let outputSettings: [String: Any] = [
|
||||||
|
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||||
|
AVVideoWidthKey: naturalSize.width,
|
||||||
|
AVVideoHeightKey: naturalSize.height
|
||||||
|
]
|
||||||
|
|
||||||
|
let readerVideoOutput = AVAssetReaderVideoCompositionOutput(
|
||||||
|
videoTracks: [videoTrack],
|
||||||
|
videoSettings: [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange]
|
||||||
|
)
|
||||||
|
readerVideoOutput.alwaysCopiesSampleData = false
|
||||||
|
|
||||||
|
if reader.canAdd(readerVideoOutput) {
|
||||||
|
reader.add(readerVideoOutput)
|
||||||
|
videoOutput = readerVideoOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
let writerVideoInput = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings)
|
||||||
|
writerVideoInput.expectsMediaDataInRealTime = false
|
||||||
|
|
||||||
|
if writer.canAdd(writerVideoInput) {
|
||||||
|
writer.add(writerVideoInput)
|
||||||
|
videoInput = writerVideoInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置音频
|
||||||
|
if let audioTrack = audioTracks.first {
|
||||||
|
let readerAudioOutput = AVAssetReaderAudioMixOutput(
|
||||||
|
audioTracks: [audioTrack],
|
||||||
|
audioSettings: nil
|
||||||
|
)
|
||||||
|
readerAudioOutput.alwaysCopiesSampleData = false
|
||||||
|
|
||||||
|
if reader.canAdd(readerAudioOutput) {
|
||||||
|
reader.add(readerAudioOutput)
|
||||||
|
audioOutput = readerAudioOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioSettings: [String: Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVSampleRateKey: 44100,
|
||||||
|
AVNumberOfChannelsKey: 2,
|
||||||
|
AVEncoderBitRateKey: 128000
|
||||||
|
]
|
||||||
|
|
||||||
|
let writerAudioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
|
||||||
|
writerAudioInput.expectsMediaDataInRealTime = false
|
||||||
|
|
||||||
|
if writer.canAdd(writerAudioInput) {
|
||||||
|
writer.add(writerAudioInput)
|
||||||
|
audioInput = writerAudioInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始录制
|
||||||
|
reader.startReading()
|
||||||
|
writer.startWriting()
|
||||||
|
writer.startSession(atSourceTime: .zero)
|
||||||
|
|
||||||
|
isRecording = true
|
||||||
|
delegate?.recorderDidStart(self)
|
||||||
|
|
||||||
|
// 处理视频
|
||||||
|
if let vOutput = videoOutput, let vInput = videoInput {
|
||||||
|
processMedia(output: vOutput, input: vInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理音频
|
||||||
|
if let aOutput = audioOutput, let aInput = audioInput {
|
||||||
|
processMedia(output: aOutput, input: aInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待完成或超时
|
||||||
|
while isRecording {
|
||||||
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
|
|
||||||
|
let elapsed = Date().timeIntervalSince(startTime!)
|
||||||
|
let progress = RecordingProgress(
|
||||||
|
currentTime: elapsed,
|
||||||
|
totalDuration: duration,
|
||||||
|
bytesWritten: bytesWritten,
|
||||||
|
bitrate: 0
|
||||||
|
)
|
||||||
|
delegate?.recorder(self, didUpdateProgress: progress)
|
||||||
|
|
||||||
|
if elapsed >= duration {
|
||||||
|
isRecording = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if reader.status == .failed {
|
||||||
|
throw RecordingError.unknown("读取失败: \(reader.error?.localizedDescription ?? "未知错误")")
|
||||||
|
}
|
||||||
|
|
||||||
|
if writer.status == .failed {
|
||||||
|
throw RecordingError.unknown("写入失败: \(writer.error?.localizedDescription ?? "未知错误")")
|
||||||
|
}
|
||||||
|
|
||||||
|
if reader.status == .completed {
|
||||||
|
isRecording = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成
|
||||||
|
reader.cancelReading()
|
||||||
|
videoInput?.markAsFinished()
|
||||||
|
audioInput?.markAsFinished()
|
||||||
|
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
writer.finishWriting {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard writer.status == .completed else {
|
||||||
|
throw RecordingError.unknown("录制失败: \(writer.error?.localizedDescription ?? "未知错误")")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分析结果
|
||||||
|
let result = try await analyzeOutput(path: outputPath)
|
||||||
|
delegate?.recorder(self, didFinishWithResult: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
isRecording = false
|
||||||
|
assetReader?.cancelReading()
|
||||||
|
assetWriter?.cancelWriting()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func processMedia(output: AVAssetReaderOutput, input: AVAssetWriterInput) {
|
||||||
|
let queue = DispatchQueue(label: "recorder.media")
|
||||||
|
|
||||||
|
input.requestMediaDataWhenReady(on: queue) { [weak self] in
|
||||||
|
guard let self = self, self.isRecording else {
|
||||||
|
input.markAsFinished()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
while input.isReadyForMoreMediaData {
|
||||||
|
guard let sampleBuffer = output.copyNextSampleBuffer() else {
|
||||||
|
input.markAsFinished()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
||||||
|
let seconds = CMTimeGetSeconds(timestamp)
|
||||||
|
if seconds >= self.duration {
|
||||||
|
input.markAsFinished()
|
||||||
|
self.isRecording = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input.append(sampleBuffer)
|
||||||
|
|
||||||
|
// 估算字节数
|
||||||
|
let dataSize = CMSampleBufferGetTotalSampleSize(sampleBuffer)
|
||||||
|
self.bytesWritten += Int64(dataSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func analyzeOutput(path: String) async throws -> RecordingResult {
|
||||||
|
let attrs = try? FileManager.default.attributesOfItem(atPath: path)
|
||||||
|
let fileSize = attrs?[.size] as? Int64 ?? 0
|
||||||
|
|
||||||
|
let asset = AVURLAsset(url: URL(fileURLWithPath: path))
|
||||||
|
let videoTracks = try await asset.loadTracks(withMediaType: .video)
|
||||||
|
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
|
||||||
|
|
||||||
|
var videoInfo: VideoInfo?
|
||||||
|
var audioInfo: AudioInfo?
|
||||||
|
|
||||||
|
if let videoTrack = videoTracks.first {
|
||||||
|
let naturalSize = (try? await videoTrack.load(.naturalSize)) ?? .zero
|
||||||
|
let fps = (try? await videoTrack.load(.nominalFrameRate)) ?? 30.0
|
||||||
|
videoInfo = VideoInfo(
|
||||||
|
codec: "h264",
|
||||||
|
width: Int(naturalSize.width),
|
||||||
|
height: Int(naturalSize.height),
|
||||||
|
fps: Double(fps),
|
||||||
|
pixelFormat: "yuv420p"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let audioTrack = audioTracks.first {
|
||||||
|
let sampleRate = (try? await audioTrack.load(.naturalTimeScale)) ?? 44100
|
||||||
|
audioInfo = AudioInfo(
|
||||||
|
codec: "aac",
|
||||||
|
sampleRate: Int(sampleRate),
|
||||||
|
channels: 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let duration = (try? await asset.load(.duration).seconds) ?? self.duration
|
||||||
|
|
||||||
|
return RecordingResult(
|
||||||
|
outputPath: path,
|
||||||
|
duration: duration,
|
||||||
|
fileSize: fileSize,
|
||||||
|
hasVideo: !videoTracks.isEmpty,
|
||||||
|
hasAudio: !audioTracks.isEmpty,
|
||||||
|
videoInfo: videoInfo,
|
||||||
|
audioInfo: audioInfo,
|
||||||
|
audioLevel: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
296
Sources/StreamRecorderKit/FFmpegRecorder.swift
Normal file
296
Sources/StreamRecorderKit/FFmpegRecorder.swift
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
import Foundation
|
||||||
|
#if os(macOS)
|
||||||
|
|
||||||
|
/// Thread-safe progress state
|
||||||
|
final class ProgressState: @unchecked Sendable {
|
||||||
|
private var lastTime: String = ""
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
func update(_ timeStr: String) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
guard timeStr != lastTime else { return false }
|
||||||
|
lastTime = timeStr
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class FFmpegRecorder: StreamRecorderEngine {
|
||||||
|
public weak var delegate: StreamRecorderDelegate?
|
||||||
|
public private(set) var isRecording = false
|
||||||
|
|
||||||
|
private var process: Process?
|
||||||
|
private var duration: Double = 0
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func start(url: URL, outputPath: String, duration: Double) async throws {
|
||||||
|
guard !isRecording else { return }
|
||||||
|
|
||||||
|
self.duration = duration
|
||||||
|
|
||||||
|
// 查找 ffmpeg
|
||||||
|
let ffmpegPath = findFFmpeg()
|
||||||
|
guard FileManager.default.fileExists(atPath: ffmpegPath) else {
|
||||||
|
throw RecordingError.noFFmpeg
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 HLS 变体
|
||||||
|
let resolvedURL = HLSVariantResolver.resolve(url: url, maxWidth: 1280) ?? url
|
||||||
|
|
||||||
|
// 删除已存在的输出文件
|
||||||
|
if FileManager.default.fileExists(atPath: outputPath) {
|
||||||
|
try? FileManager.default.removeItem(atPath: outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动 ffmpeg 进程
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: ffmpegPath)
|
||||||
|
proc.arguments = [
|
||||||
|
"-y",
|
||||||
|
"-i", resolvedURL.absoluteString,
|
||||||
|
"-t", String(duration),
|
||||||
|
"-c", "copy",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
outputPath
|
||||||
|
]
|
||||||
|
|
||||||
|
let stderrPipe = Pipe()
|
||||||
|
proc.standardError = stderrPipe
|
||||||
|
proc.standardOutput = FileHandle.nullDevice
|
||||||
|
|
||||||
|
// 解析进度
|
||||||
|
let progressState = ProgressState()
|
||||||
|
stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let data = handle.availableData
|
||||||
|
guard !data.isEmpty else { return }
|
||||||
|
|
||||||
|
let str = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
if let timeRange = str.range(of: "time=") {
|
||||||
|
let sub = String(str[timeRange.upperBound...])
|
||||||
|
if let endRange = sub.range(of: " ") ?? sub.range(of: "\n") ?? sub.range(of: "\r") {
|
||||||
|
let timeStr = String(sub[..<endRange.lowerBound])
|
||||||
|
if progressState.update(timeStr) {
|
||||||
|
if let secs = self.parseTime(timeStr) {
|
||||||
|
let progress = RecordingProgress(
|
||||||
|
currentTime: secs,
|
||||||
|
totalDuration: self.duration,
|
||||||
|
bytesWritten: 0,
|
||||||
|
bitrate: 1500.0
|
||||||
|
)
|
||||||
|
self.delegate?.recorder(self, didUpdateProgress: progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isRecording = true
|
||||||
|
delegate?.recorderDidStart(self)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try proc.run()
|
||||||
|
self.process = proc
|
||||||
|
} catch {
|
||||||
|
isRecording = false
|
||||||
|
throw RecordingError.unknown("无法启动 ffmpeg: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待完成
|
||||||
|
proc.waitUntilExit()
|
||||||
|
stderrPipe.fileHandleForReading.readabilityHandler = nil
|
||||||
|
isRecording = false
|
||||||
|
|
||||||
|
guard proc.terminationStatus == 0 else {
|
||||||
|
throw RecordingError.processFailed(Int(proc.terminationStatus))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard FileManager.default.fileExists(atPath: outputPath) else {
|
||||||
|
throw RecordingError.outputNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分析输出文件
|
||||||
|
let ffprobePath = ffmpegPath.replacingOccurrences(of: "ffmpeg", with: "ffprobe")
|
||||||
|
let result = try await analyzeOutput(path: outputPath, ffprobePath: ffprobePath)
|
||||||
|
delegate?.recorder(self, didFinishWithResult: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
process?.terminate()
|
||||||
|
isRecording = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func findFFmpeg() -> String {
|
||||||
|
let paths = [
|
||||||
|
"/opt/homebrew/bin/ffmpeg",
|
||||||
|
"/usr/local/bin/ffmpeg",
|
||||||
|
"/usr/bin/ffmpeg"
|
||||||
|
]
|
||||||
|
return paths.first { FileManager.default.fileExists(atPath: $0) } ?? "ffmpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseTime(_ str: String) -> Double? {
|
||||||
|
let parts = str.split(separator: ":")
|
||||||
|
guard parts.count == 3,
|
||||||
|
let h = Double(parts[0]),
|
||||||
|
let m = Double(parts[1]),
|
||||||
|
let s = Double(parts[2]) else { return nil }
|
||||||
|
return h * 3600 + m * 60 + s
|
||||||
|
}
|
||||||
|
|
||||||
|
private func analyzeOutput(path: String, ffprobePath: String) async throws -> RecordingResult {
|
||||||
|
guard FileManager.default.fileExists(atPath: ffprobePath) else {
|
||||||
|
let attrs = try? FileManager.default.attributesOfItem(atPath: path)
|
||||||
|
let fileSize = attrs?[.size] as? Int64 ?? 0
|
||||||
|
return RecordingResult(
|
||||||
|
outputPath: path,
|
||||||
|
duration: duration,
|
||||||
|
fileSize: fileSize,
|
||||||
|
hasVideo: true,
|
||||||
|
hasAudio: true,
|
||||||
|
videoInfo: nil,
|
||||||
|
audioInfo: nil,
|
||||||
|
audioLevel: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: ffprobePath)
|
||||||
|
proc.arguments = [
|
||||||
|
"-v", "quiet",
|
||||||
|
"-print_format", "json",
|
||||||
|
"-show_format",
|
||||||
|
"-show_streams",
|
||||||
|
path
|
||||||
|
]
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
proc.standardOutput = pipe
|
||||||
|
proc.standardError = FileHandle.nullDevice
|
||||||
|
|
||||||
|
do {
|
||||||
|
try proc.run()
|
||||||
|
proc.waitUntilExit()
|
||||||
|
} catch {
|
||||||
|
throw RecordingError.unknown("无法分析输出文件")
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
throw RecordingError.unknown("无法解析文件信息")
|
||||||
|
}
|
||||||
|
|
||||||
|
let format = json["format"] as? [String: Any]
|
||||||
|
let fileDuration = Double(format?["duration"] as? String ?? "0") ?? duration
|
||||||
|
let fileSize = Int64(format?["size"] as? String ?? "0") ?? 0
|
||||||
|
|
||||||
|
let streams = json["streams"] as? [[String: Any]] ?? []
|
||||||
|
var hasVideo = false
|
||||||
|
var hasAudio = false
|
||||||
|
var videoInfo: VideoInfo?
|
||||||
|
var audioInfo: AudioInfo?
|
||||||
|
|
||||||
|
for stream in streams {
|
||||||
|
let codecType = stream["codec_type"] as? String ?? ""
|
||||||
|
if codecType == "video" {
|
||||||
|
hasVideo = true
|
||||||
|
videoInfo = VideoInfo(
|
||||||
|
codec: stream["codec_name"] as? String ?? "unknown",
|
||||||
|
width: stream["width"] as? Int ?? 0,
|
||||||
|
height: stream["height"] as? Int ?? 0,
|
||||||
|
fps: parseFPS(stream["r_frame_rate"] as? String),
|
||||||
|
pixelFormat: stream["pix_fmt"] as? String ?? "unknown"
|
||||||
|
)
|
||||||
|
} else if codecType == "audio" {
|
||||||
|
hasAudio = true
|
||||||
|
audioInfo = AudioInfo(
|
||||||
|
codec: stream["codec_name"] as? String ?? "unknown",
|
||||||
|
sampleRate: Int(stream["sample_rate"] as? String ?? "0") ?? 0,
|
||||||
|
channels: stream["channels"] as? Int ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioLevel = await checkAudioLevel(path: path)
|
||||||
|
|
||||||
|
return RecordingResult(
|
||||||
|
outputPath: path,
|
||||||
|
duration: fileDuration,
|
||||||
|
fileSize: fileSize,
|
||||||
|
hasVideo: hasVideo,
|
||||||
|
hasAudio: hasAudio,
|
||||||
|
videoInfo: videoInfo,
|
||||||
|
audioInfo: audioInfo,
|
||||||
|
audioLevel: audioLevel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseFPS(_ str: String?) -> Double {
|
||||||
|
guard let str = str else { return 30.0 }
|
||||||
|
let parts = str.split(separator: "/")
|
||||||
|
guard parts.count == 2,
|
||||||
|
let num = Double(parts[0]),
|
||||||
|
let den = Double(parts[1]),
|
||||||
|
den > 0 else { return 30.0 }
|
||||||
|
return num / den
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkAudioLevel(path: String) async -> AudioLevel? {
|
||||||
|
let ffmpegPath = findFFmpeg()
|
||||||
|
guard FileManager.default.fileExists(atPath: ffmpegPath) else { return nil }
|
||||||
|
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: ffmpegPath)
|
||||||
|
proc.arguments = [
|
||||||
|
"-i", path,
|
||||||
|
"-af", "volumedetect",
|
||||||
|
"-f", "null",
|
||||||
|
"-"
|
||||||
|
]
|
||||||
|
proc.standardOutput = FileHandle.nullDevice
|
||||||
|
let pipe = Pipe()
|
||||||
|
proc.standardError = pipe
|
||||||
|
|
||||||
|
do {
|
||||||
|
try proc.run()
|
||||||
|
proc.waitUntilExit()
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
let str = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
|
||||||
|
var meanVolume: Double?
|
||||||
|
var maxVolume: Double?
|
||||||
|
|
||||||
|
if let meanRange = str.range(of: "mean_volume:") {
|
||||||
|
let sub = String(str[meanRange.upperBound...])
|
||||||
|
if let dbRange = sub.range(of: " dB") {
|
||||||
|
let volStr = String(sub[..<dbRange.lowerBound]).trimmingCharacters(in: .whitespaces)
|
||||||
|
meanVolume = Double(volStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let maxRange = str.range(of: "max_volume:") {
|
||||||
|
let sub = String(str[maxRange.upperBound...])
|
||||||
|
if let dbRange = sub.range(of: " dB") {
|
||||||
|
let volStr = String(sub[..<dbRange.lowerBound]).trimmingCharacters(in: .whitespaces)
|
||||||
|
maxVolume = Double(volStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let mean = meanVolume, let max = maxVolume else { return nil }
|
||||||
|
|
||||||
|
return AudioLevel(
|
||||||
|
meanVolume: mean,
|
||||||
|
maxVolume: max,
|
||||||
|
isSilent: mean < -60
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
86
Sources/StreamRecorderKit/HLSVariantResolver.swift
Normal file
86
Sources/StreamRecorderKit/HLSVariantResolver.swift
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// HLS 变体解析器
|
||||||
|
public struct HLSVariantResolver {
|
||||||
|
|
||||||
|
public struct Variant {
|
||||||
|
public let bandwidth: Int
|
||||||
|
public let resolution: String
|
||||||
|
public let path: String
|
||||||
|
public let fullURL: URL
|
||||||
|
|
||||||
|
public var width: Int? {
|
||||||
|
let parts = resolution.components(separatedBy: "x")
|
||||||
|
return parts.count == 2 ? Int(parts[0]) : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public var height: Int? {
|
||||||
|
let parts = resolution.components(separatedBy: "x")
|
||||||
|
return parts.count == 2 ? Int(parts[1]) : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 master playlist,返回最佳变体 URL
|
||||||
|
/// - Parameters:
|
||||||
|
/// - url: master playlist URL
|
||||||
|
/// - maxWidth: 最大宽度限制(默认 1280)
|
||||||
|
/// - Returns: 最佳变体 URL,如果不是 master playlist 则返回 nil
|
||||||
|
public static func resolve(url: URL, maxWidth: Int = 1280) -> URL? {
|
||||||
|
guard url.absoluteString.contains(".m3u8"),
|
||||||
|
let data = try? Data(contentsOf: url),
|
||||||
|
let content = String(data: data, encoding: .utf8),
|
||||||
|
content.contains("EXT-X-STREAM-INF") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let variants = parseVariants(from: content, baseURL: url)
|
||||||
|
guard !variants.isEmpty else { return nil }
|
||||||
|
|
||||||
|
// 选择最佳变体(带宽最高且宽度 <= maxWidth)
|
||||||
|
let sorted = variants.sorted { $0.bandwidth > $1.bandwidth }
|
||||||
|
let chosen = sorted.first { variant in
|
||||||
|
guard let w = variant.width else { return true }
|
||||||
|
return w <= maxWidth
|
||||||
|
} ?? sorted[0]
|
||||||
|
|
||||||
|
return chosen.fullURL
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseVariants(from content: String, baseURL: URL) -> [Variant] {
|
||||||
|
var variants: [Variant] = []
|
||||||
|
let lines = content.components(separatedBy: "\n")
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while i < lines.count - 1 {
|
||||||
|
let line = lines[i]
|
||||||
|
if line.hasPrefix("#EXT-X-STREAM-INF:") {
|
||||||
|
var bandwidth = 0
|
||||||
|
var resolution = ""
|
||||||
|
|
||||||
|
let attrs = line.replacingOccurrences(of: "#EXT-X-STREAM-INF:", with: "")
|
||||||
|
for attr in attrs.components(separatedBy: ",") {
|
||||||
|
let trimmed = attr.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.hasPrefix("BANDWIDTH=") {
|
||||||
|
bandwidth = Int(trimmed.replacingOccurrences(of: "BANDWIDTH=", with: "")) ?? 0
|
||||||
|
} else if trimmed.hasPrefix("RESOLUTION=") {
|
||||||
|
resolution = trimmed.replacingOccurrences(of: "RESOLUTION=", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextLine = lines[i + 1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !nextLine.isEmpty && !nextLine.hasPrefix("#") {
|
||||||
|
let variantURL = baseURL.deletingLastPathComponent().appendingPathComponent(nextLine)
|
||||||
|
variants.append(Variant(
|
||||||
|
bandwidth: bandwidth,
|
||||||
|
resolution: resolution,
|
||||||
|
path: nextLine,
|
||||||
|
fullURL: variantURL
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return variants
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Sources/StreamRecorderKit/RecorderFactory.swift
Normal file
28
Sources/StreamRecorderKit/RecorderFactory.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 录制引擎工厂
|
||||||
|
public struct RecorderFactory {
|
||||||
|
|
||||||
|
/// 创建适合当前平台的录制引擎
|
||||||
|
/// - Returns: macOS 返回 FFmpegRecorder,iOS 返回 AVFoundationRecorder
|
||||||
|
public static func createRecorder() -> StreamRecorderEngine {
|
||||||
|
#if os(macOS)
|
||||||
|
return FFmpegRecorder()
|
||||||
|
#elseif os(iOS)
|
||||||
|
return AVFoundationRecorder()
|
||||||
|
#else
|
||||||
|
fatalError("不支持的平台")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前平台信息
|
||||||
|
public static var platformInfo: String {
|
||||||
|
#if os(macOS)
|
||||||
|
return "macOS (FFmpeg)"
|
||||||
|
#elseif os(iOS)
|
||||||
|
return "iOS (AVFoundation)"
|
||||||
|
#else
|
||||||
|
return "Unknown"
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
83
Sources/StreamRecorderKit/StreamRecorderEngine.swift
Normal file
83
Sources/StreamRecorderKit/StreamRecorderEngine.swift
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 录制进度回调
|
||||||
|
public protocol StreamRecorderDelegate: AnyObject {
|
||||||
|
func recorderDidStart(_ recorder: StreamRecorderEngine)
|
||||||
|
func recorder(_ recorder: StreamRecorderEngine, didUpdateProgress progress: RecordingProgress)
|
||||||
|
func recorder(_ recorder: StreamRecorderEngine, didFinishWithResult result: RecordingResult)
|
||||||
|
func recorder(_ recorder: StreamRecorderEngine, didFailWithError error: Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 录制进度
|
||||||
|
public struct RecordingProgress {
|
||||||
|
public let currentTime: Double // 当前已录制时长(秒)
|
||||||
|
public let totalDuration: Double // 总时长(秒)
|
||||||
|
public let bytesWritten: Int64 // 已写入字节数
|
||||||
|
public let bitrate: Double // 当前码率 (kbps)
|
||||||
|
|
||||||
|
public var percentage: Double {
|
||||||
|
guard totalDuration > 0 else { return 0 }
|
||||||
|
return min(currentTime / totalDuration * 100, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 录制结果
|
||||||
|
public struct RecordingResult {
|
||||||
|
public let outputPath: String
|
||||||
|
public let duration: Double
|
||||||
|
public let fileSize: Int64
|
||||||
|
public let hasVideo: Bool
|
||||||
|
public let hasAudio: Bool
|
||||||
|
public let videoInfo: VideoInfo?
|
||||||
|
public let audioInfo: AudioInfo?
|
||||||
|
public let audioLevel: AudioLevel?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct VideoInfo {
|
||||||
|
public let codec: String
|
||||||
|
public let width: Int
|
||||||
|
public let height: Int
|
||||||
|
public let fps: Double
|
||||||
|
public let pixelFormat: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AudioInfo {
|
||||||
|
public let codec: String
|
||||||
|
public let sampleRate: Int
|
||||||
|
public let channels: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AudioLevel {
|
||||||
|
public let meanVolume: Double // dB
|
||||||
|
public let maxVolume: Double // dB
|
||||||
|
public let isSilent: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RecordingError: Error, LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case noFFmpeg
|
||||||
|
case noMediaTracks
|
||||||
|
case processFailed(Int)
|
||||||
|
case outputNotFound
|
||||||
|
case unknown(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL: return "无效的 URL"
|
||||||
|
case .noFFmpeg: return "未找到 ffmpeg,请安装: brew install ffmpeg"
|
||||||
|
case .noMediaTracks: return "未找到任何媒体轨道"
|
||||||
|
case .processFailed(let code): return "录制失败 (exit code: \(code))"
|
||||||
|
case .outputNotFound: return "输出文件不存在"
|
||||||
|
case .unknown(let msg): return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 录制引擎协议
|
||||||
|
public protocol StreamRecorderEngine: AnyObject {
|
||||||
|
var delegate: StreamRecorderDelegate? { get set }
|
||||||
|
var isRecording: Bool { get }
|
||||||
|
|
||||||
|
func start(url: URL, outputPath: String, duration: Double) async throws
|
||||||
|
func stop()
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user