yumoqing 68806e1190 fix: use fragmented MP4 for crash-safe HLS live stream recording
-movflags +faststart requires ffmpeg to write moov atom at the end,
which fails when SIGINT interrupts a live HLS recording — the mp4
file has no moov atom and is unplayable (0 bytes effective).

Switch to -movflags +frag_keyframe+empty_moov+default_base_moof
(fragmented MP4): moov atom written immediately at file start,
each segment is self-contained. Even if ffmpeg is killed mid-recording,
the file remains fully playable with both video and audio intact.

Verified: HLS live stream (CBS News) → 45s 1280x720 h264 + AAC 48kHz,
13MB, SIGINT-safe. Local files unaffected.
2026-06-29 23:58:31 +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", "+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)")
}
//
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