yumoqing 7263f2d837 fix: re-encode audio to AAC instead of copy for mp4 compatibility
-c copy may produce mp4 files with AC3/DTS/FLAC audio that macOS
AVPlayer cannot decode, resulting in silent playback.
Change to -c:v copy -c:a aac -b:a 192k to preserve video quality
while ensuring universally playable audio.
2026-06-29 23:51:19 +08:00

310 lines
10 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
}
}
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", "+faststart",
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)")
}
//
proc.waitUntilExit()
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
}
//
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() {
stopRequested = true
// SIGINT (Ctrl+C) ffmpeg
if let pid = process?.processIdentifier {
kill(pid, SIGINT)
}
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