VisionOS 音视频播放
提供空间体验的视频内容
基于HLS交付2D视频内容
视频
在视频方面,对源视频进行编码,将其辑成合适的长度,并调整比特率进行颜色校正。在这里,用户可选择如何配置和使用HEVC(高效视频编码的简称)等视频编码器。该平台支持高达4K分辨率的内容播放,显示器的刷新率是90Hz,而对于每秒24fps的视频,可以自动使用特殊的96Hz模式。支持标准和高动态范围。
音频
在视频的对应音频方面,确定并制作所需的源音频流的数量(数量取决于主要角色对话和其他音频)。在考虑到HLS传输的情况下将这些音源进行编码。(HLS开发者页面有关于准备音频的文档链接。)
字幕
字幕包括字幕和隐藏式字幕,以涵盖不同的语言和角色。这里的字幕是指视频中用于提供不同语言翻译的口语文本转录,或用于确定设置的时间和地点。隐藏式字幕类似于一般字幕,它不仅能提供对话转录还能提示声音效果和相关音频的细节描述,便于听障人士查看,即SDH(Subtitles for Deaf and Hard-of-hearing)人群。
与视频和音频编码类似,用户可以制作HLS支持的字幕文件和格式,最常见的是WebVTT。
包装/打包有了源视频、音频和字幕在手,接下来就是打包,这是将源媒体文件转化为各种类型的片段以进行可靠传输的过程。可以用苹果公司的HLS工具来完成,可在早期的HLS流媒体页面上找到,也可以使用其他内容平台的工具。
在2D的视频内容基础上交付3D视频内容
通过上图可以看到,3D的内容交互是基于当前2D内容传输流程基础之上的,HLS为碎片化的MP4定时元数据(metadata)增加了新的支持。
现在来重点关注2D内容和3D立体内容的差异。首先了解一下3D视频,3D视频是一种通过在时间轴上排列一系列帧序列来呈现三维场景的视频形式。每个帧都包含了在不同视角或位置捕捉到的图像,以产生具有深度感的视觉效果。这些帧序列可以通过不同的方法进行捕捉,例如使用双目摄像机、深度传感器或计算机生成图形。
3D视频中的“3D”可与“立体”互换使用,这里的立体视觉可为左眼提供一张图像,为右眼提供另一个非常相似的图像,视角略有不同。左右图像之间的这些差异,称为视差,使用户在观看这类视频时能感受到三维的深度。
接下来视频介绍了3D视频帧制作的一些参考方法。通过对所有立体帧使用单个视频轨道,保留了传统的2D视频轨道的制作。左边和右边的图像或视图,对于任何显示时间都是在一个单一的压缩帧。理想情况下,获得苹果芯片支持后,应该可以被非3D感知播放所解码,允许在2D工作流程中预览视频。为了提供立体帧,我们引入了多视图HEVC(也称为MV-HEVC),它是 HEVC 的扩展。“MV”是多视图的。每帧携带多个视图,每帧都有一对压缩的左图像和右图像。
在MV-HEVC中,将基本的HEVC 2D视图存储在每个压缩的帧中。编码确定左右图像之间的差异或增量。这种技术被称为2D Plus Delta,意味着2D解码器可以找到并使用基础2D视图,例如左眼 。但 3D 解码器可以计算出另一个视图,将两个视图呈现给对应的眼睛。
由于视差的不同,视频场景中的一个物体可能被认为比另一个更近或更远。可以定义立体深度内容的三个主要区域:
- 一是没有视差的屏幕平面
- 二是负视差,将导致物体在屏幕平面前被感知
- 三是正视差,将导致物体在屏幕平面后被感知
对于3D字幕,官方的说法是不要求使用新的字幕格式或改变现有的格式,而是提供一种方法来描述每个视频帧的视差,有些区域显然离观看者较近,有些区域则离观看者较远。他们将其称为“视差轮廓(parallax contour)”并将其作为元数据记录在元数据轨道中,与视频轨道的帧同步。
通过上面的介绍可以看到:HLS 交付的工作原理与 3D 资产相同。交付 3D 资产与交付 2D 资产基本相同,但您可以采取一些措施来优化体验。准备源资源。将 MV-HEVC 用于3D 视频,并包括新的视差轮廓元数据。而对于音频和字幕可以在 2D 和 3D 中使用相同的流。使用更新的包装来生成相关片段和播放列表。资产保持不变
关于如何创建MV-HEVC格式的视频文件或者如何拍摄这种格式的视频,以上session并未提到,可以关注官方论坛。
打造出色的空间播放体验
在VisionOS 中,AVFoundation 得到了增强,可以支持利用其独特功能的新媒体格式,例如 3D 视频。同时可以使用 RealityKit 进行高性能和高质量渲染,从而可以无缝合成视频融入您周围的世界,这样音频也会对您周围的世界做出反应。AVKit 的 AVPlayerViewController已得到扩展,可以利用 RealityKit 的强大功能和平台的独特功能来创建高度精致的体验。这包括您期望的所有播放控件,而且还具有许多独特的功能。接下来我将使用demo为大家一一展示这个session里面的具体内容
在VisionOS中使用 AVPlayerViewController来进行视频的播放,就像在 iOS 或 tvOS 上使用一样
在内嵌播放器中播放视频
内联播放器就是将AVPlayerViewController
放在另一个视图的内部时,会显示内联控件,例如暂停、跳过和查找。在应用程序中显示标准播放控件可提供熟悉的 UI,该 UI 可自动调整其外观以适应每个平台,
这里使用系统的AVPlayerViewController
简单实现了一个内联播放,其中左边是预览播放,右边是该视频的相关信息。
现在打开VisionOS的模拟器,在Demo应用程序启动之前,只有房间可见。当应用程序启动时,一个大屏幕出现在前面,房间变暗,营造出良好的氛围。移动时,屏幕保持在原位,并且音频保持固定在屏幕上。要显示播放控件,请查看屏幕并点击。控件浮动在视频前面。可以通过看着屏幕并再次点击来使它们消失。
我们也可以抓住屏幕下方的窗口栏以重新定位它。抓住屏幕的一角来调整大小。请注意,随着屏幕大小的调整,它会平滑地呈现动画并与视频的宽高比相匹配。通过旋转数码表冠调节音量。或者使用 Digital Crown 打开一个环境(在模拟器上可以点击右上角的scene)。你必须亲自体验一下。现在,让我们更仔细地检查播放控件以了解它们提供的功能。首先是播放器界面。右上角是音量控制,可进行快速调节或静音。左下角是熟悉的播放/暂停和后退/前进按钮。底部中间是跳转到电影中不同时间的洗涤器。右下角是这个按钮,有更多选项。以下是调整播放速度的选项。当电影包含多个音轨或字幕轨道时,请使用这些选项来选择语言用于音轨,或启用首选语言的字幕。最后一个选项是调光效果。我喜欢在黑暗中看电影,以便真正集中注意力,这些都是内置的功能。当在内嵌视图中播放时还有出现全屏展开的按钮,点击后默认全屏播放。
AVPlayerViewController
在内嵌时仅支持显示 2D 内容。需要全屏显示播放器才能播放 3D 视频。
在全屏播放器中播放视频
通过将播放器设置为应用程序的根视图或使用fullScreenCover
来呈现播放器,以全屏模式呈现播放器。在全屏模式下,播放器呈现出更加内容前向的设计,默认会调暗环境以提供更合适的观看效果。
import Foundation
import SwiftUI
import AVFoundation
import AVKit
struct TestPlayView: UIViewControllerRepresentable {
private var player: AVPlayer {
return AVPlayer(url: Bundle.main.url(forResource: "test1", withExtension: "mp4")!)
}
func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) {
playerController.modalPresentationStyle = .fullScreen
playerController.player = player
playerController.player?.play()
}
func makeUIViewController(context: Context) -> AVPlayerViewController {
return AVPlayerViewController()
}
}
接着只需要在SwiftUI中展示该控制器即可。
struct ContentView: View {
var body: some View {
PlayerView()
}
}
配置空间音频体验
我们的APP除了需要配置应用程序进行媒体播放中的步骤之外,还可以针对全屏播放,优化一下听觉上的效果,配置一下空间音频。它是区别于立体音频的。
func changePresentation(_ presentation: Presentation) {
self.presentation = presentation
do {
let experience: AVAudioSessionSpatialExperience
switch presentation {
case .inline:
// 对于内联播放器视图,它将体验设置为一个小型的前置声场,其中音频来自人对前方的感知。
experience = .headTracked(soundStageSize: .small, anchoringStrategy: .front)
case .fullScreen:
// 全屏显示视频时,它会指定自动设置,让系统优化体验以最适合视频演示。
experience = .headTracked(soundStageSize: .automatic, anchoringStrategy: .automatic)
}
try AVAudioSession .sharedInstance().setIntendedSpatialExperience(experience)
} catch {
logger.error( "Unable to set the intended spatial experience. (error.localizedDescription) " )
}
}
拖动进度条时显示缩略图(Trick-Play)
这里有关于缩略图的一份说明developer.apple.com/documentati…
这里全屏播放的url换成苹果提供的测试url可以看到,在拖动进度条的时候可以显示缩略图
HLS测试链接:
- playgrounds-cdn.apple.com/assets/beac…
- playgrounds-cdn.apple.com/assets/lake…
- playgrounds-cdn.apple.com/assets/lake…
插页式事件处理
有时需要在视频媒体中插入Logo、回顾或广告到时间线中。插页式广告支持可实现此功能。当出现插页式广告时,控件将自动通过时间线中的指示器来反映它们。这些插页式广告可以使用 AVPlayerInterstital EventController以编程方式进行配置,也可以在 HLS 流中进行描述。
这个功能在iOS15就推出来了,现在在VisionOS里也支持了。
添加附加的UI
有一些视频播放应用程序常用的附加 UI 选项。上下文操作允许您添加“跳过简介”或“播放下一集”等按钮。它们可以有标题和可选图像。自定义信息视图控制器可用于显示有关内容的元数据或建议相关内容。这些 API 的工作方式与其他 Apple 平台相同。
这里介绍了三种功能,默认的附加UI选项,自定义信息视图,根据上下文添加按钮
接下来我用代码一一简单实现:
- 默认的附加UI选项
当播放的媒体信息提供嵌入或外部元数据时,播放器 UI 会显示“信息”选项卡。该选项卡的视图显示元数据详细信息,并且可能会沿其后缘显示最多两个控件
先创建AVMetadataItem
func createMetadataItems(for video: Video) -> [AVMetadataItem] {
let mapping: [AVMetadataIdentifier: Any] = [
.commonIdentifierTitle: video.title,
.commonIdentifierArtwork: video.imageData,
.commonIdentifierDescription: video.description,
.commonIdentifierCreationDate: video.info.releaseDate,
.iTunesMetadataContentRating: video.info.contentRating,
.quickTimeMetadataGenre: video.info.genres
]
return mapping.compactMap { createMetadataItem(for: $0, value: $1) }
}
private func createMetadataItem(
for identifier: AVMetadataIdentifier,
value: Any
) -> AVMetadataItem {
let item = AVMutableMetadataItem()
item.identifier = identifier
item.value = value as? NSCopying & NSObjectProtocol
// 指定未定义语言
item.extendedLanguageTag = "und"
return item.copy() as! AVMetadataItem
}
然后指定当前播放的AVPlayerItem的额外元数据为上面创建的demo信息即可
struct FullScreenPlayer: UIViewControllerRepresentable {
@Environment(PlayerModel.self) private var model
@Environment(VideoLibrary.self) private var video
func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) {
playerController.modalPresentationStyle = .fullScreen
playerController.player ? .currentItem ? .externalMetadata = model.createMetadataItems(for: video.videos.first ! )
}
func makeUIViewController(context: Context) -> AVPlayerViewController {
return model.makePlayerViewController()
}
}
当添加以上信息后,可以看到系统自动支持了这样的一个Additional UI。
接着我们在该界面追加另一个按钮:
func makeUIViewController(context: Context) -> AVPlayerViewController {
let controller = model.makePlayerViewController()
// 给系统默认info界面添加一个按钮
let infoCircle = UIImage (systemName: "info.circle" )
let showMoreInfo = UIAction (title: "展示更多信息" , image: infoCircle) { action in
print ( "展示更多信息" )
}
controller.infoViewActions.append(showMoreInfo)
return controller
}
可以看到,这里在信息选项卡的界面追加了另一个控件。
- 自定义信息视图
VisionOS播放器UI还可以在用户界面中显示多个内容选项卡以显示支持信息或相关内容。默认情况下,当资产包含嵌入元数据或在播放器项目上设置外部元数据时,播放器会显示“信息”选项卡,如上面的媒体信息展示。
接下来我们来自定义一个选项卡来显示支持内容。
func makeUIViewController(context: Context) -> AVPlayerViewController {
let controller = model.makePlayerViewController()
// 给系统默认info界面添加一个按钮
let infoCircle = UIImage(systemName: "info.circle")
let showMoreInfo = UIAction(title: "展示更多信息", image: infoCircle) { action in
print("展示更多信息")
}
controller.infoViewActions.append(showMoreInfo)
// 新增一个播放下一首的按钮UI先关页面
if let upNextViewController {
controller.customInfoViewControllers = [upNextViewController]
}
return controller
}
func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) {
playerController.modalPresentationStyle = .fullScreen
playerController.player?.currentItem?.externalMetadata = model.createMetadataItems(for: video.videos.first!)
Task { @MainActor in
if let upNextViewController {
playerController.customInfoViewControllers = [upNextViewController]
}
}
}
extension FullScreenPlayer {
var upNextViewController: UIViewController ? {
let view = UpNextView ()
let controller = UIHostingController (rootView: view)
controller.title = "PlayList"
controller.preferredContentSize = CGSize (width: 500 , height: 150 )
return controller
}
}
- 根据上下文添加按钮
我们也可以使用visionOS播放器UI来根据上下文呈现控件,我们可以在内容中根据播放时间范围范围来展示或者清除这个按钮。此类控件的常见用途是在电影或电视节目的标题序列期间显示的跳过按钮。人们可以点击按钮绕过介绍并快速跳至主要内容。
播放器将它们显示在屏幕的底部尾随侧。以下代码示例显示了一个操作的简单实现
func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) {
playerController.modalPresentationStyle = .fullScreen
playerController.player?.currentItem?.externalMetadata = model.createMetadataItems(for: video.videos.first!)
Task { @MainActor in
if let upNextViewController {
playerController.customInfoViewControllers = [upNextViewController]
}
if let upNextAction {
playerController.contextualActions = [skipAdAction]
} else {
playerController.contextualActions = []
}
}
}
extension FullScreenPlayer {
var upNextViewController: UIViewController? {
let view = UpNextView()
let controller = UIHostingController(rootView: view)
controller.title = "Next Video"
controller.preferredContentSize = CGSize(width: 500, height: 150)
return controller
}
var skipAdAction: UIAction ? {
return UIAction (title: "跳过广告" ) { _ in
print ( "跳过广告, seek到正文内容" )
}
}
}
当我们设置这个属性值时,播放器会立即显示控件。如果要仅在内容的相关部分中呈现它们,可以通过添加对播放时长的监听。来判断是否需要展示这个选项卡。如果是,该示例将跳过广告进入正文播放;否则,它将通过将其设置为空数组来清除该选项卡。
private func addTimeObserver() {
// Observe the player's timing every second.
let interval = CMTime(value: 1, timescale: 1)
let fifteenSeconds = CMTime (value: 15, timescale: 1)
timeObserver = avPlayer.addPeriodicTimeObserver(forInterval: interval,
queue: .main) { [weak self] time in
guard let self else { return }
let duration = avPlayer.currentItem?.duration ?? .zero
// Show the Skip Intro button during the first 15 seconds of the content.
showSkipIntroAction = time <= fifteenSeconds
}
}
func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) {
playerController.modalPresentationStyle = .fullScreen
playerController.player?.currentItem?.externalMetadata = model.createMetadataItems(for: video.videos.first!)
Task { @MainActor in
if let upNextViewController {
playerController.customInfoViewControllers = [upNextViewController]
}
if let upNextAction , showSkipIntroAction {
playerController.contextualActions = [skipAdAction]
} else {
playerController.contextualActions = []
}
}
}
当然这个跳转监听逻辑我没有去真的实现......
呈现沉浸式空间
接下来,我们来介绍成沉浸式控件,我们的APP可以通过名为“沉浸式空间”的功能将观看者带到另一个地方。当我们在APP中创建沉浸式空间时,我们可以自行决定该空间的外观。当进入沉浸式空间播放视频时,屏幕将自动移动到该空间并固定在可预测的尺寸和位置以保证每次都有出色的视角。同时那些操作控件将与屏幕分离,(比如上面介绍的信息选项卡,操作按钮等),使它们更容易交互。
介绍这部分,肯定得先知道如何创建一个沉浸式空间developer.apple.com/documentati…
根据介绍,要创建完全沉浸式的体验,首先要打开ImmersiveSpace并将其样式设置为full。沉浸式空间是一种SwiftUl场景,可以让你将内容放置在人周围的任何地方。将完整样式应用于场景会告诉系统隐藏直通视频,只显示应用程序的内容。在你的应用对象的body属性中声明沉浸式空间,或者在你管理SwiftUl场景的任何地方。
@main
struct MovePlayingAppApp: App {
@State private var player = PlayerModel()
@State private var library = VideoLibrary()
var body: some Scene {
WindowGroup {
ContentView()
.environment(player)
.environment(library)
}
// 这里我们为指定类型的呈现数据创建一个沉浸式空间
ImmersiveSpace (for: Destination . self ) { $content in
if let content {
}
}
// 设置沉浸式空间的风格,这里有3种风格可供设置
// mixed风格将您的内容与直通融合在一起。这使您能够将虚拟对象放置在人的周围环境中。
// full样式仅显示您的内容,并关闭直通。这使您能够完全控制视觉体验,就像当您想要将人们传送到一个新世界时一样。
// progressive样式完全取代了部分显示的直通。您可以使用这种风格让人们立足于现实世界,同时展示另一个世界的观点。
.immersionStyle(selection: .constant(.full), in: .progressive, .mixed, .full)
}
}
接下来是在这个无限空间中展示你的内容,可以是2D的,也可以是3D的。通常我们使用RealityKit 来呈现3D内容
ImmersiveSpace(for: Destination.self) { $content in
if let content {
DestinationView (content)
}
}
struct DestinationView: View {
@State private var destination: Destination
@State private var destinationChanged = false
init(_ destination: Destination) {
self.destination = destination
}
var body: some View {
RealityView { content in
let rootEntity = Entity ()
rootEntity.addSkybox(for: destination)
content.add(rootEntity)
}
.transition(.opacity)
}
}
extension Entity {
func addSkybox(for destination: Destination) {
let subscription = TextureResource.loadAsync(named: destination.imageName).sink(
receiveCompletion: {
switch $0 {
case .finished: break
case .failure(let error): assertionFailure("(error)")
}
},
receiveValue: { [weak self] texture in
guard let self = self else { return }
var material = UnlitMaterial()
material.color = .init(texture: .init(texture))
// 创建实体视觉外观的资源集合。
// 其中generateSphere是系统提供的创建一个具有指定半径的球体网格。 球体以实体原点为中心。 参数 球体的半径,单位为米。 返回:一个球体网格。 这里设置10的三次方,也就是1000米,相当于无限空间
// materials是模型使用的材料
self.components.set(ModelComponent(
mesh: .generateSphere(radius: 1E3),
materials: [material]
))
// 指定实体的缩放因子
self.scale *= .init(x: -1, y: 1, z: 1)
// 设置实体沿 x、y 和 z 轴的位置。
self.transform.translation += SIMD3<Float>(0.0, 1.0, 0.0)
// Rotate the sphere to show the best initial view of the space.
updateRotation(for: destination)
}
)
components.set(Entity.SubscriptionComponent(subscription: subscription))
}
func updateRotation(for destination: Destination) {
// Rotate the immersive space around the Y-axis set the user's
// initial view of the immersive scene.
let angle = Angle.degrees(destination.rotationDegrees)
let rotation = simd_quatf(angle: Float(angle.radians), axis: SIMD3<Float>(0, 1, 0))
self.transform.rotation = rotation
}
/// A container for the subscription that comes from asynchronous texture loads.
///
/// In order for async loading callbacks to work we need to store
/// a subscription somewhere. Storing it on a component will keep
/// the subscription alive for as long as the component is attached.
struct SubscriptionComponent: Component {
var subscription: AnyCancellable
}
}
上面的DestinationView使用RealityView来展示3D内容,我们提供了一个将提供的图片纹理映射到在人周围显示的球体内部的这样一个实体并将其作为RealityView的内容进行展示。
如下图,我们可以看到在沉浸式空间中,我们可以创建任意我们想看到的内容。
当我们打开沉浸式空间时,系统会隐藏所有其他可见的应用程序。SwiftUI一次只允许打开一个。在打开另一个沉浸式空间之前,要先关闭当前打开的任何沉浸式空间。
当我们开始完全沉浸式体验时,visionOS 定义了一个从人头部初始位置延伸 1.5 米的系统边界。如果我们的头部移出该区域,系统会自动停止沉浸式体验并再次打开外部视频。此功能是帮助防止人与物体碰撞。
当进入沉浸式空间时,视频的播放控件是主动和主屏幕分离的。这个也是系统的默认实现。
更多通过RealyKit来展现3D内容的教学请查看Explore materials with Reality Composer Pro
提供共享的观看体验
增强应用程序播放体验的最佳方法之一是让该体验可与其他人共享。我们可以使用AVFoundation和GroupActivities框架来构建SharePlay体验,即使人们无法位于同一位置,也可以将他们聚集在一起。
比如说当人们进行 FaceTime 通话并在全屏播放器中播放视频时,通话中的每个人都可以播放视频
但是这个在模拟器上不太好实现,所以这里没有去实践并展开,后面有机会可以在此分享,这个SharePlay不一定是用来共享观看,可以做很多事情。
好了,上面分享的内容就是本次Session的中所有提到的功能点和相关的扩展。
官方论坛
新增规范和格式支持
参考资料
- wwdc视频
- 视频播放
- 视频播放UI
- 沉浸式空间
- RealityKit,RealityView构建