摘要
竖拍的视频放进网页的 video 标签,点开是正的。同一个文件换一种读法就不一样了:我用 mp4box.js 把文件拆开,再把里面的压缩数据交给 VideoDecoder 去解,拿回来的每一帧都是横着躺的 1920×1080,四个浏览器全是这样。这件事我在 2026-10-12 从头到尾量了一遍,这篇是那次的完整记录,写给在浏览器里做视频处理的前端。你可能在做封面截取、逐帧滤镜或者压缩转码,也可能正打算用 WebCodecs 搭一条「拆文件、解码、处理、再编码」的流水线。只要代码里有一步不经过 video 标签,下面的事迟早会碰到。
结论先放在这里。竖拍视频常见的存法是画面像素躺着存,文件里另外记一条「播放时转 90 度」的旋转标记。打个比方,方向写在包装上,不在画面里。播放器会读包装,video 标签播放和 drawImage 截帧在四个浏览器里都是正的。一旦绕开播放器,包装上的字就没人替你读了。createImageBitmap 在 Chromium 149 里给出一张被压扁的 1920×1080,在 Firefox 151 里给出一张躺倒的图。自己解封装再解码的话,四个浏览器全躺,Chromium 帧上的 rotation 还是 0。导出的时候同样要自己拿主意。带标记的 MP4 四个浏览器都能放正,带标记的 WebM 在 Firefox 151 和 WebKit 26.5 里放出来却是躺的。我现在的做法是先从轨道头的矩阵里读出角度,导出前再把方向转进像素里,文件不再带标记。
版本声明
测试时间是 2026-10-12。机器用的是 Apple Silicon 的 Mac,系统版本 macOS 26.5。浏览器用了四个:Chromium 149.0.7827.55、Chrome 154.0.8037.98、Firefox 151.0 和 WebKit 26.5。这里的 WebKit 26.5 是 macOS 上的构建,不是 iPhone 上的 Safari。转码用 ffmpeg 7.1,读帧对照用走 FFMPEG 后端的 OpenCV 5.0.0,浏览器里拆 MP4 文件用 mp4box.js 0.5.2。文中出现的浏览器端压缩工具用的是 2026-10-12 当天的线上版本,只在 Chromium 149 里跑过。
适用边界
先说样片。两段源片是网上找的示例视频,都出自维基共享资源。风机那段的作者是 Grendelkhan,下雪那段的作者是 Canbel,许可都是 CC BY-SA 4.0。源片的像素本身是竖的,也没有旋转标记。文中带标记的对照样片是我用 ffmpeg 加工出来的,不是手机直接拍出来的原片。手机原片、HEVC 编码、iPhone 上的 Safari 和安卓上的浏览器这次都没有测,前置摄像头那种带镜像的情况也不在范围里。任何视频平台和云转码服务我都没有测,文中不对它们下判断。浏览器之间为什么表现不一样我不去猜,只列出量到的结果。
文章目录
一、竖拍视频为什么自己解码出来是横的
- 1.1 自己解码四个浏览器拿到的都是躺帧
- 1.2 样片是用ffmpeg做的对照片
二、竖拍视频的方向信息存在哪
- 2.1 画面像素是躺着存的
- 2.2 旋转标记写在轨道头的矩阵里
- 2.3 解码器拿不到这条标记
三、播放器替你做了哪些事
- 3.1 四个浏览器播放MP4都是正的
- 3.2 videoWidth报的是转正后的宽高
四、浏览器里哪些取帧方式认旋转标记
- 4.1 drawImage在四个浏览器里都是正的
- 4.2 取位图有两个浏览器不对
- 4.3 从video建帧在Firefox里是躺的
- 4.4 自己解封装再解码看不到标记
五、自己解封装时怎么读出旋转角度
- 5.1 mp4box能读到九个数的矩阵
- 5.2 用前两个数就能算出角度
- 5.3 轨道宽高是存储尺寸
六、导出视频时方向该怎么处理
- 6.1 带标记的MP4四个浏览器都能放正
- 6.2 带标记的WebM在两个浏览器里是躺的
- 6.3 我选择把方向转进像素
七、常见转码方式默认怎么处理方向
- 7.1 ffmpeg默认转码会把像素转正
- 7.2 固定宽高缩放会把竖画面压扁
- 7.3 只清标记不转像素四个浏览器全躺
- 7.4 OpenCV 5.0.0默认按标记转正
- 7.5 浏览器端压缩工具的产物是竖着存的
八、适用边界与风险
九、测量方法的坑
十、还没解决的
参考资料
一、竖拍视频为什么自己解码出来是横的
这一章只做一件事:把现象摆清楚。同一个文件用两种办法读,拿到的方向不一样。先看到这个差别,后面讲方向存在哪、谁替你处理了、怎么自己接住,才有落脚的地方。
1.1 自己解码四个浏览器拿到的都是躺帧
先解释三个词。一个 MP4 文件像一个分了格的箱子,画面的压缩数据、声音的压缩数据和一堆说明信息各放各的格子。把箱子打开,再把压缩数据一包一包取出来,这一步叫解封装,mp4box.js 是做这件事的一个开源库。WebCodecs 是浏览器提供的一组底层编解码接口,其中 VideoDecoder 负责解码,你把压缩数据喂给它,它还给你一帧一帧的画面。这套组合的好处是每一帧都在自己手里,想逐帧处理或者想精确取到某一帧都可以。代价是原本播放器替你做的事现在全归你。
我拿一段带旋转标记的竖拍样片 turbine_rot90.mp4 做了对照。它放进 video 标签播放时画面是正的,宽高是 1080×1920。换成 mp4box.js 解封装再交给 VideoDecoder 以后,解出来的帧是 1920×1080,画面顶部朝左躺着。Chromium 149、Chrome 154、Firefox 151 和 WebKit 26.5 我都试了一遍,四个浏览器自己解码拿到的都是躺帧,没有一个例外。

