IOS safari 播放 mp4 遇到的坎儿

起因

事情的起因是调试 IOS 手机下播放服务器接口返回的 mp4 文件流失败。对于没调试过移动端和 Safari 的我来说着实费了些功夫,网上和AI也没有讲明白。好在最终大概理清楚了,在这里整理出来供有缘人参考。

问题

因为直接用 IOS 手机的浏览器打开页面去播放也能复现,所以问题出现在 IOS上的 Safari 浏览器上。

问题主要分两类:

  • 一、视频能播放,但是不主动设置 poster,就不显示默认的 poster
  • 二、视频不能播放

首先文件是没有问题的,是主流浏览器都支持的视频编码格式为 H.264(AVC).mp4 文件,由文件格式导致的播放失败问题,网上很多,这里就不赘述了。

问题复现和解决

因为其他浏览器可以正常播放,所以用来对比差异时,我直接在PC端(Edge)查看信息,而 Safari 是在手机上访问项目页面,以及局域网下访问本地运行的 web 服务页面。

用到的工具如下:

  • Live Server 用来运行本地 web 页面
  • node + nodemon + Express 用来运行文件服务,模拟后端接口,nodemon 方便实时同步运行修改内容
  • video.js 播放视频的插件,直接用 CDN。
  • vConsole 移动端查看控制台,直接用 CDN

编写简单 demo

排查问题的时候我是一点一点将实现方式还原到最原始的方式:

  1. 后端接口 + video.js
  2. 后端接口 + video标签
  3. 本地运行的接口 + video标签
  4. 本地文件 + video标签

所以这里再梳理,就可以反向从最简单的方式排查,在本地写个用本地视频文件 + video标签的demo,为了后续排查方便,将事件日志也打印出来。

视频资源我用的 https://vjs.zencdn.net/v/oceans.mp4 下载到本地,不过它的开头是黑屏淡入的,为了方便区分 “不显示poster”,我将开头的几秒黑屏裁剪掉了。
也可以用安卓手机录制一个,默认就是支持播放的 mp4 文件。(PS:别用QQ录屏,编码不对)

<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script><script>var vConsole = new window.VConsole()</script><style>#video {width: 480px;height: 25vh;max-width: 100%;/* 背景色用于在视频不显示poster时查看占位 */background: pink;border: 4px solid pink;}</style></head><body><div class="video-box"><video id="video" src="oceans.mp4" controls preload="auto" playsinline="true" muted></video></div><div id="log"><h2>日志:方便手机上的 Safari 查看</h2><div class="log__content"></div></div><script>// 打印日志function log(msg) {// 页面上打印日志const logContentEl = document.querySelector('.log__content')const pEl = document.createElement('p')pEl.textContent = msglogContentEl.appendChild(pEl)// 控制台打印日志console.log(msg)}const video = document.getElementById('video')// 主要关心的事件const eventNames = [{ name: 'abort', desc: '当音频/视频的加载已放弃时触发' },{ name: 'canplay', desc: '当浏览器可以开始播放音频/视频时触发' },{ name: 'canplaythrough', desc: '当浏览器预计能够在不因缓冲而停顿的情况下持续播放指定的音频/视频时触发' },{ name: 'durationchange', desc: '当音频/视频的时长已更改时触发' },{ name: 'error', desc: '当在音频/视频加载期间发生错误时触发' },{ name: 'loadeddata', desc: '当浏览器已加载音频/视频的当前帧时触发' },{ name: 'loadedmetadata', desc: '当浏览器已加载音频/视频的元数据时触发' },{ name: 'loadstart', desc: '当浏览器开始查找音频/视频时触发' },{ name: 'play', desc: '当音频/视频已开始或不再暂停时触发' },{ name: 'playing', desc: '当音频/视频在因缓冲而暂停或停止后已就绪时触发' },{ name: 'progress', desc: '当浏览器正在下载音频/视频时触发' },{ name: 'timeupdate', desc: '当音频/视频的播放位置发生改变时触发' },{ name: 'waiting', desc: '当视频由于需要缓冲下一帧而停止,等待时触发' }]// 注册video事件监听器eventNames.forEach(v => {video.addEventListener(v.name, () => {// 打印日志log(`【readyState: ${video.readyState}${v.name}: ${v.desc}`)})})</script></body>
</html>

