349 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
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)
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
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)")
}
// 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 {
throw RecordingError.processFailed(Int(proc.terminationStatus))
}
guard FileManager.default.fileExists(atPath: outputPath) else {
throw RecordingError.outputNotFound
}
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