这张图要看的是最右边那一格。五格是同一个文件在 Chrome 154 里的五种读法,每格下面标着接口名和拿到的宽高。前四格都是 1080×1920,红块在左上、绿块在右上。最右一格是 mp4box 加 VideoDecoder 的结果,宽高变成了 1920×1080。绿块跑到了左上,红块落在左下,风机横在画面中间。红块和绿块是我做样片时画上去的方向记号,后面每张图都靠它们认方向。
我又换了另外两种角度的样片。rot270 的样片解出来也是躺的,只是顶部朝右。rot180 的样片解出来是倒立的。躺向哪边是跟着文件走的,可见问题不在我喂数据的方式上,是文件里有一条信息没有传到解码这一步。
1.2 样片是用ffmpeg做的对照片
要量方向,样片得是我知道底细的。我从维基共享资源找了两段竖画面的示例视频。风机那段是 VP9 编码的 1080×1920、60 fps,长 7.98 秒。下雪那段是 Theora 编码的 720×1280、29.97 fps,长 9.51 秒,带 Vorbis 音轨。它们的像素本来就是竖的,文件里也没有旋转标记。
对照样片是我用 ffmpeg 做的,一共分三步。第一步在画面左上角画一个红色方块、右上角画一个绿色方块,各 160×160。第二步把像素转成横放。第三步不重新编码,只往文件里补一条旋转标记。做出来的样片全是 H.264 编码。风机样片长 5 秒、30 fps、没有音轨,下雪样片长 6 秒、带 AAC 音轨。
bash
# 画方向记号,再把像素逆时针转成横放
ffmpeg -i turbine_src.webm -t 5 -r 30 -an \
-vf "drawbox=x=0:y=0:w=160:h=160:color=red:t=fill,drawbox=x=iw-160:y=0:w=160:h=160:color=lime:t=fill,transpose=2" \
-c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p lying_ccw.mp4
# 不重新编码,只在文件里补一条旋转标记
ffmpeg -display_rotation -90 -i lying_ccw.mp4 -c copy -movflags +faststart turbine_rot90.mp4
# 看 ffmpeg 自己怎么读这个文件
ffmpeg -hide_banner -i turbine_rot90.mp4
text
Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(progressive), 1920x1080, ...
displaymatrix: rotation of -90.00 degrees
代码说明:transpose=2 是把画面逆时针转 90 度,转完像素就是躺着的 1920×1080。第二条命令里的 -c copy 表示压缩数据原样搬过去,-display_rotation 只改文件里的那条标记。第三条命令的输出有两处要看。流尺寸写的是 1920x1080,这是像素实际存放的宽高。下面单独一行 displaymatrix 才是旋转标记。输出里省掉的部分是帧率这类和方向无关的信息。
| 样片 | 存储像素 | 存储方向 | ffmpeg 读到的标记 |
|---|---|---|---|
| turbine_up.mp4 | 1080×1920 | 正 | 无 |
| turbine_rot90.mp4 和 .mov | 1920×1080 | 躺倒 顶朝左 | rotation of -90.00 degrees |
| turbine_rot270.mp4 | 1920×1080 | 躺倒 顶朝右 | rotation of 90.00 degrees |
| turbine_rot180.mp4 | 1080×1920 | 倒立 | rotation of -180.00 degrees |
| snow_up.mp4 | 720×1280 | 正 | 无 |
| snow_rot90.mp4 | 1280×720 | 躺倒 顶朝左 | rotation of -90.00 degrees |
文件名里的 rot90 指的是播放时要顺时针转 90 度画面才正,这和手机竖拍常见的那种标记是同一个方向。带 up 的两段是像素竖着存、不带标记的对照组。我得再说一遍,这些标记是 ffmpeg 写进去的,不是手机直出的。手机原片的结构是不是完全一样,这次没有验证。
为什么要做三种角度,这里交代一句。90 度和 270 度出错时宽高会对调,从尺寸上就看得出来。180 度出错时宽高不变,只能靠画面认。只做一种角度的话,后面有两处差别我是发现不了的。
二、竖拍视频的方向信息存在哪
这一章回答上一章留下的问题:播放是正的,自己解出来是躺的,那条让画面转正的信息到底放在文件的什么地方?知道它在哪,才知道哪些接口够得着它。
2.1 画面像素是躺着存的
先确认像素本身。ffmpeg 有个开关叫 -noautorotate,意思是别替我转,文件里存的是什么样就给我什么样。我用它从 turbine_rot90.mp4 里截了一帧,得到的图是 1920×1080,画面顶朝左躺着。不加这个开关再截一次,得到的是 1080×1920 的正图。两次截的是同一个时间点,差别只在 ffmpeg 有没有按标记转。
这就坐实了第一件事:画面像素是躺着存的。压缩数据里那一帧就是一张 1920×1080 的横图,上面的风机是横着的。至于常见的竖拍视频为什么会这么存,我不是做相机那一侧的,这里不猜。对写处理代码的人来说,知道有这种存法就够用了。照片也有类似的事,这篇不展开。
2.2 旋转标记写在轨道头的矩阵里
再看标记放在哪。MP4 把内容分成轨道,视频一条、声音一条。每条轨道前面有一小段说明叫轨道头,在文件结构里它的名字是 tkhd。轨道头里有一个由九个整数组成的矩阵,它描述这条轨道的画面显示时要怎么摆。转不转、转多少都在里面。播放器看到它就知道解出来的画面要先转一下再给人看。
我用 mp4box.js 把四个风机样片的轨道头都读了一遍。不带标记的 turbine_up.mp4 读到的是 65536, 0, 0, 0, 65536, 0, 0, 0, 1073741824,这是一个「什么都不动」的矩阵。turbine_rot90.mp4 读到的是 0, 65536, 0, -65536, 0, 0, 0, 0, 1073741824,前面几个数换了位置还多了一个负号。前面说旋转标记写在轨道头的矩阵里,指的就是这几个数。MOV 文件读到的矩阵和 MP4 的一模一样,后面的测试里两种文件的结果也始终一致。
2.3 解码器拿不到这条标记
矩阵在轨道头里,压缩数据在另一个格子里,两样东西是分开放的。我给 VideoDecoder 做配置的时候交给它的只有三样东西:编码格式的名字、编码宽高、一小段解码要用的参数。然后就是一包一包的压缩数据。矩阵不在这三样里,也不在我喂进去的数据包里,解码器拿不到这条标记。
量到的结果和这个结构对得上。在 Chromium 149 和 Chrome 154 里自己解码拿到的帧上有一个 rotation 属性,值是 0。Firefox 151 和 WebKit 26.5 的帧上没有这个属性。四个浏览器画出来都是躺的。这就是「方向写在包装上,不在画面里」的意思。解码器只管把画面还原出来。包装是我拆的,上面写了什么只有我看得到。我不往下传,后面的环节就都不知道。
这一点和平时写页面的直觉是反着的。平时我们默认一个视频文件自己知道自己该朝哪边,拿到画面就等于拿到了方向。到了自己解码这一层就不能这么想了,画面和方向是两条分开走的信息。画面从解码器那边出来,方向只留在我拆开的轨道头里,中间没有任何一个环节会自动把它们重新合到一起。
三、播放器替你做了哪些事
平时为什么感觉不到这件事?用 video 标签的时候,方向是播放器在背后处理的,而且处理了不止一处。弄清它替你做了几件事,绕开它的时候才知道自己要补几件。
3.1 四个浏览器播放MP4都是正的
我把 rot90、rot270、rot180 三种带标记的样片各放进 video 标签播了一遍,MP4 和 MOV 都试了。四个浏览器播放 MP4 都是正的,MOV 也一样,三种角度没有区别。下雪那段样片也是一样的结果,只是尺寸换成了 720×1280。
这里有必要把话说准。这批测试的起因是这样一个问题:竖拍视频放到网页上为什么会横过来。在我测的这四个浏览器里,MP4 和 MOV 放进 video 标签播放都是正的,横过来这件事在播放这一步复现不出来。画面横掉、压扁或者倒立都出在后面几章讲的那些步骤上,也就是程序自己取帧和转码的时候。
3.2 videoWidth报的是转正后的宽高
播放器做的第二件事更不起眼。video 元素上有两个属性叫 videoWidth 和 videoHeight,代码里常用它们来定画布的大小。对带标记的样片,四个浏览器报的都是 1080×1920,可文件里存的像素明明是 1920×1080。也就是说 videoWidth 报的是转正后的宽高,已经把旋转算进去了。
第三件事是截帧。把 video 元素直接画到画布上是最常见的取封面办法,写法是 canvas 的 drawImage(video)。它在四个浏览器里画出来都是正的。算下来播放器替你做了三件事:把画面转正、把宽高换成转正后的、把画到画布上的那一帧也转正。平时这三件事都是无声完成的,没出过错的东西很难被注意到。等你换一条路取帧,三件事里少了哪一件画面就会以不同的样子出错。少了第一件画面会躺,少了第二件画布的宽高会定反,两件对不上的时候画面就被压扁。
四、浏览器里哪些取帧方式认旋转标记
这一章是全文数据最密的地方,回答一个很实际的问题:从 video 标签到完全自己解码,中间有好几种取帧的办法,每一种在每个浏览器里到底认不认旋转标记?我把五条取帧路径在四个浏览器里的结果列成了一张表,表里多放了一行宽高读数做参照。样片是带标记的那几段,MP4 和 MOV 结果一致。每一格我都记了两样东西:拿到的宽高和红绿方块落在哪个角。表里写「正」的格子两样都对得上,写「躺倒」的是方块跑到了侧边,写「压扁」的是方块位置没错可宽高不对。
| 路径 | Chromium 149 | Chrome 154 | Firefox 151 | WebKit 26.5 |
|---|---|---|---|---|
| video 标签播放 | 正 | 正 | 正 | 正 |
| videoWidth × videoHeight | 1080×1920 | 1080×1920 | 1080×1920 | 1080×1920 |
| drawImage(video) | 正 | 正 | 正 | 正 |
| createImageBitmap(video) | 1920×1080 画面正但被压扁 | 1080×1920 正 | 1920×1080 躺倒 | 1080×1920 正 |
| new VideoFrame(video) | 显示宽高 1080×1920 有 rotation 画出来正 | 同左 | 显示宽高 1920×1080 无 rotation 画出来躺倒 | 显示宽高 1080×1920 无 rotation 画出来正 |
| mp4box 解封装加 VideoDecoder | 躺倒 rotation 为 0 | 躺倒 rotation 为 0 | 躺倒 | 躺倒 |

