Revamp Device Profile Builder (#519)

This commit is contained in:
holow29 2024-01-13 17:42:06 -05:00 committed by GitHub
parent b038f05848
commit e2d6237a23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 178 additions and 272 deletions

View File

@ -17,11 +17,10 @@ extension BaseItemDto {
func videoPlayerViewModel(with mediaSource: MediaSourceInfo) async throws -> VideoPlayerViewModel { func videoPlayerViewModel(with mediaSource: MediaSourceInfo) async throws -> VideoPlayerViewModel {
let builder = DeviceProfileBuilder() let currentVideoPlayerType = Defaults[.VideoPlayer.videoPlayerType]
// TODO: fix bitrate settings // TODO: fix bitrate settings
let tempOverkillBitrate = 360_000_000 let tempOverkillBitrate = 360_000_000
builder.setMaxBitrate(bitrate: tempOverkillBitrate) let profile = DeviceProfileBuilder.buildProfile(for: currentVideoPlayerType, maxBitrate: tempOverkillBitrate)
let profile = builder.buildProfile()
let userSession = Container.userSession.callAsFunction() let userSession = Container.userSession.callAsFunction()

View File

@ -6,143 +6,181 @@
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors // Copyright (c) 2024 Jellyfin & Jellyfin Contributors
// //
// lol can someone buy me a coffee this took forever :|
import Defaults
import Foundation
import JellyfinAPI import JellyfinAPI
enum CPUModel { enum DeviceProfileBuilder {
case A4
case A5
case A5X
case A6
case A6X
case A7
case A7X
case A8
case A8X
case A9
case A9X
case A10
case A10X
case A11
case A12
case A12X
case A12Z
case A13
case A14
case M1
case A99
}
class DeviceProfileBuilder { static func buildProfile(for type: VideoPlayerType, maxBitrate: Int? = nil) -> DeviceProfile {
public var bitrate: Int = 0 let maxStreamingBitrate = maxBitrate
let maxStaticBitrate = maxBitrate
public func setMaxBitrate(bitrate: Int) { let musicStreamingTranscodingBitrate = maxBitrate
self.bitrate = bitrate
}
public func buildProfile() -> DeviceProfile {
let segmentContainer = "mp4"
let maxStreamingBitrate = bitrate
let maxStaticBitrate = bitrate
let musicStreamingTranscodingBitrate = bitrate
// Build direct play profiles
var directPlayProfiles: [DirectPlayProfile] = [] var directPlayProfiles: [DirectPlayProfile] = []
directPlayProfiles = var transcodingProfiles: [TranscodingProfile] = []
[DirectPlayProfile(audioCodec: "aac,mp3,wav", container: "mov,mp4,mkv,webm", type: .video, videoCodec: "h264,mpeg4,vp9")] var codecProfiles: [CodecProfile] = []
var subtitleProfiles: [SubtitleProfile] = []
// Device supports Dolby Digital (AC3, EAC3) switch type {
if supportsFeature(minimumSupported: .A8X) {
if supportsFeature(minimumSupported: .A9) {
directPlayProfiles = [DirectPlayProfile(
audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus",
container: "mov,mp4,mkv,webm",
type: .video,
videoCodec: "hevc,h264,hev1,mpeg4,vp9"
)] // HEVC/H.264 with Dolby Digital
} else {
directPlayProfiles = [DirectPlayProfile(
audioCodec: "ac3,eac3,aac,mp3,wav,opus",
container: "mov,mp4,mkv,webm",
type: .video,
videoCodec: "h264,mpeg4,vp9"
)] // H.264 with Dolby Digital
}
}
// Device supports Dolby Vision? case .swiftfin:
if supportsFeature(minimumSupported: .A10X) { func buildProfileSwiftfin() {
directPlayProfiles = [DirectPlayProfile( // Build direct play profiles
audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus", directPlayProfiles = [
container: "mov,mp4,mkv,webm", // Just make one profile because if VLCKit can't decode it in a certain container, ffmpeg probably can't decode it for
type: .video, // transcode either
videoCodec: "dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9" DirectPlayProfile(
)] // H.264/HEVC with Dolby Digital - No Atmos - Vision // No need to list containers or videocodecs since if jellyfin server can detect it/ffmpeg can decode it, so can
} // VLCKit
// However, list audiocodecs because ffmpeg can decode TrueHD/mlp but VLCKit cannot
// Device supports Dolby Atmos? audioCodec: "flac,alac,aac,eac3,ac3,dts,opus,vorbis,mp3,mp2,mp1,pcm_s24be,pcm_s24le,pcm_s16be,pcm_s16le,pcm_u8,pcm_alaw,pcm_mulaw,pcm_bluray,pcm_dvd,wavpack,wmav2,wmav1,wmapro,wmalossless,nellymoser,speex,amr_nb,amr_wb",
if supportsFeature(minimumSupported: .A12) { type: .video
directPlayProfiles = [DirectPlayProfile( ),
audioCodec: "aac,mp3,wav,ac3,eac3,flac,truehd,dts,dca,opus", ]
container: "mov,mp4,mkv,webm",
type: .video,
videoCodec: "h264,hevc,dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9"
)] // H.264/HEVC with Dolby Digital & Atmos - Vision
}
// Build transcoding profiles // Build transcoding profiles
var transcodingProfiles: [TranscodingProfile] = [] // The only cases where transcoding should occur:
transcodingProfiles = [TranscodingProfile(audioCodec: "aac,mp3,wav", container: "ts", type: .video, videoCodec: "h264,mpeg4")] // 1) TrueHD/mlp audio
// 2) When server forces transcode for bitrate reasons
// Device supports Dolby Digital (AC3, EAC3)
if supportsFeature(minimumSupported: .A8X) {
if supportsFeature(minimumSupported: .A9) {
transcodingProfiles = [TranscodingProfile( transcodingProfiles = [TranscodingProfile(
audioCodec: "aac,mp3,wav,eac3,ac3,flac,opus", audioCodec: "flac,alac,aac,eac3,ac3,dts,opus,vorbis,mp3,mp2,mp1",
// no PCM,wavpack,wmav2,wmav1,wmapro,wmalossless,nellymoser,speex,amr_nb,amr_wb in mp4
isBreakOnNonKeyFrames: true, isBreakOnNonKeyFrames: true,
container: segmentContainer, container: "mp4",
context: .streaming, context: .streaming,
maxAudioChannels: "6", maxAudioChannels: "8",
minSegments: 2, minSegments: 2,
protocol: "hls", protocol: "hls",
type: .video, type: .video,
videoCodec: "h264,hevc,mpeg4" videoCodec: "hevc,h264,av1,vp9,vc1,mpeg4,h263,mpeg2video,mpeg1video,mjpeg" // vp8,msmpeg4v3,msmpeg4v2,msmpeg4v1,theora,ffv1,flv1,wmv3,wmv2,wmv1
// not supported in mp4
)] )]
} else {
transcodingProfiles = [TranscodingProfile( // Create subtitle profiles
audioCodec: "aac,mp3,wav,eac3,ac3,opus", subtitleProfiles = [
isBreakOnNonKeyFrames: true, SubtitleProfile(format: "pgssub", method: .embed), // *pgs* normalized to pgssub; includes sup
container: segmentContainer, SubtitleProfile(format: "dvdsub", method: .embed),
context: .streaming, // *dvd* normalized to dvdsub; includes sub/idx I think; microdvd case?
maxAudioChannels: "6", SubtitleProfile(format: "subrip", method: .embed), // srt
minSegments: 2, SubtitleProfile(format: "ass", method: .embed),
protocol: "hls", SubtitleProfile(format: "ssa", method: .embed),
SubtitleProfile(format: "vtt", method: .embed), // webvtt
SubtitleProfile(format: "mov_text", method: .embed), // MPEG-4 Timed Text
SubtitleProfile(format: "ttml", method: .embed),
SubtitleProfile(format: "text", method: .embed), // txt
SubtitleProfile(format: "dvbsub", method: .embed),
// dvb_subtitle normalized to dvbsub; burned in during transcode regardless?
SubtitleProfile(format: "libzvbi_teletextdec", method: .embed), // dvb_teletext
SubtitleProfile(format: "xsub", method: .embed),
SubtitleProfile(format: "vplayer", method: .embed),
SubtitleProfile(format: "subviewer", method: .embed),
SubtitleProfile(format: "subviewer1", method: .embed),
SubtitleProfile(format: "sami", method: .embed), // SMI
SubtitleProfile(format: "realtext", method: .embed),
SubtitleProfile(format: "pjs", method: .embed), // Phoenix Subtitle
SubtitleProfile(format: "mpl2", method: .embed),
SubtitleProfile(format: "jacosub", method: .embed),
SubtitleProfile(format: "cc_dec", method: .embed), // eia_608
// Can be passed as external files; ones that jellyfin can encode to must come first
SubtitleProfile(format: "subrip", method: .external), // srt
SubtitleProfile(format: "ttml", method: .external),
SubtitleProfile(format: "vtt", method: .external), // webvtt
SubtitleProfile(format: "ass", method: .external),
SubtitleProfile(format: "ssa", method: .external),
SubtitleProfile(format: "pgssub", method: .external),
SubtitleProfile(format: "text", method: .external), // txt
SubtitleProfile(format: "dvbsub", method: .external), // dvb_subtitle normalized to dvbsub
SubtitleProfile(format: "libzvbi_teletextdec", method: .external), // dvb_teletext
SubtitleProfile(format: "dvdsub", method: .external),
// *dvd* normalized to dvdsub; includes sub/idx I think; microdvd case?
SubtitleProfile(format: "xsub", method: .external),
SubtitleProfile(format: "vplayer", method: .external),
SubtitleProfile(format: "subviewer", method: .external),
SubtitleProfile(format: "subviewer1", method: .external),
SubtitleProfile(format: "sami", method: .external), // SMI
SubtitleProfile(format: "realtext", method: .external),
SubtitleProfile(format: "pjs", method: .external), // Phoenix Subtitle
SubtitleProfile(format: "mpl2", method: .external),
SubtitleProfile(format: "jacosub", method: .external),
]
}
buildProfileSwiftfin()
case .native:
func buildProfileNative() {
// Build direct play profiles
directPlayProfiles = [
// Apple limitation: no mp3 in mp4; avi only supports mjpeg with pcm
// Right now, mp4 restrictions can't be enforced because mp4, m4v, mov, 3gp,3g2 treated the same
DirectPlayProfile(
audioCodec: "flac,alac,aac,eac3,ac3,opus",
container: "mp4",
type: .video,
videoCodec: "hevc,h264,mpeg4"
),
DirectPlayProfile(
audioCodec: "alac,aac,ac3",
container: "m4v",
type: .video, type: .video,
videoCodec: "h264,mpeg4" videoCodec: "h264,mpeg4"
)] ),
} DirectPlayProfile(
} audioCodec: "alac,aac,eac3,ac3,mp3,pcm_s24be,pcm_s24le,pcm_s16be,pcm_s16le",
container: "mov",
type: .video,
videoCodec: "hevc,h264,mpeg4,mjpeg"
),
DirectPlayProfile(
audioCodec: "aac,eac3,ac3,mp3",
container: "mpegts",
type: .video,
videoCodec: "h264"
),
DirectPlayProfile(
audioCodec: "aac,amr_nb",
container: "3gp,3g2",
type: .video,
videoCodec: "h264,mpeg4"
),
DirectPlayProfile(
audioCodec: "pcm_s16le,pcm_mulaw",
container: "avi",
type: .video,
videoCodec: "mjpeg"
),
]
// Device supports FLAC? // Build transcoding profiles
if supportsFeature(minimumSupported: .A10X) { transcodingProfiles = [
transcodingProfiles = [TranscodingProfile( TranscodingProfile(
audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus", audioCodec: "flac,alac,aac,eac3,ac3,opus",
isBreakOnNonKeyFrames: true, isBreakOnNonKeyFrames: true,
container: segmentContainer, container: "mp4",
context: .streaming, context: .streaming,
maxAudioChannels: "6", maxAudioChannels: "8",
minSegments: 2, minSegments: 2,
protocol: "hls", protocol: "hls",
type: .video, type: .video,
videoCodec: "hevc,h264,mpeg4" videoCodec: "hevc,h264,mpeg4"
)] ),
]
// Create subtitle profiles
subtitleProfiles = [
// FFmpeg can only convert bitmap to bitmap and text to text; burn in bitmap subs
SubtitleProfile(format: "pgssub", method: .encode),
SubtitleProfile(format: "dvdsub", method: .encode),
SubtitleProfile(format: "dvbsub", method: .encode),
SubtitleProfile(format: "xsub", method: .encode),
// According to Apple HLS authoring specs, WebVTT must be in a text file delivered via HLS
SubtitleProfile(format: "vtt", method: .hls), // webvtt
// Apple HLS authoring spec has closed captions in video segments and TTML in fmp4
SubtitleProfile(format: "ttml", method: .embed),
SubtitleProfile(format: "cc_dec", method: .embed),
]
}
buildProfileNative()
} }
var codecProfiles: [CodecProfile] = [] // For now, assume native and VLCKit support same codec conditions:
let h264CodecConditions: [ProfileCondition] = [ let h264CodecConditions: [ProfileCondition] = [
ProfileCondition( ProfileCondition(
condition: .notEquals, condition: .notEquals,
@ -169,6 +207,9 @@ class DeviceProfileBuilder {
value: "true" value: "true"
), ),
] ]
codecProfiles.append(CodecProfile(applyConditions: h264CodecConditions, codec: "h264", type: .video))
let hevcCodecConditions: [ProfileCondition] = [ let hevcCodecConditions: [ProfileCondition] = [
ProfileCondition( ProfileCondition(
condition: .notEquals, condition: .notEquals,
@ -196,28 +237,7 @@ class DeviceProfileBuilder {
), ),
] ]
codecProfiles.append(CodecProfile(applyConditions: h264CodecConditions, codec: "h264", type: .video))
if supportsFeature(minimumSupported: .A9) {
codecProfiles.append(CodecProfile(applyConditions: hevcCodecConditions, codec: "hevc", type: .video)) codecProfiles.append(CodecProfile(applyConditions: hevcCodecConditions, codec: "hevc", type: .video))
}
var subtitleProfiles: [SubtitleProfile] = []
subtitleProfiles.append(SubtitleProfile(format: "ass", method: .embed))
subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .embed))
subtitleProfiles.append(SubtitleProfile(format: "subrip", method: .embed))
subtitleProfiles.append(SubtitleProfile(format: "sub", method: .embed))
subtitleProfiles.append(SubtitleProfile(format: "pgssub", method: .embed))
// These need to be filtered. Most subrips are embedded. I hate subtitles.
subtitleProfiles.append(SubtitleProfile(format: "subrip", method: .external))
subtitleProfiles.append(SubtitleProfile(format: "sub", method: .external))
subtitleProfiles.append(SubtitleProfile(format: "ass", method: .external))
subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .external))
subtitleProfiles.append(SubtitleProfile(format: "vtt", method: .external))
subtitleProfiles.append(SubtitleProfile(format: "ass", method: .external))
subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .external))
let responseProfiles: [ResponseProfile] = [ResponseProfile(container: "m4v", mimeType: "video/mp4", type: .video)] let responseProfiles: [ResponseProfile] = [ResponseProfile(container: "m4v", mimeType: "video/mp4", type: .video)]
@ -233,102 +253,4 @@ class DeviceProfileBuilder {
transcodingProfiles: transcodingProfiles transcodingProfiles: transcodingProfiles
) )
} }
private func supportsFeature(minimumSupported: CPUModel) -> Bool {
let intValues: [CPUModel: Int] = [
.A4: 1,
.A5: 2,
.A5X: 3,
.A6: 4,
.A6X: 5,
.A7: 6,
.A7X: 7,
.A8: 8,
.A8X: 9,
.A9: 10,
.A9X: 11,
.A10: 12,
.A10X: 13,
.A11: 14,
.A12: 15,
.A12X: 16,
.A12Z: 16,
.A13: 17,
.A14: 18,
.M1: 19,
.A99: 99,
]
return intValues[CPUinfo()] ?? 0 >= intValues[minimumSupported] ?? 0
}
/**********************************************
* CPUInfo():
* Returns a hardcoded value of the current
* devices CPU name.
***********************************************/
private func CPUinfo() -> CPUModel {
#if targetEnvironment(simulator)
let identifier = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"]!
#else
var systemInfo = utsname()
uname(&systemInfo)
let machineMirror = Mirror(reflecting: systemInfo.machine)
let identifier = machineMirror.children.reduce("") { identifier, element in
guard let subtitle = element.value as? Int8, subtitle != 0 else { return identifier }
return identifier + String(UnicodeScalar(UInt8(subtitle)))
}
#endif
switch identifier {
case "iPod5,1": return .A5
case "iPod7,1": return .A8
case "iPod9,1": return .A10
case "iPhone3,1", "iPhone3,2", "iPhone3,3": return .A4
case "iPhone4,1": return .A5
case "iPhone5,1", "iPhone5,2": return .A6
case "iPhone5,3", "iPhone5,4": return .A6
case "iPhone6,1", "iPhone6,2": return .A7
case "iPhone7,2": return .A8
case "iPhone7,1": return .A8
case "iPhone8,1": return .A9
case "iPhone8,2", "iPhone8,4": return .A9
case "iPhone9,1", "iPhone9,3": return .A10
case "iPhone9,2", "iPhone9,4": return .A10
case "iPhone10,1", "iPhone10,4": return .A11
case "iPhone10,2", "iPhone10,5": return .A11
case "iPhone10,3", "iPhone10,6": return .A11
case "iPhone11,2", "iPhone11,6", "iPhone11,8": return .A12
case "iPhone12,1", "iPhone12,3", "iPhone12,5", "iPhone12,8": return .A13
case "iPhone13,1", "iPhone13,2", "iPhone13,3", "iPhone13,4": return .A14
case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4": return .A5
case "iPad3,1", "iPad3,2", "iPad3,3": return .A5X
case "iPad3,4", "iPad3,5", "iPad3,6": return .A6X
case "iPad4,1", "iPad4,2", "iPad4,3": return .A7
case "iPad5,3", "iPad5,4": return .A8X
case "iPad6,11", "iPad6,12": return .A9
case "iPad2,5", "iPad2,6", "iPad2,7": return .A5
case "iPad4,4", "iPad4,5", "iPad4,6": return .A7
case "iPad4,7", "iPad4,8", "iPad4,9": return .A7
case "iPad5,1", "iPad5,2": return .A8
case "iPad11,1", "iPad11,2": return .A12
case "iPad6,3", "iPad6,4": return .A9X
case "iPad6,7", "iPad6,8": return .A9X
case "iPad7,1", "iPad7,2": return .A10X
case "iPad7,3", "iPad7,4": return .A10X
case "iPad7,5", "iPad7,6", "iPad7,11", "iPad7,12": return .A10
case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4": return .A12X
case "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8": return .A12X
case "iPad8,9", "iPad8,10", "iPad8,11", "iPad8,12": return .A12Z
case "iPad11,3", "iPad11,4", "iPad11,6", "iPad11,7": return .A12
case "iPad13,1", "iPad13,2": return .A14
case "iPad13,8", "iPad13,9", "iPad13,10", "iPad13,11": return .M1
case "AppleTV5,3": return .A8
case "AppleTV6,2": return .A10X
case "AppleTV11,1": return .A12
case "AudioAccessory1,1": return .A8
default: return .A99
}
}
} }

