yumoqing 71d321b056 refactor: 跨平台架构 (macOS+iOS)
- StreamRecorderKit: 跨平台库
  - StreamRecorderEngine: 协议+类型
  - HLSVariantResolver: HLS master 解析
  - FFmpegRecorder: macOS (Process+ffmpeg)
  - AVFoundationRecorder: iOS (AVAssetReader+Writer)
  - RecorderFactory: 平台工厂
- StreamRecorder: CLI 入口
- 15秒录制测试通过
2026-06-28 17:49:12 +08:00

297 lines
10 KiB
Swift

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