Skip to content

网页摄像头采集分辨率横竖屏异常:从 448×800 被裁剪谈起

更新: 6/27/2026 字数: 0 字 时长: 0 分钟

一份面向前端开发者的 WebRTC getUserMedia 分辨率适配踩坑文档

一、问题背景

我们要解决的是一个具体而典型的现象:同一台设备、同一个摄像头,用 getUserMedia 请求 448×800 时,浏览器返回的画面是把摄像头横向内容裁剪后挤成的竖屏——人脸两侧被切掉、内容变形;而请求 1080×1920 时,却能拿到正常完整的竖屏画面。

更让人困惑的是,通过 track.getCapabilities() 查询,该摄像头明明报告自己最大能采集 1920×1920。既然 1920×1920 这个"正方形"的能力都有,为什么低分辨率的 448×800 反而出问题?

核心矛盾就在这里:getCapabilities 报告的是"能力边界(范围)",不是"真实可用的离散分辨率组合"。 浏览器在拿到一个它无法原生采集的分辨率请求时,会用"就近匹配 + 裁剪缩放"的策略去凑,而这个凑的过程,正是横竖屏异常的根源。

网页摄像头分辨率横竖屏之谜

二、踩坑点汇总

在动手分析原因之前,先把这一类问题里最容易踩的五个坑列清楚,方便对照自查:

  1. 坑 1:误把 getCapabilities 的最大值当成"想要多大就给多大"。 它返回的是 {min, max} 区间,代表硬件 ISP 能力上限,不代表任意中间值都能原生输出。

  2. 坑 2:把宽高的独立范围,当成可以任意组合的有效分辨率。 width.max=1920height.max=1920 是两个独立维度的范围,并不意味着 448×800 这样的具体组合存在于摄像头的原生采集模式列表里。

  3. 坑 3:低分辨率请求被浏览器悄悄裁剪而无报错。 浏览器为满足约束会做 downscale + crop,过程静默、不抛异常,开发者很难第一时间意识到画面已被动过手脚。

  4. 坑 4:忽略摄像头只有有限几种"原生采集模式"(native capture formats)。 传感器实际只按少数几组固定分辨率/比例出图,其余分辨率都是软件二次加工的结果。

  5. 坑 5:请求的分辨率宽高比与摄像头当前朝向/原生比例不符。 当请求比例和原生帧比例对不上时,浏览器只能裁掉多余的边,这就是"横屏内容被切成竖屏"的直接表现。

踩坑点汇总

三、错误原因拆解

3.1 getCapabilities 到底报告了什么

getCapabilities() 返回的是一组约束范围,典型结构如下:

js
const caps = track.getCapabilities();
// {
//   width:  { min: 1, max: 1920 },
//   height: { min: 1, max: 1920 },
//   aspectRatio: { min: 0.000xxx, max: 1920 },
//   frameRate:  { min: 1, max: 30 },
//   ...
// }

关键认知:width.max=1920height.max=1920两个相互独立的一维区间。它"声明"了传感器在某个方向上最多能到 1920 像素,但绝不等于 (任意宽 ≤1920) × (任意高 ≤1920) 的笛卡尔积都是合法的原生采集模式。所谓"最大 1920×1920",只是把两个维度的上限拼在一起得到的能力包络,而非一个真实存在的输出格式。

3.2 摄像头其实只有几种"原生采集模式"

物理传感器(sensor)+ ISP 管线,出厂时只固化了有限几组原生采集格式,例如常见的:

  • 1920×1080(16:9 横向)
  • 1280×720(16:9 横向)
  • 640×480(4:3 横向)
  • 1920×1920(1:1,本例硬件支持的最大正方形)

注意:绝大多数手机/笔记本前置摄像头,传感器的原生出图方向是横向(landscape)。所谓"竖屏",在很多情况下是上层对横向帧做旋转/裁剪后呈现的结果。

3.3 为什么 1080×1920 正常,而 448×800 被裁

当你请求一个分辨率时,浏览器的处理逻辑大致是:在原生采集模式里找一个最接近、且能覆盖请求尺寸的模式,然后通过缩放(scale)和裁剪(crop)凑出你要的目标。

  • 请求 1080×1920(9:16 竖屏): 浏览器找到原生的 1920×1080,做一次旋转得到 1080×1920,比例 9:16 恰好吻合,可以整帧缩放、几乎不裁剪就对齐目标——所以画面完整正常。换句话说,1080×1920 命中了一条"友好路径"。

  • 请求 448×800(比例 ≈ 9:16,但尺寸很小): 这里有两层问题叠加:

    1. 摄像头没有 448×800 这样的小尺寸原生模式,浏览器只能拿一个大的横向原生帧(如 1920×1080)去 downscale。
    2. downscale 过程中,源帧是横向 16:9,目标是竖向 9:16,比例方向相反。浏览器为了塞进竖向画布,会沿宽度方向把横向画面的左右两侧裁掉,只保留中间一条,再缩放到 448×800。结果就是"横屏内容被裁成竖屏",人脸两侧丢失、主体变形。