preload 默认是 metadata,和 auto 的日志结果一样。为了排除一些可能性,我将其设置了 auto
playsinline="true" 防止 IOS 播放视频时自动打开全屏。

问题1 不显示预览图

demo 页面加载后发现,Safari 浏览器没有显示视频的预览图:

在这里插入图片描述
Edge 显示了预览图:

在这里插入图片描述
通过日志发现,Safari 加载完元数据后(loadedmetadata)就不会继续加载了。

我们知道,视频的预览图就是 video 标签的 poster 属性,当 poster 有值时就会显示指定的图片,当 poster 没有值时,浏览器就会自动处理,而不同浏览器的处理方式也不一样,可能会有这几种情况:

  1. 如果配置了预加载,显示加载后的第一帧,或第二、三桢。
  2. 什么都不显示,或显示默认的播放器背景样式

从日志可以看到,Safari 只加载了元数据,并没有加载画面。查看官方文档,得到了解答:

在这里插入图片描述
再看 poster 的说明:
在这里插入图片描述
既然如此,那只能提供一个 poster 来解决了。

问题2 视频不能播放

问题复现

播放 demo 示例里的视频,是可以正常播放的。

将播放方式改成 video.js + 本地文件,播放是正常的。代码如下:

<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><link href="https://unpkg.com/video.js@7.10.2/dist/video-js.min.css" rel="stylesheet" /><script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script><script>var vConsole = new window.VConsole()</script><style>#video {width: 480px;max-width: 100%;height: 25vh;/* 背景色用于在视频不显示poster时查看占位 */background: pink;border: 4px solid pink;}</style></head><body><video id="video" class="video-js" controls preload="auto" playsinline="true" muted><source src="oceans.mp4" type="video/mp4" /></video><div id="log"><h3>日志:方便手机上的 Safari 查看</h3><div class="log__content"></div></div><script src="https://unpkg.com/video.js@7.10.2/dist/video.min.js"></script><script>// 打印日志function log(msg) {// 页面上打印日志const logContentEl = document.querySelector('.log__content')const pEl = document.createElement('p')pEl.textContent = msglogContentEl.appendChild(pEl)// 控制台打印日志console.log(msg)}// 主要关心的 videojs 事件const eventNames = [{ name: 'abort', desc: '当音频/视频的加载已放弃时触发' },{ name: 'canplay', desc: '当浏览器可以开始播放音频/视频时触发' },{ name: 'canplaythrough', desc: '当浏览器预计能够在不因缓冲而停顿的情况下持续播放指定的音频/视频时触发' },{ name: 'durationchange', desc: '当音频/视频的时长已更改时触发' },{ name: 'error', desc: '当在音频/视频加载期间发生错误时触发' },{ name: 'loadeddata', desc: '当浏览器已加载音频/视频的当前帧时触发' },{ name: 'loadedmetadata', desc: '当浏览器已加载音频/视频的元数据时触发' },{ name: 'loadstart', desc: '当浏览器开始查找音频/视频时触发' },{ name: 'play', desc: '当音频/视频已开始或不再暂停时触发' },{ name: 'playing', desc: '当音频/视频在因缓冲而暂停或停止后已就绪时触发' },{ name: 'progress', desc: '当浏览器正在下载音频/视频时触发' },{ name: 'timeupdate', desc: '当音频/视频的播放位置发生改变时触发' },{ name: 'waiting', desc: '当视频由于需要缓冲下一帧而停止,等待时触发' }]const player = videojs('video')player.ready(() => {// 注册video事件监听器eventNames.forEach(v => {player.on(v.name, () => {// 打印日志log(`【readyState: ${player.readyState()}${v.name}: ${v.desc}`)})})})</script></body>
</html>