这张图是上面那张表的画面版。三行是三个浏览器,五列是五种读法。先看每一行最右边那格,三个浏览器全是横躺的。再看中间那一行 Firefox 151,它从第三格开始就躺了,一共躺了三格。Chrome 154 和 WebKit 26.5 这两行只有最右一格是躺的。
前四种读法我是用下面这段代码一次取完的。
js
// el 是已经跳到第 1 秒的 video 元素
async function grabFour(el) {
const report = { tag: [el.videoWidth, el.videoHeight] };
const pad = document.createElement('canvas');
pad.width = el.videoWidth;
pad.height = el.videoHeight;
pad.getContext('2d').drawImage(el, 0, 0);
report.canvas = [pad.width, pad.height];
const bmp = await createImageBitmap(el);
report.bitmap = [bmp.width, bmp.height];
const vf = new VideoFrame(el);
report.frame = {
coded: [vf.codedWidth, vf.codedHeight],
display: [vf.displayWidth, vf.displayHeight],
rotation: vf.rotation,
};
vf.close();
return report;
}
text
turbine_rot90.mp4
Chromium 149 tag 1080x1920 | bitmap 1920x1080 | frame coded 1920x1080 display 1080x1920 rotation 90
Chrome 154 tag 1080x1920 | bitmap 1080x1920 | frame coded 1920x1080 display 1080x1920 rotation 90
Firefox 151 tag 1080x1920 | bitmap 1920x1080 | frame coded 1920x1080 display 1920x1080 rotation undefined
WebKit 26.5 tag 1080x1920 | bitmap 1080x1920 | frame coded 1080x1920 display 1080x1920 rotation undefined
代码说明:这段只取宽高和属性。方向另外靠读画布四个角的颜色来判,红块绿块落在哪个角就是哪种朝向。输出里 tag 一列四个浏览器完全一样,差别从 bitmap 一列开始出现。coded 是帧里像素实际的宽高,display 是这一帧打算被显示成的宽高。rotation 写着 undefined 的两行,意思是帧对象上根本没有这个属性。
4.1 drawImage在四个浏览器里都是正的
先说最稳的一条。把 video 元素用 drawImage 画到画布上,四个浏览器里都是正的,三种角度的样片都是。画布宽高照着 videoWidth 和 videoHeight 来定,画出来就是一张 1080×1920 的正图。如果你的需求只是给视频取一张封面,这条路在我测的范围里没有出过错,用不着往下折腾。
它的局限也在这里。drawImage 取的是 video 元素当前显示的那一帧,想取哪一帧得先让播放器跳过去再画。要逐帧处理一整段视频,这么一帧一帧地跳并不合适,时间点也不容易对准。很多人就是因为这个才去换别的接口,然后就撞上后面几节的事。
4.2 取位图有两个浏览器不对
createImageBitmap 是把图像源转成位图对象的接口。位图可以传给后台线程去处理,做逐帧处理的人很爱用它。这条路上有两个浏览器取到的位图不对,错的样子还不一样。
Firefox 151 给的位图是 1920×1080,画面躺倒,等于标记完全没有生效。Chromium 149 的情况更隐蔽:位图同样是 1920×1080,可画面内容是正的,红块还在左上,绿块还在右上。整幅竖画面被横向拉宽、纵向压矮,塞进了一个横的框里,原本 160×160 的方块变成了大约 284×90。Chrome 154 和 WebKit 26.5 给的是 1080×1920 的正图。

