Files
HaishinKit.swift/HaishinKit/Sources/Codec/VideoCodec.swift
Tabber (인병윤) b119b56969 Add framerate to RTMP onMetaData (#1832)
* feat: Add framerate to RTMP onMetaData

Adds framerate field to RTMP onMetaData for RTMP servers like
nginx-rtmp-module to correctly parse stream frame rate statistics.

The framerate value is synchronized from VideoCodec.expectedFrameRate
when MediaMixer.setFrameRate() is called.

* feat: Move expectedFrameRate to VideoCodecSettings and add framerate to RTMP metadata

- Move expectedFrameRate from VideoCodec to VideoCodecSettings
- Remove setExpectedFrameRate() method from OutgoingStream and StreamConvertible
- Add framerate field to RTMP onMetaData message
- Synchronize expectedFrameRate in MediaMixer.setFrameRate() and addOutput()
- Add helper methods for frame rate synchronization
- Make MediaMixer.defaultFrameRate public

* feat: Rename expectedFrameRate to defaultFrameRate and add framerate to RTMP metadata

- Rename VideoCodecSettings.expectedFrameRate to defaultFrameRate to represent
  a different concept from MediaMixer.frameRate
- Remove synchronization code from MediaMixer.setFrameRate() and addOutput()
  to allow independent frame rate management per stream
- Remove VideoCodec.expectedFrameRate and OutgoingStream.expectedFrameRate properties
- Add framerate field to RTMP onMetaData message for RTMP servers like
  nginx-rtmp-module to correctly parse stream frame rate statistics
- Make MediaMixer.defaultFrameRate public

This change allows each stream to have its own independent defaultFrameRate
(e.g., Service A at 30 FPS, Service B at 60 FPS) while MediaMixer can output
at a different frame rate. The defaultFrameRate is used both for VideoToolbox
encoder configuration and RTMP metadata.

Breaking changes:
- VideoCodecSettings.expectedFrameRate renamed to defaultFrameRate
- MediaMixer.addOutput() no longer synchronizes frame rate (streams must
  explicitly set defaultFrameRate via VideoCodecSettings init or setVideoSettings())
2025-11-21 23:17:23 +09:00

175 lines
5.7 KiB
Swift

import AVFoundation
import CoreFoundation
import VideoToolbox
#if canImport(UIKit)
import UIKit
#endif
final class VideoCodec {
static let frameInterval: Double = 0.0
var settings: VideoCodecSettings = .default {
didSet {
let invalidateSession = settings.invalidateSession(oldValue)
if invalidateSession {
self.invalidateSession = invalidateSession
} else {
settings.apply(self, rhs: oldValue)
}
}
}
var passthrough = true
var outputStream: AsyncStream<CMSampleBuffer> {
AsyncStream { continuation in
self.continuation = continuation
}
}
var frameInterval = VideoCodec.frameInterval
private var startedAt: CMTime = .zero
private var continuation: AsyncStream<CMSampleBuffer>.Continuation?
private var invalidateSession = true
private var presentationTimeStamp: CMTime = .zero
private(set) var isRunning = false
private(set) var inputFormat: CMFormatDescription? {
didSet {
guard inputFormat != oldValue else {
return
}
invalidateSession = true
outputFormat = nil
}
}
private(set) var session: (any VTSessionConvertible)? {
didSet {
oldValue?.invalidate()
invalidateSession = false
}
}
private(set) var outputFormat: CMFormatDescription?
func append(_ sampleBuffer: CMSampleBuffer) {
guard isRunning else {
return
}
do {
inputFormat = sampleBuffer.formatDescription
if invalidateSession {
if sampleBuffer.formatDescription?.isCompressed == true {
session = try VTSessionMode.decompression.makeSession(self)
} else {
session = try VTSessionMode.compression.makeSession(self)
}
}
guard let session, let continuation else {
return
}
if sampleBuffer.formatDescription?.isCompressed == true {
try session.convert(sampleBuffer, continuation: continuation)
} else {
if useFrame(sampleBuffer.presentationTimeStamp) {
try session.convert(sampleBuffer, continuation: continuation)
presentationTimeStamp = sampleBuffer.presentationTimeStamp
}
}
} catch {
logger.warn(error)
}
}
func makeImageBufferAttributes(_ mode: VTSessionMode) -> [NSString: AnyObject]? {
switch mode {
case .compression:
var attributes: [NSString: AnyObject] = [:]
if let inputFormat {
// Specify the pixel format of the uncompressed video.
attributes[kCVPixelBufferPixelFormatTypeKey] = inputFormat.mediaType.rawValue as CFNumber
}
return attributes.isEmpty ? nil : attributes
case .decompression:
return [
kCVPixelBufferIOSurfacePropertiesKey: NSDictionary(),
kCVPixelBufferMetalCompatibilityKey: kCFBooleanTrue
]
}
}
private func useFrame(_ presentationTimeStamp: CMTime) -> Bool {
guard startedAt <= presentationTimeStamp else {
return false
}
guard self.presentationTimeStamp < presentationTimeStamp else {
return false
}
guard Self.frameInterval < frameInterval else {
return true
}
return frameInterval <= presentationTimeStamp.seconds - self.presentationTimeStamp.seconds
}
#if os(iOS) || os(tvOS) || os(visionOS)
@objc
private func applicationWillEnterForeground(_ notification: Notification) {
invalidateSession = true
}
@objc
private func didAudioSessionInterruption(_ notification: Notification) {
guard
let userInfo: [AnyHashable: Any] = notification.userInfo,
let value: NSNumber = userInfo[AVAudioSessionInterruptionTypeKey] as? NSNumber,
let type = AVAudioSession.InterruptionType(rawValue: value.uintValue) else {
return
}
switch type {
case .ended:
invalidateSession = true
default:
break
}
}
#endif
}
extension VideoCodec: Runner {
// MARK: Running
func startRunning() {
guard !isRunning else {
return
}
#if os(iOS) || os(tvOS) || os(visionOS)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.didAudioSessionInterruption),
name: AVAudioSession.interruptionNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.applicationWillEnterForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
#endif
startedAt = passthrough ? .zero : CMClockGetTime(CMClockGetHostTimeClock())
isRunning = true
}
func stopRunning() {
guard isRunning else {
return
}
isRunning = false
session = nil
invalidateSession = true
inputFormat = nil
outputFormat = nil
presentationTimeStamp = .zero
continuation?.finish()
startedAt = .zero
#if os(iOS) || os(tvOS) || os(visionOS)
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
#endif
}
}