然后换成后端接口地址,Edge 可以播放,但是 Safari 就失败了。

video 标签方式:
在这里插入图片描述
video.js 方式:报错 The media could not be loaded, either because the server or network failed or because the format is not supported.
在这里插入图片描述

我肯定不是服务器网络故障,也不是文件格式不支持,所以原因只能是接口了。

本地模拟接口

为了不麻烦后端同事,我只能在本地搭建一个服务器,模拟项目接口。

搭建服务

创建 app.js 文件

const express = require('express')
const fs = require('fs')
const path = require('path')const app = express()app.get('/', (req, res) => {res.send('Stupid IOS')
})app.listen(3000, () => {const ip = 192.169.3.7 // 我的局域网ipconsole.log(`server is running on http://${ip}:3000`)
})
# 安装依赖
npm i express
# 已安装 nodemon,所以直接使用
nodemon app.js

访问 http://192.169.3.7:3000/。把 oceans.mp4 视频文件放到 app.js 同目录下,后面就编写接口就行。

方式1 使用 express 封装好的方法返回文件流
// 方式1: 使用封装好的方法返回文件流
app.get('/type1/:file', (req, res) => {const fileName = req.params.fileconst filePath = path.join(__dirname, fileName)res.sendFile(filePath)
})

文件地址:http://192.169.3.7:3000/type1/oceans.mp4

video标签和 video.js 播放都正常。

方式2 手动读取全部文件流并返回

同样是接口,本地模拟的正常,项目接口不能播放,那就继续更细致的模拟,手动读取文件流,设置相同的响应头。

查看项目接口的响应头,排除一些范围,只模拟有可能影响的:

// 方式2: 手动读取全部文件流并返回
app.get('/type2/:file', (req, res) => {const fileName = req.params.fileconst filePath = path.join(__dirname, fileName)// 打印请求头console.log(req.headers)fs.stat(filePath, (err, stats) => {if (err) {return res.status(404).send('file not found')}// 设置响应头res.set({'Accept-Ranges': 'bytes', // 支持 Range 请求'Cache-Control': 'no-cache, no-store', // 不缓存'Content-Type': 'video/mp4;charset=UTF-8','Content-Range': `bytes 0-${stats.size - 1}/${stats.size}`,'Content-Length': stats.size,'Content-Disposition': 'attachment;filename="oceans.mp4"',ETag: `"${stats.ino.toString()}-${stats.size.toString()}-${Date.now().toString()}"`,'Last-Modified': stats.mtime.toUTCString(),'Pragma': 'no-cache'})const stream = fs.createReadStream(filePath)stream.pipe(res)})
})

文件地址:http://192.169.3.7:3000/type2/oceans.mp4

问题依旧存在,于是查看官方文档,找到一段说明:
在这里插入图片描述

Range 请求就是范围请求或分块传输,客户端通过请求头 Range 指定当前请求想要获取的数据子节范围,服务器根据这个范围,读取文件流,并将读取的内容返回给客户端。
通常,服务器会返回 206 状态码,表示范围请求的响应结果。并且需要在响应头中包含 Content-Range 字段,指明实际返回的数据范围,以及整个资源的总大小。

可是我加了支持 range 请求的响应头啊:'Accept-Ranges': 'bytes'

继续看文档,下面介绍了如何确认服务器是否支持 range 请求:
在这里插入图片描述
大概意思就是主动发送一个指定范围 100 bytes 的请求,看返回的数据是100 bytes,那就是支持,如果返回了整个文件,那就是不支持。

查看之前服务器中打印的请求头,Range 请求头的值:

  • Edge 是 0-,表示获取整个资源
  • Safari 是 0-1:表示获取位置01 的子节的资源,注意可不是从开头到第1个子节,这个范围的请求数是2个子节。

因为我每次都返回的完整的文件流,没有按照 Safari 的要求范围处理,所以属于不支持。

看来仅仅配置响应头是不行的,还要正确处理请求头中的指定范围。

方式3 手动读取指定范围的文件流并返回-分块传输