这张图看两行的第三格。上面一行是 Chromium 149,第三格里风机变得又矮又胖,左上角的红块成了一条扁扁的长方形。下面一行是 Chrome 154,同一段代码、同一个文件,它的第三格是正常的竖图,红块是方的。两行其余几格没有差别。
压扁这种错法我想多说一句。躺倒的图一眼就能看出来,压扁的图就不一定了。画面里要是没有圆形或者人脸这类形状熟悉的东西,很可能就混过去了。rot180 的样片又是另一种情况。Chromium 149 对它给的位图是正的,Firefox 151 给的是倒立的。倒立的图宽高没有变,光看尺寸发现不了。
4.3 从video建帧在Firefox里是躺的
VideoFrame 是 WebCodecs 里表示「一帧画面」的对象,它可以直接从 video 元素建出来。写法是 new VideoFrame(video)。这样建出来的帧在 Firefox 151 里是躺的。另外三个浏览器画出来是正的,可它们给出的信息并不相同。
Chromium 149 和 Chrome 154 的做法是如实交代。帧的 coded 宽高是 1920×1080,display 宽高是 1080×1920,另有一个 rotation 属性,三种样片分别是 90、270 和 180。意思是像素我没动,该怎么转我告诉你,画的时候它也按这个画。WebKit 26.5 的帧直接就是 1080×1920,没有 rotation 属性,画出来是正的。Firefox 151 的帧 display 宽高是 1920×1080,也没有 rotation 属性,画出来躺倒。
rotation 这个属性我一开始很想依赖它,量完就放弃了。四个浏览器里只有两个有,没有它的那两个又是一个正、一个躺,光凭这个属性存不存在判断不出方向。
4.4 自己解封装再解码看不到标记
最后一条路就是第一章的那个现象。自己解封装再解码,这条路上看不到标记,四个浏览器拿到的全是躺帧。rot270 的样片顶朝右,rot180 的样片倒立。在 Chromium 149 和 Chrome 154 里帧上的 rotation 是 0,没有任何提示。
把这一章的表从上往下再看一遍会发现一个规律。越靠上的路径播放器参与得越多,结果越一致。越往下播放器参与得越少,浏览器之间的分歧越大。到了最后一行播放器完全不参与,四个浏览器反倒又一致了,一致地不管方向。对做视频处理的人来说中间那两行最麻烦,它们在你自己的浏览器里可能恰好是对的。最后一行最省心,它明明白白地把方向交到了你手上。
中间那两行有一个便宜的自检办法可以先用上。拿到位图或者帧以后,把它的宽高和 video 元素的 videoWidth、videoHeight 比一下。对转 90 度和 270 度的样片来说这个比较很管用。Chromium 149 的压扁和 Firefox 151 的躺倒都会被它抓出来,因为这两种情况下拿到的都是 1920×1080,video 元素报的却是 1080×1920。它只能告诉你不对,分不出是压扁还是躺倒。它也抓不出转 180 度的情况,Firefox 151 给出的倒立图尺寸和 video 元素报的完全一样。
下雪那段样片我也照样跑了一遍,它的结果和风机样片一一对应,只是尺寸换成了 720×1280 和 1280×720。换一段内容完全不同的画面结论不变,这让我对这张表放心了一些。
五、自己解封装时怎么读出旋转角度
上一章的结论是自己解码就得自己管方向。这一章讲管的第一步:从文件里把角度读出来。这一步不依赖浏览器的行为,读的是文件本身,在四个浏览器里读到的数是同一组。
5.1 mp4box能读到九个数的矩阵
mp4box.js 解析完文件头以后会给出每条轨道的信息,视频轨道的信息里就带着轨道头的矩阵。这个矩阵有九个数,按行排开是三行三列。读法和四个风机样片的读数都在下面。
js
const mp4 = MP4Box.createFile();
let movie;
mp4.onReady = meta => { movie = meta; };
bytes.fileStart = 0; // bytes 是整个文件的 ArrayBuffer
mp4.appendBuffer(bytes);
mp4.flush();
const vtrack = movie.videoTracks[0];
const m9 = Array.from(vtrack.matrix || mp4.getTrackById(vtrack.id).tkhd.matrix);
function turnOf(m) {
const deg = Math.round(Math.atan2(m[1], m[0]) * 180 / Math.PI);
return (deg + 360) % 360;
}
console.log(vtrack.track_width, vtrack.track_height, m9.slice(0, 5), turnOf(m9));
text
turbine_up 1080 1920 [65536, 0, 0, 0, 65536] 0
turbine_rot90 1920 1080 [0, 65536, 0, -65536, 0] 90
turbine_rot270 1920 1080 [0, -65536, 0, 65536, 0] 270
turbine_rot180 1080 1920 [-65536, 0, 0, 0, -65536] 180
代码说明:前半段是把文件交给 mp4box 并等它解析完文件头,vtrack 就是视频轨道的信息。矩阵有时直接挂在轨道信息上,取不到时我再去轨道头里拿。输出只打印了九个数里的前五个,后四个在这四个样片里都是 0、0、0 和 1073741824。最后一列是 turnOf 算出来的角度,它不是浏览器报的。
5.2 用前两个数就能算出角度
矩阵里的数看着吓人,其实是定点数。65536 代表 1,-65536 代表 -1。最后那个 1073741824 也代表 1,只是换了一种精度。把它们换回去再看 turbine_rot90 的矩阵,左上角四个数就是 0、1、-1、0,正是一个转 90 度的旋转矩阵。
九个数按三行三列排开以后,真正管旋转的只有左上角那四个。右边一列上面两个数在这四个样片里都是 0。最下面一行前两个数管的是平移,样片里也都是 0。右下角那个数换算回来是 1。也就是说对这批只做了旋转的样片,看懂左上角四个数就等于看懂了整个矩阵。镜像翻转会让这四个数出现别的组合,那种情况我放在最后一章说。
只算旋转的话,用前两个数就能算出角度。把第一个数当作余弦、第二个数当作正弦求一次反正切,再把负数角度折回 0 到 360 之间。四个样片算出来是 0、90、270、180。这组数我拿两处做了核对。Chromium 在 new VideoFrame(video) 上报的 rotation 是 90、270、180,OpenCV 读到的方向信息也是 90、270、180,三边对得上。
有一处符号让我在这里停了一下。同一个 turbine_rot90.mp4,ffmpeg 打印的是带负号的 rotation of -90.00 degrees,Chromium 和 OpenCV 说的却是 90。它们描述的是同一个矩阵,只是说法不同。一个说的是矩阵表示的旋转,一个说的是显示时要顺时针转多少。我自己写代码时统一用后一种,也就是「要顺时针转多少度才正」,日志里也只打这一种。两种说法混着用的话,90 和 270 很容易写反。
角度读出来以后怎么用?办法是按角度把帧旋转着画到一张画布上,90 度和 270 度的时候画布的宽高要对调。这一步的代码这次没有放进四个浏览器的对照表里量,我不在这里贴结果。
5.3 轨道宽高是存储尺寸
还有一个容易想当然的地方。mp4box 给的轨道信息里有 track_width 和 track_height,名字看着像是「这条轨道显示出来多宽多高」。在 turbine_rot90.mp4 上我读到的是 1920×1080。这个轨道宽高是存储尺寸,没有把旋转算进去。这和 video 元素的 videoWidth 正好相反,那边报的是转正后的 1080×1920。
从 video 标签的代码迁移到自己解码的代码时这里很容易出错。原来用 videoWidth 定画布,现在顺手换成 track_width,画布就成了横的。角度是 90 或 270 的时候画布的宽和高要自己对调一下,角度是 180 的时候不用。
到这里方向这条信息算是被我接住了。它原本躺在轨道头里没人理,现在变成了一个 0 到 360 之间的整数,跟着每一帧一起往下走。后面每个环节要不要转、画布怎么定都看这个数。
六、导出视频时方向该怎么处理
处理完的帧通常还要再编码,再装回一个文件里。到这一步方向又要做一次决定,这一章讲的就是这个决定。办法有两种。一种是保持像素躺着,在新文件里照样写一条旋转标记,我叫它带标记。另一种是把每一帧先转正再编码,新文件的像素本身就是竖的,不再需要标记,我叫它转进像素。
6.1 带标记的MP4四个浏览器都能放正
带标记的好处是省事,像素不用动。输出 MP4 的话播放没有问题:带标记的 MP4 四个浏览器都能放正,这在第三章已经量过了,rot90、rot270、rot180 三种角度都是正的。只换文件格式、不重新编码的场景只能走这条路,因为压缩数据是原样搬运的,像素没有机会被转。
它的问题是把麻烦留给了下一个读这个文件的程序,第四章那张表就是下一个程序会遇到的情况。它如果用 createImageBitmap 取帧,在 Chromium 149 里会拿到压扁的图,在 Firefox 151 里会拿到躺倒的图。它如果也是自己解码,又得自己去读一遍矩阵。我导出的文件别人怎么读我是管不了的。
6.2 带标记的WebM在两个浏览器里是躺的
如果输出的是 WebM,带标记这条路连播放都保不住。我用 ffmpeg 加上 -noautorotate 把样片转成了 VP9 编码的 WebM。转出来的文件仍然是 1920×1080 的躺倒像素,ffmpeg 读得到 rotation of -90.00 degrees,标记确实带过去了。
| Chromium 149 | Chrome 154 | Firefox 151 | WebKit 26.5 | |
|---|---|---|---|---|
| video 标签播放带标记的 WebM | 正 1080×1920 | 正 1080×1920 | 躺倒 1920×1080 | 躺倒 1920×1080 |
这个带标记的 WebM 在两个浏览器里是躺的:Firefox 151 和 WebKit 26.5 的 video 标签放出来就是横的,videoWidth 报的也是 1920×1080。Chromium 149 和 Chrome 154 是正的。同样是「带旋转标记」,换了一种文件格式以后连播放器这一层都不再一致。这个结果出来的时候我有点意外,我原以为播放这一层是最不用操心的。WebM 的封装我没有自己写过,标记在这种文件里具体记在哪个字段我没有去核对。这里只报量到的结果。
取帧的情况我也顺手看了一眼。带标记的 WebM 在 Chromium 149 里用 createImageBitmap 取位图,拿到的同样是画面正但被压扁的 1920×1080,和 MP4 那边的表现一样。Chrome 154 给的是 1080×1920 的正图。也就是说除了播放层不一致,第四章里位图被压扁的那个差别在 WebM 上照样有。带标记的 WebM 是我这次量到的最不让人放心的一种输出。
6.3 我选择把方向转进像素
两种办法摆在一起,我选择把方向转进像素。理由有三条,都来自前面的数据。
头一条是它不挑读法。像素竖着存、不带标记的两段对照样片在所有路径、四个浏览器里全都是正的,整张表里全对的只有这一类文件。第二条是它不挑格式,MP4 和 WebM 都一样,因为根本没有标记需要别人去认。第三条是我的流水线本来就要重新编码。每一帧在编码之前都要经过我的手,多转一下并不需要另开一条流程。
它也有代价。转进像素就得重新编码,那种只换格式不动画面的快速通道走不了。每一帧还要多一次旋转的绘制,这部分开销我这次没有量,不给数。如果你的场景是纯粹的换格式,那就留在 MP4 里并且带好标记,这是我认为第二好的选择。最差的做法是第三种:像素不转,标记也不带,下一章有它的实测结果。
把这一章压成几句话是这样的。输出的文件要交给不确定的下游去读,就转进像素。只做换格式,又能确定下游是认标记的播放器,就留在 MP4 里带着标记走。WebM 我不会让它带标记出门。
七、常见转码方式默认怎么处理方向
我在上一章说了自己的选择。这一章拿三样现成的东西来对照,看它们默认是怎么处理的:ffmpeg 7.1、OpenCV 5.0.0 和一个浏览器里的压缩工具。看别人的默认行为有两个用处。一是验证自己的选择是不是另类。二是这些命令本身就常被前端写进脚本,里面有两处很容易写错。
7.1 ffmpeg默认转码会把像素转正
源文件都是 turbine_rot90.mp4,它的像素是 1920×1080 躺倒、标记是 -90。
bash
SRC=turbine_rot90.mp4
X264="-c:v libx264 -preset veryfast -crf 26 -an"
ffmpeg -i $SRC $X264 a_default.mp4 # 什么都不加
ffmpeg -noautorotate -i $SRC $X264 b_keep_tag.mp4 # 不让它自动转
ffmpeg -i $SRC -c copy c_copy.mp4 # 不重新编码
ffmpeg -i $SRC -vf scale=1280:720 $X264 d_fixed_wh.mp4 # 写死宽和高
ffmpeg -i $SRC -vf scale=-2:720 $X264 e_fixed_h.mp4 # 只写高
ffmpeg -i $SRC -c copy -metadata:s:v rotate=0 g_meta.mp4 # 想用元数据清标记
ffmpeg -display_rotation 0 -i $SRC -c copy h_matrix.mp4 # 直接把标记清成 0
| 命令要点 | 输出尺寸 | 输出标记 | 存储像素 | 播放结果 |
|---|---|---|---|---|
| 什么都不加 | 1080×1920 | 无 | 正 | 正 |
| -noautorotate | 1920×1080 | -90 | 躺倒 | 认标记的播放器里正 |
| -c copy | 1920×1080 | -90 | 躺倒 | 认标记的播放器里正 |
| -vf scale=1280:720 | 1280×720 | 无 | 正但压扁 | 竖画面被压成横的 |
| -vf scale=-2:720 | 406×720 | 无 | 正 | 正 |
| -c copy -metadata:s:v rotate=0 | 1920×1080 | 仍是 -90 | 躺倒 | 标记没被清掉 |
| -display_rotation 0 加 -c copy | 1920×1080 | 无 | 躺倒 | 四个浏览器全部躺倒 |
代码说明:X264 是我固定下来的一组编码参数,七条命令只在方向相关的写法上有区别。表里「输出标记」一列是转完以后再用 ffmpeg 读一遍得到的,「存储像素」一列是加 -noautorotate 截一帧看到的。
第一行就是这一节的结论:什么参数都不加,ffmpeg 默认转码会把像素转正,输出是不带标记的 1080×1920。默认转成 VP9 的 WebM 也是 1080×1920、无标记。截一帧存成 JPG,默认拿到的同样是 1080×1920 的正图。ffmpeg 7.1 的默认行为和我在上一章的选择是一个方向。要让它保留躺倒的像素和标记,得明确写 -noautorotate 或者 -c copy。
7.2 固定宽高缩放会把竖画面压扁
第四行是脚本里很常见的写法,把视频统一缩到 1280×720。这样固定宽高缩放会把竖画面压扁,输出是一个不带标记的 1280×720 文件,里面的竖画面被硬塞成了横的。从结果看,缩放这一步拿到的已经是转正后的竖画面,然后被按写死的宽高拉成了横的。这个文件放到四个浏览器里 videoWidth 都是 1280×720,怎么放都是扁的。
第五行只写高,宽让 ffmpeg 自己算。输出是 406×720,画面是正的。差别只是把 1280 换成了 -2。还有一种组合我也试了,就是 -noautorotate 加上 scale=1280:720。输出是 1280×720、带 -90 标记、像素躺倒,它在认标记的播放器里是正的。这时候缩放拿到的是躺着的横图,横图缩成横的尺寸,比例没有坏。
7.3 只清标记不转像素四个浏览器全躺
最后两行是想「去掉旋转信息」的两种写法。第六行用元数据的方式写 rotate=0,转完再读标记仍是 -90,等于没改。第七行用 -display_rotation 0 配合 -c copy,标记真的清掉了,像素却原封不动地躺着。
只清标记不转像素的这个文件放进 video 标签,四个浏览器全躺,videoWidth 是 1920×1080。在这批测试里,MP4 能在播放这一步放出躺倒画面的只有这一个文件。它是转码时把包装上的字擦了、画面却没动造成的。上一章说的最差做法指的就是它。标记和像素得一起处理:要么都留,要么转完像素再去掉标记。
7.4 OpenCV 5.0.0默认按标记转正
第二样是 OpenCV,后端读帧、做分析的脚本里常见它。它有两个和方向有关的属性。CAP_PROP_ORIENTATION_META 是它从文件里读到的角度。CAP_PROP_ORIENTATION_AUTO 是要不要自动按这个角度把帧转正。
python
import cv2
for keep_raw in (False, True):
vc = cv2.VideoCapture('turbine_rot90.mp4')
if keep_raw:
vc.set(cv2.CAP_PROP_ORIENTATION_AUTO, 0)
hint = vc.get(cv2.CAP_PROP_ORIENTATION_META)
ok, shot = vc.read()
print(vc.get(cv2.CAP_PROP_ORIENTATION_AUTO), hint, shot.shape[1], shot.shape[0])
vc.release()
text
1.0 90.0 1080 1920
0.0 90.0 1920 1080
代码说明:循环跑两遍,第一遍什么都不改,第二遍把自动转正关掉。每一行输出依次是自动转正的开关、读到的角度、帧的宽和高。
从输出看,OpenCV 5.0.0 默认按标记转正:开关默认是 1,读出来的帧是 1080×1920 的正图。关掉以后是 1920×1080,顶朝左躺着。rot270 和 rot180 的样片也一样,默认是正的,关掉以后一个顶朝右、一个倒立,读到的角度分别是 270 和 180。我只测了 5.0.0 这一个版本。更早的版本默认值是什么没有测,不下结论。
7.5 浏览器端压缩工具的产物是竖着存的
第三样是在浏览器里完成整条流程的工具。我用的是图映 ImgIng 的视频压缩,它是一个在浏览器里用的图片与视频处理工具。测试在 Chromium 149 里做,档位选的是「体积优先」,其余保持默认。导入 turbine_rot90.mp4 以后,界面上「画面与体积」一栏写的是 1080 × 1920,这是转正后的宽高。下面预算那一行写的是 608×1080。
| 导入 | 输出格式 | 产物 | 存储像素 | 旋转标记 | 方向 |
|---|---|---|---|---|---|
| turbine_up.mp4 | MP4 H.264 | 608×1080 24 fps | 竖 | 无 | 正 |
| turbine_rot90.mp4 | MP4 H.264 | 608×1080 24 fps | 竖 | 无 | 正 |
| turbine_rot270.mp4 | MP4 H.264 | 608×1080 24 fps | 竖 | 无 | 正 |
| turbine_rot180.mp4 | MP4 H.264 | 608×1080 24 fps | 竖 | 无 | 正 |
| turbine_rot90.mov | MP4 H.264 | 608×1080 24 fps | 竖 | 无 | 正 |
| snow_rot90.mp4 带音轨 | MP4 H.264 加 AAC | 608×1080 24 fps | 竖 | 无 | 正 |
| turbine_rot90.mp4 | WebM VP9 | 608×1080 24 fps | 竖 | 无 | 正 |
一共跑了七次任务,覆盖三种角度、MP4 和 MOV 两种输入、MP4 和 WebM 两种输出。产物全是竖着存的:七个文件都是 608×1080,都不带旋转标记,方向都是正的。产物我是保存下来以后用 ffmpeg 读的,没有只看界面上的预览。

