From 71d321b05639654d7ccd2bf8eb8fc027c992e656 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Sun, 28 Jun 2026 17:46:37 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E8=B7=A8=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=20(macOS+iOS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StreamRecorderKit: 跨平台库 - StreamRecorderEngine: 协议+类型 - HLSVariantResolver: HLS master 解析 - FFmpegRecorder: macOS (Process+ffmpeg) - AVFoundationRecorder: iOS (AVAssetReader+Writer) - RecorderFactory: 平台工厂 - StreamRecorder: CLI 入口 - 15秒录制测试通过 --- .gitignore | 2 + Package.swift | 21 ++ Sources/StreamRecorder/StreamRecorder.swift | 198 ++++++++++++ .../AVFoundationRecorder.swift | 303 ++++++++++++++++++ .../StreamRecorderKit/FFmpegRecorder.swift | 296 +++++++++++++++++ .../HLSVariantResolver.swift | 86 +++++ .../StreamRecorderKit/RecorderFactory.swift | 28 ++ .../StreamRecorderEngine.swift | 83 +++++ 8 files changed, 1017 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 Sources/StreamRecorder/StreamRecorder.swift create mode 100644 Sources/StreamRecorderKit/AVFoundationRecorder.swift create mode 100644 Sources/StreamRecorderKit/FFmpegRecorder.swift create mode 100644 Sources/StreamRecorderKit/HLSVariantResolver.swift create mode 100644 Sources/StreamRecorderKit/RecorderFactory.swift create mode 100644 Sources/StreamRecorderKit/StreamRecorderEngine.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7af4797 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.build/ +*.mp4 diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..a109831 --- /dev/null +++ b/Package.swift @@ -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: [] + ) + ] +) diff --git a/Sources/StreamRecorder/StreamRecorder.swift b/Sources/StreamRecorder/StreamRecorder.swift new file mode 100644 index 0000000..729119a --- /dev/null +++ b/Sources/StreamRecorder/StreamRecorder.swift @@ -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) + } +} diff --git a/Sources/StreamRecorderKit/AVFoundationRecorder.swift b/Sources/StreamRecorderKit/AVFoundationRecorder.swift new file mode 100644 index 0000000..cabf5bd --- /dev/null +++ b/Sources/StreamRecorderKit/AVFoundationRecorder.swift @@ -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 diff --git a/Sources/StreamRecorderKit/FFmpegRecorder.swift b/Sources/StreamRecorderKit/FFmpegRecorder.swift new file mode 100644 index 0000000..0898b64 --- /dev/null +++ b/Sources/StreamRecorderKit/FFmpegRecorder.swift @@ -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[.. 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[.. 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 + } +} diff --git a/Sources/StreamRecorderKit/RecorderFactory.swift b/Sources/StreamRecorderKit/RecorderFactory.swift new file mode 100644 index 0000000..0fb9e72 --- /dev/null +++ b/Sources/StreamRecorderKit/RecorderFactory.swift @@ -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 + } +} diff --git a/Sources/StreamRecorderKit/StreamRecorderEngine.swift b/Sources/StreamRecorderKit/StreamRecorderEngine.swift new file mode 100644 index 0000000..45075fb --- /dev/null +++ b/Sources/StreamRecorderKit/StreamRecorderEngine.swift @@ -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() +}