原来 Safari 会先发送一个获取范围为 bytes=0-1 的请求,以测试服务器是否支持 range 请求。

于是我手动改了下响应头,去掉那些没有影响的,还是返回整个文件流:

    // 设置响应头res.set({'Accept-Ranges': 'bytes', // 支持 Range 请求'Cache-Control': 'no-cache, no-store', // 不缓存'Content-Type': 'video/mp4;charset=UTF-8','Content-Range': `bytes 0-1/${stats.size}`,'Content-Length': stats.size,})

看来仅仅伪造响应头还是不行,还要返回正确大小的文件流,那就编写分块传输的接口:

// 方式3: 手动读取指定范围的文件流并返回-分块传输
app.get('/type3/:file', (req, res) => {const fileName = req.params.fileconst filePath = path.join(__dirname, fileName)// 查看请求头的范围console.log(req.headers.range)fs.stat(filePath, (err, stats) => {if (err) {return res.status(404).send('file not found')}// 设置响应头res.set({'Accept-Ranges': 'bytes', // 支持 Range 请求'Cache-Control': 'no-cache, no-store', // 不缓存'Content-Type': 'video/mp4'})const range = req.headers.rangelet partslet start = 0let end = stats.size - 1if (range) {parts = range.replace(/bytes=/, '').split('-')start = parseInt(parts[0], 10)end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1}if (start >= stats.size || end > stats.size) {// 如果Range请求超出文件大小,返回416状态码return res.status(416).end()}if (start >= 0 && end >= 0) {// 处理 Range 请求res.set({'Content-Range': `bytes ${start}-${end}/${stats.size}`,'Content-Length': end - start + 1})// 部分内容状态码,返回200浏览器也能正常处理res.status(206)}const stream = fs.createReadStream(filePath, { start, end })stream.pipe(res)})
})

终于视频可以正常播放了,video.js 也可以播放。

Safari 的校验还挺严格,如果服务器正确处理了这个2子节的请求,Safari 就会开始正式发送正常范围的请求。

服务器的日志可以体现出来:

bytes=0-1
bytes=0-51883958
bytes=196608-51883958
bytes=458752-51883958

最终查看了后端代码,果然接口没有处理 range 请求,在修改逻辑后,功能终于正常。

继续伪造 range 接口

为了搞清楚 Safari 的校验到底有多严格,我再次尝试模拟了一下第一次请求的响应:

	const range = req.headers.rangelet partslet start = 0let end = stats.size - 1// 增加逻辑 start-----------------------------------------if (range === 'bytes=0-1') {// 设置响应头res.set({'Accept-Ranges': 'bytes', // 支持 Range 请求'Cache-Control': 'no-cache, no-store', // 不缓存'Content-Type': 'video/mp4;charset=UTF-8','Content-Range': `bytes 3-3/${stats.size}`, // 随便写个范围'Content-Length': 2})const stream = fs.createReadStream(filePath, { start:100, end:200 }) // 随便获取个范围,但不能少于2stream.pipe(res)return res.status(206) // 随便返回个状态码}// 增加逻辑 end-----------------------------------------if (range) {parts = range.replace(/bytes=/, '').split('-')start = parseInt(parts[0], 10)end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1}

再次提醒,bytes=0-1 表示请求的位置是01的子节,是2个子节,而不是 1-0=1 的子节数量。

不断测试下,发现只要满足这几个要求,Safari 就认为接口支持 range 请求:

  1. Content-Length 要正确。
  2. Content-Range 的范围要合理。
  3. 返回了不少于 Range 请求头要求大小的文件流数据。

而下面这几点,不影响 Safari 的校验结果:

  1. Content-Range 的范围和 Range 的指定范围不一样
  2. 读取的数据范围和 Range 的指定范围不一样
  3. 返回任意状态码