本质上,两个请求走了不同的内部匹配分支:1080×1920 命中了可整帧对齐的路径,而 448×800 落入了"无原生模式 → 从横向帧强行裁切"的路径。问题不在分辨率高低本身,而在于请求尺寸/比例与原生采集模式的对齐程度,以及浏览器在不匹配时静默裁剪的行为。

错误原因拆解

四、正确规避方案

下面是一套可直接落地的实践路径,核心思路是:用高分辨率原生模式采集,自己掌控缩放裁剪,并始终以 getSettings 校验真实结果。

4.1 用高分辨率友好模式采集,而非直接请求小尺寸

不要直接向 getUserMedia 请求 448×800。改为请求一个能命中原生模式的高分辨率(如 1080×1920),拿到完整、未被意外裁剪的画面:

js
const stream = await navigator.mediaDevices.getUserMedia({
  video: {
    width:  { ideal: 1080 },
    height: { ideal: 1920 },
    // 用 ideal 而非 exact,给浏览器协商空间,避免 OverconstrainedError
  }
});

4.2 自己用 Canvas / CSS 缩放裁剪到目标尺寸

需要 448×800 的最终画面时,在拿到高分辨率视频流后,由你自己通过 Canvas 绘制或 CSS object-fit 控制裁剪方式,而不是把裁剪决策权交给浏览器的黑盒:

js
// Canvas 方式:可精确控制裁哪一块、怎么缩放
const canvas = document.createElement('canvas');
canvas.width = 448; canvas.height = 800;
const ctx = canvas.getContext('2d');

function drawFrame() {
  // 自行计算 sx,sy,sWidth,sHeight 决定从源帧裁切区域
  ctx.drawImage(video, sx, sy, sWidth, sHeight, 0, 0, 448, 800);
  requestAnimationFrame(drawFrame);
}

这样裁剪逻辑透明可控,绝不会出现"内容被悄悄切掉"的意外。

4.3 始终用 getSettings() 校验真实生效的分辨率

约束是"请求",getSettings() 才是"实际生效值"。任何采集后都应校验:

js
const track = stream.getVideoTracks()[0];
const settings = track.getSettings();
console.log(settings.width, settings.height, settings.aspectRatio);
// 用真实值判断是否需要后续处理,不要假设请求值 = 实际值

4.4 显式声明 aspectRatioresizeMode

通过 resizeMode 明确告知浏览器你能否接受裁剪;aspectRatio 帮助协商到比例匹配的模式:

js
video: {
  width:  { ideal: 1080 },
  height: { ideal: 1920 },
  aspectRatio: { ideal: 9 / 16 },
  resizeMode: 'none'   // 'none' 倾向原生不缩放;'crop-and-scale' 允许裁剪缩放
}

resizeMode: 'none' 让你更接近拿到原生帧,从而把裁剪控制权收回到自己手里。

4.5 探测真实可用的分辨率组合

不要信任 getCapabilities 的范围去推断组合。用"逐个尝试 + getSettings 回读"的方式,建立该设备真实可用的离散分辨率清单:

js
const candidates = [[1920,1920],[1920,1080],[1080,1920],[1280,720],[640,480]];
for (const [w, h] of candidates) {
  try {
    const s = await navigator.mediaDevices.getUserMedia({
      video: { width: { exact: w }, height: { exact: h } }
    });
    const got = s.getVideoTracks()[0].getSettings();
    console.log(`请求 ${w}x${h} → 实际 ${got.width}x${got.height}`);
    s.getTracks().forEach(t => t.stop());
  } catch (e) {
    console.log(`${w}x${h} 不支持:`, e.name);
  }
}

正确规避方案

五、总结提醒

把这次踩坑沉淀成几条可以反复套用的原则:

  • getCapabilities 只报"范围",不报"真实可用组合"。 看到 max: 1920×1920 不要理解成任意尺寸都能原生输出,它只是两个独立维度的上限拼接。

  • 低分辨率请求最危险,而非最安全。 越小、越偏离原生模式的尺寸,越容易触发浏览器的静默 downscale + crop,横竖屏不匹配时直接从横向帧里切一条出来。

  • 高分辨率采集 + 自行缩放裁剪是最稳的链路。 先用能命中原生模式的尺寸(如 1080×1920)拿到完整帧,再用 Canvas/CSS 把裁剪决策权握在自己手里。

  • 永远用 getSettings() 验证,不要假设"请求即所得"。 约束是协商意向,实际生效值必须回读确认。

  • 比例方向比像素数量更关键。 横向传感器配竖向请求时,先想清楚比例如何对齐,再决定旋转还是裁剪,避免主体内容被默默切掉。

记住一句话:摄像头给你的是"它愿意按原生模式出的帧",浏览器只是帮你"凑"你要的尺寸——凑的过程不透明,所以越关键的画面,越要自己掌控采集与裁剪的每一步。

总结提醒