View File

@ -150,11 +150,6 @@ extension Defaults.Keys {
) )
} }
enum Native {
static let fMP4Container: Key<Bool> = .init("fmp4Container", default: false, suite: .generalSuite)
}
enum Overlay { enum Overlay {
static let chapterSlider: Key<Bool> = .init("chapterSlider", default: true, suite: .generalSuite) static let chapterSlider: Key<Bool> = .init("chapterSlider", default: true, suite: .generalSuite)

View File

@ -30,14 +30,13 @@ class VideoPlayerViewModel: ViewModel {
var hlsPlaybackURL: URL { var hlsPlaybackURL: URL {
let segmentContainer = Defaults[.VideoPlayer.Native.fMP4Container] ? "mp4" : "ts"
let userSession = Container.userSession.callAsFunction() let userSession = Container.userSession.callAsFunction()
let parameters = Paths.GetMasterHlsVideoPlaylistParameters( let parameters = Paths.GetMasterHlsVideoPlaylistParameters(
isStatic: true, isStatic: true,
tag: mediaSource.eTag, tag: mediaSource.eTag,
playSessionID: playSessionID, playSessionID: playSessionID,
segmentContainer: segmentContainer, segmentContainer: "mp4",
minSegments: 2, minSegments: 2,
mediaSourceID: mediaSource.id!, mediaSourceID: mediaSource.id!,
deviceID: UIDevice.vendorUUIDString, deviceID: UIDevice.vendorUUIDString,
@ -46,7 +45,7 @@ class VideoPlayerViewModel: ViewModel {
.joined(separator: ","), .joined(separator: ","),
isBreakOnNonKeyFrames: true, isBreakOnNonKeyFrames: true,
requireAvc: false, requireAvc: false,
transcodingMaxAudioChannels: 6, transcodingMaxAudioChannels: 8,
videoCodec: videoStreams videoCodec: videoStreams
.compactMap(\.codec) .compactMap(\.codec)
.joined(separator: ","), .joined(separator: ","),

View File

@ -70,7 +70,7 @@ class UINativeVideoPlayerViewController: AVPlayerViewController {
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
let newPlayer: AVPlayer = .init(url: manager.currentViewModel.hlsPlaybackURL) let newPlayer: AVPlayer = .init(url: manager.currentViewModel.playbackURL)
newPlayer.allowsExternalPlayback = true newPlayer.allowsExternalPlayback = true
newPlayer.appliesMediaSelectionCriteriaAutomatically = false newPlayer.appliesMediaSelectionCriteriaAutomatically = false

View File

@ -11,8 +11,6 @@ import SwiftUI
struct NativeVideoPlayerSettingsView: View { struct NativeVideoPlayerSettingsView: View {
@Default(.VideoPlayer.Native.fMP4Container)
private var fMP4Container
@Default(.VideoPlayer.resumeOffset) @Default(.VideoPlayer.resumeOffset)
private var resumeOffset private var resumeOffset
@ -33,13 +31,6 @@ struct NativeVideoPlayerSettingsView: View {
} footer: { } footer: {
Text("Resume content seconds before the recorded resume time") Text("Resume content seconds before the recorded resume time")
} }
Section {
Toggle("fMP4 Container", isOn: $fMP4Container)
} footer: {
Text("Use fMP4 container to allow hevc content on supported devices")
}
} }
.navigationTitle("Native Player") .navigationTitle("Native Player")
} }

View File

@ -70,7 +70,7 @@ class UINativeVideoPlayerViewController: AVPlayerViewController {
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
let newPlayer: AVPlayer = .init(url: manager.currentViewModel.hlsPlaybackURL) let newPlayer: AVPlayer = .init(url: manager.currentViewModel.playbackURL)
newPlayer.allowsExternalPlayback = true newPlayer.allowsExternalPlayback = true
newPlayer.appliesMediaSelectionCriteriaAutomatically = false newPlayer.appliesMediaSelectionCriteriaAutomatically = false