总结

  1. IOS 上的 Safari 不支持 video 预加载(preload),浏览器不会自动提取帧画面作为默认的 poster 预览图
  2. Safari 上使用 video 播放视频,必须支持并正确处理 Range 范围请求,浏览器会先发送 bytes=0-1 范围的请求来测试服务器是否支持 Range 请求,如果校验成功,就会继续发送正常范围的 Range 请求。否则不再请求资源。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/pingmian/65753.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Mac 环境 VVenC 编译与编码命令行工具使用教程

VVenC VVenC 是一个开源的高效视频编码器&#xff0c;专门用于支持 H.266/VVC (Versatile Video Coding) 标准的编码。H.266/VVC 是继 HEVC (H.265) 之后的新一代视频编码标准&#xff0c;主要目的是提供比 HEVC 更高的压缩效率&#xff0c;同时保持或提高视频质量。H.266/VVC…

【0379】Postgres内核 Walreceiver (libpqwalreceiver API)分析

文章目录 1. libpqwalreceiver API1.1 四个函数2. Walreceiver IPC3. Walsender IPC4. Walsender - walreceiver protocol1. libpqwalreceiver API walreceiver 中与传输相关的部分,其负责连接主服务器、接收 WAL 文件以及发送消息,是动态加载的,以避免主服务器的二进制文件…

Mediatek Android13 ROM定制

目录 1、系统签名 2、默认关闭WIFI 3、默认关闭Bluetooth 4、默认关闭NFC 5、移除物理键盘支持 6、禁止 ANR、崩溃等的对话框显示 7、修改默认壁纸 8、取消USB PTP MTP MIDI等支持 9、设置Screen Lock默认为None 10、设置Data&time自动 11、设置特定时区 12、设…

手搓一个ChatUI需要分几步

只关注项目代码的同学可以直接跳转目录中的正文部分&#xff0c;查看项目仓库和功能介绍。 引言 Chatbot的UI界面设计&#xff0c;是和传统软件不同的&#xff1a;都是当面一个简洁的对话框&#xff0c;框里预备着热乎的工具&#xff0c;可以随时更新。 像我这样做工的牛马&a…

linux更换yum源

1.备份系统源文件 mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.bak2.下载国内的yum源到/etc/yum.repos.d/CentOS-Base.repo wget -O /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo如无法使用wget命令也可以…

IDEA 社区版 SpringBoot不能启动

报错原因&#xff0c;Failed to load class [javax.servlet.Filter] <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-tomcat</artifactId><scope>provided</scope> </dependency>…

FPGA 4x4矩阵键盘 实现

1原理 FPGA(现场可编程门阵列)4x4矩阵键盘的实现原理主要基于行列扫描法,通过FPGA对键盘的扫描和识别,实现对键盘输入信号的采集和处理。以下是对FPGA 4x4矩阵键盘实现原理的详细解释: 一、矩阵键盘的基本原理 结构:4x4矩阵键盘由4行和4列组成,共16个按键。每个按键位…

如何进行有效沟通?

在信息化系统项目中进行有效沟通&#xff0c;可以参考以下策略和技巧&#xff1a; 沟通技巧 明确沟通目的&#xff1a;确保每次沟通都有明确的目标&#xff0c;了解沟通接收方的需求及偏好&#xff0c;并监督衡量沟通效果。 使用5C原则&#xff1a;正确的语法和拼写&#xff…

018-spring-基于aop的事务控制

1 先配置平台事务管理器 2 在配置 spring提供的advice 3 事务增强的aop 总结&#xff1a; 事务就是要做2个配置&#xff1a; <!-- 1 开启事务管理器 不同的框架对应不同的事务管理器 --> <bean id"transactionManager" class"org.springframework.j…

IDEA工具使用介绍、IDEA常用设置以及如何集成Git版本控制工具

文章目录 一、IDEA二、建立第一个 Java 程序三、IDEA 常用设置四、IDEA 集成版本控制工具&#xff08;Git、GitHub&#xff09;4.1 IDEA 拉 GitHub/Git 项目4.2 IDEA 上传 项目到 Git4.3 更新提交命令 一、IDEA 1、什么是IDEA&#xff1f; IDEA&#xff0c;全称为 IntelliJ ID…