这张图是 turbine_rot90.mp4 压完以后的界面。中间的成品预览是竖的,红块在左上、绿块在右上。左下那行预算信息里能看到 608×1080 和 24.0 fps。右边写的 1.48 MB → 620 KB 按字节算是 1,549,770 到 634,583。这只是这段 5 秒样片在「体积优先」档的结果,换一段视频不会是同一个比例。有个小细节是压完并不会自动落盘,要再点一下「↓ 保存成品」才写到磁盘上。
这一节只看产物,工具内部哪一步做的旋转不展开。三样东西对照下来结论很整齐:ffmpeg 7.1、OpenCV 5.0.0 和这个浏览器端工具默认给出的都是转正的像素。
八、适用边界与风险
先说这些结论能用到哪里。数据全部来自一台 macOS 26.5 的机器和四个桌面浏览器,别的系统、别的版本我没有量。表里的每一格都只对应写明的那个版本号。Chromium 149 和 Chrome 154 在 createImageBitmap 上结果不同,这只是两个版本号下的两个读数。中间的版本我没有测,从哪一版开始不同我不知道,也不给这个差别定性。
样片的风险要单独提。所有带标记的样片都是 ffmpeg 写的标记,编码都是 H.264。手机拍出来的原片可能用 HEVC,也可能在别的位置还带着方向信息。这些我没有碰过。如果你拿这篇的结论去处理手机原片,建议先拿一段真实文件把第四章的代码跑一遍,跑完再决定信不信这张表。WebKit 26.5 是 macOS 上的构建,iPhone 上的 Safari 没有测。安卓上的 Chrome 和各种 App 里内嵌的浏览器也没有测。
还有一类风险来自「恰好是对的」。假设你平时只用新版 Chrome 开发,createImageBitmap 和 new VideoFrame(video) 在你机器上都是正的。代码看起来没有任何问题。换到 Firefox 151 两条路径都会躺,换到 Chromium 149 位图会被压扁。发布之前拿带标记的样片在每个要支持的浏览器里各跑一次,这比读任何表格都可靠。
最后是结论的时效。浏览器更新得很快,这张表记的是 2026-10-12 这一天的读数。过几个月再看某些格子很可能已经变了。表会过期,量的办法还能接着用。样片怎么做、角标怎么画、宽高和方向怎么一起核对,这一套随时可以重跑。
九、测量方法的坑
这一章记我自己在量的过程中踩到的地方。量方向看着简单,可测量手段本身会骗人,下面四处我都差点信了错的读数。
头一个坑是角点判定会把压扁判成正。我判方向的办法是读画布四个角的颜色,红块在左上、绿块在右上就记为正。Chromium 149 那张被压扁的位图,红块确实还在左上、绿块确实还在右上。程序给出的结论是「正」,单看这一列我会以为它没问题。后来我把宽高放在一起看,一张「正」的竖拍画面居然是 1920×1080,这才知道画面被压了。方向和尺寸要同时核对,任何一个单独拿出来都不够。
第二个坑和它相反,只看宽高会漏掉倒立。rot180 的样片存储像素是 1080×1920,转正以后还是 1080×1920。Firefox 151 的 createImageBitmap 对它给出倒立的图,尺寸一点没变。我当初坚持在样片上画红绿两个方块就是为了这种情况。只画一个方块也不行,一个记号分不清转了 180 度还是做了镜像。
第三个坑是记号会随着缩放失效。7.2 节那两个缩放过的文件,角点判定什么都没认出来,返回的是一个问号。原因出在取样的位置上,它是按 160×160 的方块定的。画面缩小以后方块跟着变小,取样点落到了方块外面。那两行的「正但压扁」和「正」是我对着截出来的画面看的,不是程序判的。要做成自动回归的话取样位置得按输出尺寸的比例来算。
第四个坑是工具之间的口径。ffmpeg 打印的流尺寸对带标记的文件是 1920x1080,这是存储尺寸,它不会替你换算成显示尺寸。角度的符号前面已经说过,同一个文件 ffmpeg 写 -90、OpenCV 写 90。我这台机器上没有 ffprobe,它的输出格式我没有单独看过。另外自己解封装那条路径我只对 MP4 和 MOV 做了,带标记的 WebM 没有走这条路,只量了 video 标签播放和从 video 元素取帧的读法。
这四处之外还有一条让我少走了弯路的做法值得记一笔,就是从头到尾带着对照组。像素竖着存、不带标记的那两段样片我每一轮都跟着一起跑。它们在所有路径、所有浏览器里都是正的。有了这一组全对的读数,我才敢说躺倒和压扁是标记引起的,而不是我的取帧代码或者角点判定自己有问题。
十、还没解决的
最大的一块空白是真实的手机原片。这次的标记全是 ffmpeg 写的,矩阵干干净净,只有四种取值。手机直出的文件矩阵是不是也这么规整?HEVC 的原片有没有别的讲究?我手上没有可以公开引用的样本,这次就没碰。前置摄像头拍的视频可能带镜像,矩阵会是另一个样子。只取前两个数算角度的办法遇到镜像就不够用了,这种情况我还没有处理。
第二块是导出时自己写标记。6.1 和 6.2 两节里带标记的文件都是 ffmpeg 生成的。在浏览器里用 JavaScript 的封装库往新文件里写这个矩阵,写出来四个浏览器认不认我没有量过。我自己走的是转进像素那条路,这块空白暂时不挡我的事,可它确实是空着的。第三块是 MKV。ffmpeg 用 -c copy 把样片搬进 MKV 以后标记还在,这个文件我没有放到浏览器里看。
浏览器之间为什么不一样我也没有答案。同一段 createImageBitmap 的代码,两个版本号一个压扁一个正常。我只能把读数摆出来。
如果你正在写绕开 video 标签的视频处理代码,可以按这个顺序自查一遍。先用 ffmpeg 给一段竖视频加上 90、270、180 三种旋转标记,再画上两个颜色不同的角标。然后把取帧代码在每个要支持的浏览器里各跑一次,跑的时候同时核对宽高和角标位置。自己解封装的话先从轨道头的矩阵读出角度,画布宽高按角度对调。导出之前把每一帧转正再编码,别让标记和像素分头行动。
参考资料
- ISO/IEC 14496-12(ISO 基础媒体文件格式):Track Header Box 的 matrix 字段定义
- W3C WebCodecs:VideoDecoder、VideoFrame 的 codedWidth、displayWidth 与 rotation
- MDN:HTMLVideoElement.videoWidth、CanvasRenderingContext2D.drawImage、createImageBitmap
- mp4box.js 0.5.2 项目说明
- FFmpeg 7.1 文档:-noautorotate、-display_rotation 与 scale 滤镜
- OpenCV 5.0.0 文档:CAP_PROP_ORIENTATION_AUTO 与 CAP_PROP_ORIENTATION_META
- 风机画面:维基共享资源「Vertical-axis wind turbine at Hartnell College Alisal Campus.gk.webm」作者 Grendelkhan(CC BY-SA 4.0)
- 下雪画面:维基共享资源「Snowfall in Freiburg Switzerland.ogv」作者 Canbel(CC BY-SA 4.0)
- 本文实测数据:2026-10-12 本机运行记录,环境见「版本声明」