在前端开发中处理视频录制需求时,经常遇到的一个问题是:摄像头采集的画面比例和目标录制比例不一致。比如摄像头输出的是16:9的1920x1080画面,而产品要求录制出1:1的正方形视频,或者需要固定输出640x480的4:3画面。这时候就必须在录制前对画面进行缩放处理,而浏览器并没有提供一个现成的Video Scaling Mode API,需要开发者借助canvas作为中间层,自己实现缩放逻辑。用TypeScript来做这件事的好处非常明显:缩放模式、参数对象、录制配置都可以被严格约束,避免拼错字符串导致的运行时错误。

为什么需要自定义缩放模式类型
熟悉CSS的同学都知道,图片有object-fit属性,视频元素本身也支持。但MediaRecorder录制的不是DOM元素,而是媒体流。如果直接把getUserMedia得到的视频流交给MediaRecorder,录制出来的画面比例就是摄像头原始比例,无法改变。要控制输出比例,标准做法是把视频绘制到canvas上,在绘制阶段完成缩放,再把canvas的流交给MediaRecorder。
缩放逻辑本身有几种典型策略:等比缩放完整保留画面(可能出现黑边)、等比缩放裁掉超出部分(类似CSS的cover)、直接拉伸填满(会变形)。这三种策略在各个平台上命名不统一,Android有SCALE_TO_FIT这样的常量,iOS的AVPlayer有resize、resizeAspect、resizeAspectFill。在TypeScript中我们可以定义一套自己的类型系统,让这些模式有明确的类型身份,调用方只能从固定几个值里选择,写错了编译器会直接报错,这就比到处传魔法字符串可靠得多。
定义ScalingMode类型与配置接口
首先定义缩放模式的联合类型。用字符串字面量联合类型比枚举更轻量,而且调试时在控制台看到的值是可读的字符串。接着定义录制器的配置接口,把目标宽高、帧率、MIME类型都纳入类型约束。MIME类型可以用MediaRecorder.isTypeSupported做运行时校验,同时用类型系统保证传入的配置对象结构正确。
/** 视频缩放模式 */
type VideoScalingMode =
| 'contain' // 等比缩放,完整显示,留黑边
| 'cover' // 等比缩放,裁剪溢出部分,填满画布
| 'fill'; // 直接拉伸,可能变形
/** 录制配置 */
interface ScaleRecordOptions {
targetWidth: number;
targetHeight: number;
scalingMode: VideoScalingMode;
frameRate?: number;
mimeType?: string;
}
/** 默认配置 */
const DEFAULT_OPTIONS: Required<ScaleRecordOptions> = {
targetWidth: 720,
targetHeight: 720,
scalingMode: 'cover',
frameRate: 30,
mimeType: 'video/webm;codecs=vp9',
};这里有几个细节值得说明。Required<T>把所有可选属性变成必选,配合展开运算符可以方便地实现默认值合并。类型检查能保证scalingMode只接受三个值之一,假如调用方误写成'covers',TypeScript编译阶段就会提示类型不匹配,而不必等到测试时才发现画面没有按预期处理。
实现三种缩放算法并录制canvas流
缩放算法的核心是根据源视频的宽高和目标画布的宽高,计算绘制区域的位置和尺寸。三种模式对应三组计算公式:contain模式取宽高缩放比例中较小的一个,cover模式取较大的一个,fill模式直接铺满整个画布。计算完成后,用requestAnimationFrame循环绘制,并通过canvas.captureStream拿到可录制的媒体流。
function computeDrawRect(
srcW: number, srcH: number, opts: Required<ScaleRecordOptions>
): { x: number; y: number; w: number; h: number } {
const { targetWidth: tw, targetHeight: th, scalingMode } = opts;
if (scalingMode === 'fill') {
// 拉伸填满,不做等比处理
return { x: 0, y: 0, w: tw, h: th };
}
const scaleW = tw / srcW;
const scaleH = th / srcH;
// contain取较小比例,cover取较大比例
const ratio = scalingMode === 'contain'
? Math.min(scaleW, scaleH)
: Math.max(scaleW, scaleH);
const w = srcW * ratio;
const h = srcH * ratio;
// 居中放置,contain会产生黑边,cover会超出画布被裁掉
return { x: (tw - w) / 2, y: (th - h) / 2, w, h };
}
async function startScaledRecording(
stream: MediaStream, options: Partial<ScaleRecordOptions> = {}
): Promise<MediaRecorder> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const video = document.createElement('video');
video.srcObject = stream;
video.muted = true;
await video.play();
const canvas = document.createElement('canvas');
canvas.width = opts.targetWidth;
canvas.height = opts.targetHeight;
const ctx = canvas.getContext('2d')!;
// 黑边模式先填充黑色背景
if (opts.scalingMode === 'contain') {
ctx.fillStyle = '#000';
}
const render = (): void => {
const rect = computeDrawRect(
video.videoWidth, video.videoHeight, opts
);
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(video, rect.x, rect.y, rect.w, rect.h);
requestAnimationFrame(render);
};
requestAnimationFrame(render);
const canvasStream = canvas.captureStream(opts.frameRate);
const recorder = new MediaRecorder(canvasStream, {
mimeType: opts.mimeType,
});
recorder.start(1000);
return recorder;
}调用端的代码非常简洁,先通过getUserMedia拿到摄像头流,然后指定目标尺寸和缩放模式即可开始录制。停止录制时调用recorder.stop(),把ondataavailable事件收集到的Blob合并成文件。由于函数签名中options是Partial<ScaleRecordOptions>类型,调用方可以只传想覆盖的字段,其余自动使用默认值,这就是类型定义带来的使用体验提升。
async function demo(): Promise<void> {
const camera = await navigator.mediaDevices.getUserMedia({
video: { width: 1920, height: 1080 },
audio: true,
});
const recorder = await startScaledRecording(camera, {
targetWidth: 720,
targetHeight: 720,
scalingMode: 'contain',
});
const chunks: Blob[] = [];
recorder.ondataavailable = (e: BlobEvent): void => {
if (e.data.size > 0) chunks.push(e.data);
};
recorder.onstop = (): void => {
const blob = new Blob(chunks, { type: recorder.mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'recorded.webm';
a.click();
URL.revokeObjectURL(url);
};
// 录制10秒后自动停止
setTimeout((): void => recorder.stop(), 10000);
}有几个容易踩的坑需要注意。第一,video.play()必须等待完成再开始绘制,否则videoWidth还是0,画面会一直是黑的;第二,contain模式记得在每帧绘制前用fillRect清空画布并填充背景色,否则上一帧残留的影像会叠加;第三,MIME类型在不同浏览器支持情况不同,Safari对WebM支持不完整,建议通过MediaRecorder.isTypeSupported做特性检测,回退到video/mp4。把这些处理封装进类型化的工具函数后,整个录制模块就可以在各种比例需求的场景下安全复用了。
TypeScriptMediaRecorder视频缩放模式修改时间:2026-09-16 20:24:46