Mybatis-Plus时间查询

Mybatis-Plus时间查询 public List<HroAttendanceStatistics> listEffectiveByWorkNo(String workNo) {LambdaQueryWrapper<HroAttendanceStatistics> queryWrapper new QueryWrapper<HroAttendanceStatistics>().lambda();queryWrapper.eq(HroAttendanceSt…

让 Agent 具备语音交互能力:技术突破与应用前景(16/30)

让 Agent 具备语音交互能力&#xff1a;技术突破与应用前景 一、引言 在当今数字化时代&#xff0c;人机交互方式正经历着深刻的变革。从早期的命令行界面到图形用户界面&#xff0c;再到如今日益普及的语音交互&#xff0c;人们对于与机器沟通的便捷性和自然性有了更高的追求…

【Unity3d】C#浮点数丢失精度问题

一、float、double浮点数丢失精度问题 Unity3D研究院之被坑了的浮点数的精度&#xff08;一百零三&#xff09; | 雨松MOMO程序研究院 https://segmentfault.com/a/1190000041768195?sortnewest 浮点数丢失精度问题是由于大部分浮点数在IEEE754规范下就是无法准确以二进制…

再见24你好25

现在是2024年12月31日。 愿我们都有我与我周旋久&#xff0c;宁做我的勇气。一、验证24年的期望事件 1、 1.本身说要出去跑步&#xff0c;但是膝盖伤了&#xff0c;可能以后都跑不成了 2.linux内存方面的东西看过一遍&#xff0c;自己也写了博客&#xff0c;但还是缺乏真正的…

用 Python 从零开始创建神经网络(十八):模型对象(Model Object)

模型对象&#xff08;Model Object&#xff09; 引言到目前为止的完整代码&#xff1a; 引言 我们构建了一个可以执行前向传播、反向传播以及精度测量等辅助任务的模型。通过编写相当多的代码并在一些较大的代码块中进行修改&#xff0c;我们实现了这些功能。此时&#xff0c;…

单元测试3.0+ @RunWith(JMockit.class)+mock+Expectations

Jmockit使用笔记_基本功能使用Tested_Injectable_Mocked_Expectations_jmockit.class-CSDN博客 测试框架Jmockit集合junit使用 RunWith(JMockit.class) 写在测试案例类上的注解 Tested 在测试案例中,写在我们要测试的类上面, 一般用实现类 Injectable 在测试案例中声明…

npm install 安装选项 -d -s -g

在使用 npm install 时&#xff0c;-d、-g 和 -s 是不同的选项&#xff0c;它们分别代表不同的安装模式或行为。以下是它们的详细解释&#xff1a; 1. -d&#xff1a;--save-dev 含义&#xff1a;将包安装为开发依赖&#xff08;devDependencies&#xff09;。使用场景&#…

【复刻】数字化转型是否赋能企业新质生产力发展?(2015-2023年)

参照赵国庆&#xff08;2024&#xff09;的做法&#xff0c;对来自产业经济评论《企业数字化转型是否赋能企业新质生产力发展——基于中国上市企业的微观证据》一文中的基准回归部分进行复刻基于2015-2023年中国A股上市公司数据&#xff0c;实证分析企业数字化转型对新质生产力…

Vue axios 异步请求,请求响应拦截器

在 Vue.js 中使用 axios 进行网络请求是非常常见的做法&#xff0c;因为它提供了比原生的 Fetch API 更丰富的功能&#xff0c;并且更易于处理错误和配置。结合 Axios 的拦截器功能&#xff0c;你可以对所有的请求或响应进行预处理&#xff0c;比如添加认证头信息、统一处理错误…

px、em、rem,vw区别介绍

在网页设计中&#xff0c;px、em、rem 和 vw 都是常用的 CSS 单位&#xff0c;它们各自有不同的用途和特性。下面是这些单位的详细介绍及其区别&#xff1a; 1. px&#xff08;像素&#xff09; 定义&#xff1a; px 是最常用的绝对单位&#xff0c;表示屏幕上的一个物理像素…