377 lines
13 KiB
Swift
377 lines
13 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
|
||
}
|
||
}
|
||
|
||
/// Thread-safe atomic boolean for one-shot guards
|
||
final class AtomicBool: @unchecked Sendable {
|
||
private var value = false
|
||
private let lock = NSLock()
|
||
|
||
/// Returns true if this call set it from false to true (first caller wins)
|
||
func setTrue() -> Bool {
|
||
lock.lock()
|
||
defer { lock.unlock() }
|
||
guard !value else { return false }
|
||
value = true
|
||
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
|
||
private var stopRequested = false
|
||
private var stderrAccumulator = ""
|
||
|
||
public init() {}
|
||
|
||
public func start(url: URL, outputPath: String, duration: Double) async throws {
|
||
guard !isRecording else { return }
|
||
|
||
self.duration = duration
|
||
self.stderrAccumulator = ""
|
||
|
||
// 查找 ffmpeg
|
||
let ffmpegPath = findFFmpeg()
|
||
guard FileManager.default.fileExists(atPath: ffmpegPath) else {
|
||
throw RecordingError.noFFmpeg
|
||
}
|
||
|
||
// 解析 HLS 变体
|
||
let resolvedURL = HLSVariantResolver.resolve(url: url, maxWidth: 1280) ?? url
|
||
NSLog("[Recorder] ffmpeg input URL: %@", resolvedURL.absoluteString)
|
||
|
||
// 删除已存在的输出文件
|
||
if FileManager.default.fileExists(atPath: outputPath) {
|
||
try? FileManager.default.removeItem(atPath: outputPath)
|
||
}
|
||
|
||
// 启动 ffmpeg 进程
|
||
let proc = Process()
|
||
proc.executableURL = URL(fileURLWithPath: ffmpegPath)
|
||
var args = [
|
||
"-y",
|
||
"-i", resolvedURL.absoluteString,
|
||
]
|
||
// duration=0 表示无限录制,不传 -t 参数
|
||
if duration > 0 {
|
||
args += ["-t", String(duration)]
|
||
}
|
||
args += [
|
||
"-c:v", "copy",
|
||
"-c:a", "aac",
|
||
"-b:a", "192k",
|
||
"-movflags", "+frag_keyframe+empty_moov+default_base_moof",
|
||
outputPath
|
||
]
|
||
proc.arguments = args
|
||
NSLog("[Recorder] ffmpeg args: %@", args.joined(separator: " "))
|
||
|
||
let stderrPipe = Pipe()
|
||
proc.standardError = stderrPipe
|
||
proc.standardOutput = FileHandle.nullDevice
|
||
|
||
// 解析进度 + 收集 stderr
|
||
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) ?? ""
|
||
self.stderrAccumulator += str
|
||
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)")
|
||
}
|
||
|
||
// 异步等待进程完成(不阻塞 cooperative thread)
|
||
await waitForProcess(proc)
|
||
NSLog("[Recorder] ffmpeg process exited, status: %d", proc.terminationStatus)
|
||
stderrPipe.fileHandleForReading.readabilityHandler = nil
|
||
isRecording = false
|
||
|
||
guard proc.terminationStatus == 0 || stopRequested else {
|
||
let lastStderr = String(stderrAccumulator.suffix(2000))
|
||
NSLog("[Recorder] ffmpeg stderr (last 2000 chars):\n%@", lastStderr)
|
||
throw RecordingError.processFailed(Int(proc.terminationStatus), lastStderr)
|
||
}
|
||
|
||
guard FileManager.default.fileExists(atPath: outputPath) else {
|
||
let lastStderr = String(stderrAccumulator.suffix(2000))
|
||
NSLog("[Recorder] ffmpeg stderr (last 2000 chars):\n%@", lastStderr)
|
||
throw RecordingError.outputNotFound(lastStderr)
|
||
}
|
||
|
||
// 如果是手动停止,跳过耗时的分析(fragmented MP4 可能导致 ffprobe 卡住)
|
||
if stopRequested {
|
||
NSLog("[Recorder] Stop requested, skipping analysis")
|
||
let attrs = try? FileManager.default.attributesOfItem(atPath: outputPath)
|
||
let fileSize = attrs?[.size] as? Int64 ?? 0
|
||
let result = RecordingResult(
|
||
outputPath: outputPath,
|
||
duration: duration,
|
||
fileSize: fileSize,
|
||
hasVideo: true,
|
||
hasAudio: true,
|
||
videoInfo: nil,
|
||
audioInfo: nil,
|
||
audioLevel: nil
|
||
)
|
||
delegate?.recorder(self, didFinishWithResult: result)
|
||
return
|
||
}
|
||
|
||
NSLog("[Recorder] Analyzing output file...")
|
||
// 分析输出文件
|
||
let ffprobePath = ffmpegPath.replacingOccurrences(of: "ffmpeg", with: "ffprobe")
|
||
let result = try await analyzeOutput(path: outputPath, ffprobePath: ffprobePath)
|
||
NSLog("[Recorder] Analysis complete, notifying delegate")
|
||
delegate?.recorder(self, didFinishWithResult: result)
|
||
}
|
||
|
||
public func stop() {
|
||
stopRequested = true
|
||
// 发送 SIGINT (Ctrl+C) 让 ffmpeg 优雅关闭并写出文件头
|
||
if let pid = process?.processIdentifier {
|
||
kill(pid, SIGINT)
|
||
}
|
||
isRecording = false
|
||
}
|
||
|
||
// MARK: - Private
|
||
|
||
/// Async-friendly process wait — uses terminationHandler + continuation
|
||
/// instead of blocking waitUntilExit() which deadlocks in Task context
|
||
private func waitForProcess(_ proc: Process) async {
|
||
guard proc.isRunning else { return }
|
||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||
let resumed = AtomicBool()
|
||
proc.terminationHandler = { _ in
|
||
if resumed.setTrue() {
|
||
continuation.resume()
|
||
}
|
||
}
|
||
// Race condition: process may have exited between isRunning check and setting handler
|
||
if !proc.isRunning {
|
||
proc.terminationHandler = nil
|
||
if resumed.setTrue() {
|
||
continuation.resume()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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()
|
||
await waitForProcess(proc)
|
||
} 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()
|
||
await waitForProcess(proc)
|
||
} 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
|