浏览器端素材压缩与上传实践:从 Canvas 到 WebCodecs 与 FFmpeg 回退
🧑💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣
素材上传不只是把 File 放进 FormData。图片可能分辨率过高,视频可能超过体积上限,不同设备的编解码能力也各不相同。合理的上传链路应先检查素材、在需要时征得用户同意、执行处理并校验结果,最后才发送网络请求。
本文以图片目标不超过 2MiB、超限视频目标不超过 20MiB 为例,介绍一套浏览器端素材处理方案:图片通过 Canvas 等比缩放和质量压缩;视频优先采用 WebCodecs,请求硬件加速,并通过分辨率降级、浏览器默认编码、FFmpeg WebAssembly 建立多层回退。
下文代码为突出技术逻辑的简化示例,不是可以直接复制运行的完整应用。界面通常标注 MB,但
20 * 1024 * 1024实际对应 20MiB,前后端需要统一口径。
一、先把上传拆成几个阶段
完整链路可以分为:
选择素材
├─ 通用检查:文件类型、文件名、数量等
├─ 图片
│ └─ 读取尺寸 → 等比缩放 → 质量压缩 → 体积检查
└─ 视频
├─ 未超过体积上限 → 继续业务检查
└─ 超过体积上限 → 批量确认
├─ 取消 → 不上传这些超限视频
└─ 确认 → 排队 → 读取媒体信息 → 选择编码路径
→ 转码 → 校验 → 必要时调整码率重试
↓
将处理成功的文件交给上传队列
↓
发送请求 → 等待服务端响应 → 完成或失败
这里有两个重要边界:
- 压缩成功不等于上传成功,两者是不同任务。
- 网络资源返回 200 不等于压缩成功,例如 WASM 下载成功,只表示引擎二进制已经获取。
对于视频,时长等业务规则应独立于编码模块。编码模块负责技术约束,不应该顺手新增或取消业务限制。
二、图片处理:等比缩放,不裁剪、不拉伸
2.1 按最长边计算缩放比例
假设原图为 width × height,最长边上限为 1920 像素:
const scale = Math.min(1, 1920 / Math.max(width, height));
const targetWidth = Math.max(1, Math.round(width * scale));
const targetHeight = Math.max(1, Math.round(height * scale));
使用相同缩放系数能保留原图比例。Math.min(1, ...) 则避免把小图放大。
例如,4000×3000 的图片缩小后为 1920×1440,而不是强制转换为 9:16。保留完整画面与强制填满固定比例区域,是两种不同需求。
2.2 Canvas 负责重采样与重新编码
canvas.width = targetWidth;
canvas.height = targetHeight;
context.drawImage(image, 0, 0, targetWidth, targetHeight);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => {
if (result) resolve(result);
else reject(new Error('图片编码失败'));
},
'image/jpeg',
0.9
);
});
Canvas 先生成目标尺寸的像素,再通过 toBlob() 导出压缩结果。JPEG 的质量参数会影响文件体积与视觉质量,但不存在“质量 0.8 就一定小于 2MiB”的保证。
PNG 通常不受有损质量参数控制,因此单纯不断降低 quality,未必能减小 PNG 体积。
2.3 先降低质量,再缩小尺寸
推荐策略是:
- 图片尺寸和体积均达标时,直接复用原文件。
- 尺寸超限时,先按最长边缩小。
- 尝试保留原图片格式导出。
- 仍超限时转成 JPEG,逐步降低质量。
- 达到质量下限仍超限,再缩小尺寸,并重新绘制、编码。
- 每次都检查真实
Blob.size,不能只相信参数。
while (blob.size > maxBytes) {
blob = await encodeJpeg(quality);
if (blob.size <= maxBytes) break;
if (quality > 0.5) {
quality = Math.max(0.5, quality - 0.1);
} else {
targetLongestSide = Math.max(1, Math.floor(targetLongestSide * 0.85));
redrawAt(targetLongestSide);
quality = 0.9;
}
}
转换为 JPEG 时需要处理透明背景。可以先填充白色,再绘制原图,避免透明区域出现非预期颜色。格式改变后,文件扩展名和 MIME 类型也应同步改变。
三、超限视频为什么要先确认
视频压缩会消耗 CPU、GPU 和内存,也可能降低画质。用户选择超限文件后,不宜立即启动高成本转码。
对于一次选择的多个文件,可以先收集超限视频,再合并弹框:
上传的素材中,A.mp4、B.mp4 超出20MB,是否由前端进行压缩后上传?
提供两个明确动作:
- 压缩后上传:只对用户确认的超限视频创建处理任务。
- 取消并不上传:跳过这些超限视频,不影响其他正常素材。
批量收集可以使用微任务或短暂的批次调度,把同一轮选择产生的多个上传钩子合并处理。每个待处理文件都需要保存自己的完成回调,避免取消弹框后仍有 Promise 一直等待。
四、视频转码涉及哪些技术
一个 MP4 通常包含压缩后的视频轨道和音频轨道。处理它需要完成:
解封装 → 解码 → 画面缩放 → 视频/音频编码 → MP4 封装
相关技术分工如下:
| 技术 | 主要职责 |
|---|---|
| WebCodecs | 调用浏览器音视频解码器、编码器 |
| Mediabunny | 读取媒体容器、轨道与时间戳,组织转换和 MP4 封装 |
| WebAssembly + FFmpeg | 浏览器原生能力不足时的软件转码 |
| Web Worker | 将 FFmpeg 运算放到独立线程,减少对主线程的直接阻塞 |
| AbortController | 统一取消排队、读取和编码任务 |
WebGPU 或 WebGL 可以参与像素缩放、滤镜等画面处理,但本身不是 H.264 编码器。硬件视频编码的入口是浏览器提供的媒体编码能力,而不是给 Canvas 加一个 GPU 参数。
另外,Worker 不等于硬件加速。FFmpeg WASM 即使运行在 Worker 中,使用 libx264 时仍属于 CPU 软件编码。
五、目标输出:MP4、H.264、AAC 和 4:2:0
这些名词约束不同层面:
- MP4:容器格式,负责组织轨道、时间戳和索引。
- H.264 / AVC:视频压缩编码。
- AAC:音频压缩编码;无音轨的视频无需凭空添加音轨。
- 8-bit 4:2:0:视频位深与色度采样要求。
- yuv420p:FFmpeg 中常用的 8-bit 平面 YUV 4:2:0 像素格式名称。
在 WebCodecs 路径中,不能像 FFmpeg 一样简单设置 -pix_fmt yuv420p。应检测最终 H.264 码流是否满足 8-bit 4:2:0;这与强制编码前帧的内存布局是不同概念。
4:2:0 也不等于 BT.709 等完整色彩标准。如果业务还要求色域、传递函数或色彩矩阵,必须另行明确。
六、先计算体积预算,再选择码率
视频文件大小大致可以估算为:
文件大小 ≈ 时长 ×(视频码率 + 所有音轨码率之和)÷ 8 + 封装开销
因此,限制大小不能只设置固定分辨率,也不能只靠固定质量参数。可以根据目标字节数与时长计算视频码率:
const safetyRatio = 0.95;
const audioBitrate = 96_000;
const videoBitrate =
Math.floor((maxBytes * safetyRatio * 8) / duration) -
audioTrackCount * audioBitrate;
预留 5% 空间是为了吸收封装开销和码率控制偏差,不是绝对保证。高运动场景、关键帧和不同编码器的码率实现都会影响真实体积。
如果码率预算已经低到不可用,应明确失败,而不是偷偷截断视频、删除音轨或上传超限文件。
七、WebCodecs:先检测能力,再编码
7.1 浏览器版本不是唯一条件
WebCodecs 通常需要 HTTPS 或 localhost 等安全上下文。即使是最新版 Chrome,实际支持情况仍受操作系统、显卡驱动、编码器、分辨率和码率等影响。
检测应逐层进行:
- 当前环境是否安全,编码和解码 API 是否存在。
- 原视频和音轨能否解码。
- 原音轨声道数、采样率下能否编码 AAC。
- 指定尺寸、码率下能否编码 H.264。
- 转换初始化后是否存在被丢弃的轨道。
单个配置不支持,不代表整个 WebCodecs 不可用。
7.2 建立分辨率与编码偏好的降级矩阵
推荐的能力检测顺序是:
prefer-hardware
├─ 原目标尺寸
├─ 最长边 1280
├─ 最长边 960
└─ 最长边 640
↓ 全部不支持
no-preference
├─ 原目标尺寸
├─ 最长边 1280
├─ 最长边 960
└─ 最长边 640
↓ 全部不支持
FFmpeg 软件回退
不应放大原素材,候选尺寸需要去重,并采用偶数宽高。偶数取整可能引入极小比例误差,应使用 contain 等方式避免裁掉画面。
for (const hardwareAcceleration of [
'prefer-hardware',
'no-preference',
] as const) {
for (const size of candidateSizes) {
signal.throwIfAborted();
const config = { ...size, bitrate, hardwareAcceleration };
if (await canEncodeVideo('avc', config)) {
return config;
}
}
}
throw new Error('没有可用的 H.264 编码配置');
这里选择串行检测:命中后立即停止,避免同时初始化多组编码器。
7.2.1 生成候选分辨率:限制最长边、偶数对齐与去重
下面把候选尺寸生成拆成独立函数。输入使用媒体轨道的显示尺寸,使竖屏和横屏采用相同规则;有旋转元数据的素材,需要由媒体库正确处理显示方向。
type Size = { width: number; height: number };
function createCandidateSizes(
sourceWidth: number,
sourceHeight: number,
maxEdge = 1920
): Size[] {
if (
!Number.isFinite(sourceWidth) ||
!Number.isFinite(sourceHeight) ||
sourceWidth < 2 ||
sourceHeight < 2 ||
!Number.isFinite(maxEdge) ||
maxEdge < 2
) {
throw new Error('无效的视频尺寸');
}
const sourceEdge = Math.max(sourceWidth, sourceHeight);
const firstEdge = Math.min(sourceEdge, maxEdge);
const edges = [firstEdge, 1280, 960, 640].filter(
(edge, index, all) => edge <= firstEdge && all.indexOf(edge) === index
);
const seen = new Set<string>();
const sizes: Size[] = [];
for (const edge of edges) {
const scale = edge / sourceEdge;
// 向下取偶数:不超过源尺寸与最长边预算。
const width = Math.floor((sourceWidth * scale) / 2) * 2;
const height = Math.floor((sourceHeight * scale) / 2) * 2;
// 极端长宽比可能让短边不足两个像素,不应强制放大短边。
if (width < 2 || height < 2) continue;
const key = `${width}x${height}`;
if (seen.has(key)) continue;
seen.add(key);
sizes.push({ width, height });
}
if (!sizes.length) throw new Error('没有可用的偶数编码尺寸');
return sizes;
}
console.table(createCandidateSizes(1920, 1012));
// 1920 × 1012
// 1280 × 674
// 960 × 506
// 640 × 336
console.table(createCandidateSizes(1080, 1920));
// 1080 × 1920
// 720 × 1280
// 540 × 960
// 360 × 640
该示例选择向下取偶数;四舍五入取偶数也可用于编码,但应统一生成、实际转换与输出校验的规则。偶数对齐后,输出矩形与原图可能有极小比例差异,转换时使用 contain 保留完整画面,必要时产生很窄的补边,而不是强制拉伸。
如果源视频最长边只有 800 像素,候选只包含原尺寸与最长边 640 的档位,不会生成 1280 或 960 的放大版本。
7.2.2 串行探测:硬件优先耗尽后,再尝试默认编码
以下示例使用 Mediabunny 的 canEncodeVideo 检测能力。将探测函数作为参数传入,也便于使用模拟结果验证降级顺序。
import { canEncodeVideo } from 'mediabunny';
type Acceleration = 'prefer-hardware' | 'no-preference';
type Candidate = Size & {
bitrate: number;
hardwareAcceleration: Acceleration;
};
type Probe = (config: Candidate) => Promise<boolean>;
function checkAbort(signal: AbortSignal): void {
if (signal.aborted) {
throw signal.reason ?? new DOMException('已取消', 'AbortError');
}
}
async function selectEncodingConfig(
sizes: Size[],
bitrate: number,
signal: AbortSignal,
probe: Probe = (config) => canEncodeVideo('avc', config)
): Promise<Candidate | null> {
if (!Number.isFinite(bitrate) || bitrate <= 0) {
throw new Error('无效的视频码率');
}
const modes: Acceleration[] = ['prefer-hardware', 'no-preference'];
for (const hardwareAcceleration of modes) {
for (const size of sizes) {
checkAbort(signal);
const candidate: Candidate = {
...size,
bitrate,
hardwareAcceleration,
};
try {
const supported = await probe(candidate);
checkAbort(signal);
console.debug('[编码探测]', { ...candidate, supported });
if (supported) return candidate;
} catch (error) {
// 取消不是兼容性失败,必须立即停止,不能继续下一档或软件回退。
checkAbort(signal);
console.warn('[编码探测异常,继续下一配置]', {
...candidate,
error,
});
}
}
}
return null;
}
这里的顺序有意优先保留硬件加速机会:即使原尺寸默认编码可用,也先尝试较低分辨率的硬件优先配置。这是性能与清晰度之间的产品选择,而不是 WebCodecs 强制要求的顺序。
能力探测阶段不逐档执行完整转码,因此不会为每个候选都生成一个视频。不过检测调用本身仍有成本,不宜无节制增加档位。
此处各档使用相同的体积预算码率。降低分辨率主要减少每帧像素处理量、提高配置兼容性;它不会自动保证输出文件更小。若需要随像素数限制码率,应另行制定画质策略,最终仍以实际输出大小为准。
7.2.3 选择结果与实际编码、输出校验保持一致
async function encodeWithFallback(
source: File,
sourceSize: Size,
bitrate: number,
signal: AbortSignal
) {
const sizes = createCandidateSizes(sourceSize.width, sourceSize.height);
const selected = await selectEncodingConfig(sizes, bitrate, signal);
checkAbort(signal);
if (!selected) {
// encodeSoftware 是应用提供的 FFmpeg 编码适配器。
return encodeSoftware(source, { ...sizes[0], bitrate, signal });
}
console.info('[选中编码配置]', selected);
try {
// encodeBrowser 内部将 selected 传给实际 Conversion 视频配置。
const file = await encodeBrowser(source, selected, signal);
checkAbort(signal);
// 必须返回实际尺寸,不能只把 File 交回上层。
return {
file,
expectedSize: { width: selected.width, height: selected.height },
engine: 'webcodecs',
};
} catch (error) {
checkAbort(signal);
console.warn('[浏览器编码执行失败,转软件编码]', { selected, error });
return encodeSoftware(source, { ...sizes[0], bitrate, signal });
}
}
// 调度层读取结果后,对照本次编码器返回的实际目标尺寸校验。
const result = await encodeWithFallback(source, sourceSize, bitrate, signal);
const actual = await inspectMedia(result.file);
if (
actual.width !== result.expectedSize.width ||
actual.height !== result.expectedSize.height
) {
throw new Error('输出尺寸与本次选中的配置不一致');
}
encodeBrowser、encodeSoftware 与 inspectMedia 是示意适配器:软件编码也应返回相同结构,结果检查还需覆盖体积、时长、编码和音轨。软件回退使用哪一档尺寸应明确指定;示例选择初始目标尺寸,不隐式沿用最后一次失败探测的 640 像素档位。
这段示例展示的是“能力探测降级 + 执行失败转软件”,不是“每次实际编码失败后再把所有尺寸完整转码一遍”。后者可能放大等待成本,如需引入必须限制尝试次数。
7.2.4 用模拟探测验证顺序
不依赖特定显卡,也可以验证选择器是否按预期工作:
const calls: Candidate[] = [];
const selected = await selectEncodingConfig(
createCandidateSizes(1920, 1012),
4_000_000,
new AbortController().signal,
async (config) => {
calls.push(config);
return (
config.hardwareAcceleration === 'no-preference' &&
config.width === 1280
);
}
);
console.assert(calls.length === 6, '应先检测四档硬件,再检测两档默认配置');
console.assert(selected?.width === 1280, '应选择默认编码的 1280 档');
console.assert(selected?.hardwareAcceleration === 'no-preference');
还应覆盖原尺寸命中后停止、低分辨率源文件不放大、所有配置失败返回 null、单次探测异常继续,以及取消后不触发软件回退。模拟检测能验证调度逻辑,但不能代替真实浏览器的编码、音画同步和显卡兼容性验证。
prefer-hardware 只是偏好,并不能证明实际使用了 GPU;no-preference 则让浏览器选择实现,也可能使用软件编码。界面应使用“硬件优先”或“浏览器编码”,不能把能力检测通过直接描述为“已启用 GPU”。
7.3 把检测通过的配置用于实际转换
const conversion = await Conversion.init({
input,
output,
video: {
codec: 'avc',
...selectedConfig,
fit: 'contain',
forceTranscode: true,
},
audio: {
codec: 'aac',
bitrate: 96_000,
forceTranscode: true,
},
});
if (!conversion.isValid || conversion.discardedTracks.length > 0) {
throw new Error('转换配置无效,或存在无法保留的轨道');
}
conversion.onProgress = reportProgress;
await conversion.execute();
能力检测成功与执行成功不是同一件事。实际编码仍可能因为资源不足、源数据异常或设备限制失败,因此还需要运行时回退。
八、FFmpeg WASM:兼容性兜底,不是免费加速
只有 WebCodecs 路径失败或不可用时,再动态加载 FFmpeg 模块、Worker 与 WASM 核心,避免正常路径承担较大的资源下载成本。
核心参数可以表达为:
ffmpeg -i input \
-map 0:v:0 -map '0:a?' \
-vf 'scale=1280:674:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=1280:674:(ow-iw)/2:(oh-ih)/2,setsar=1' \
-c:v libx264 -preset fast -b:v 4000000 \
-pix_fmt yuv420p \
-c:a aac -b:a 96000 \
-movflags +faststart output.mp4
上面的尺寸和码率只是示例,实际应来自任务计算结果。
0:a?表示音轨可选,无音轨时不因此失败。- 等比缩放加居中补边,避免强制拉伸。
libx264完成 H.264 软件编码。faststart将索引提前,便于渐进播放,不是视频编码提速选项。- 未设置时长截断参数,目标是保留完整素材。
单线程 WASM 编码较慢。多线程版本还涉及 SharedArrayBuffer、跨源隔离和部署响应头,不能只替换依赖就假定可用。
九、输出校验才是能否上传的最终依据
编码完成后,应重新解析生成文件,至少检查:
- 文件非空且实际大小不超过目标值。
- 容器确实为 MP4,而不是只改了扩展名。
- 视频编码为 H.264。
- 存在的音轨均为 AAC,音轨数量符合预期。
- 视频码流满足 8-bit 4:2:0。
- 总时长、视频时长和各音轨时长未异常变化。
- 输出尺寸与最终选中的编码配置一致。
最后一点很容易遗漏:如果能力探测从 1920 像素降到 1280 像素,校验不能继续拿最初的 1920 像素比较,否则正确的降级结果也会被误判失败。
工程上应让编码器返回实际选中的尺寸或配置,由调度层进行校验,而不是让调度器猜测。
时长检查需要容差,例如:
const tolerance = Math.max(0.25, originalDuration * 0.001);
const durationValid =
Math.abs(resultDuration - originalDuration) <= tolerance;
封装时间基、音频编码延迟和帧边界可能造成小幅差异,不能一律严格相等。
9.1 超限后按实际结果修正码率
const nextBitrate = Math.floor(
currentBitrate * (maxBytes / output.size) * 0.9
);
这比盲目每次减少固定码率更有针对性。但它依然只是下一轮估计,因此必须设置最大编码次数和最低码率预算。所有重试均失败时,应提示用户更换素材,不上传原始超限文件。
十、队列、进度与取消:让高成本任务可控
10.1 压缩队列和上传队列分开
压缩会同时持有源文件、解码帧、编码队列和输出缓冲区。并行处理多个高清视频,容易让内存与编码资源耗尽。
可以使用 Promise 链串行执行压缩:
let queue: Promise<unknown> = Promise.resolve();
function enqueue<T>(run: () => Promise<T>): Promise<T> {
const task = queue.then(run);
queue = task.catch(() => undefined);
return task;
}
失败后吞掉队列链上的拒绝,仅用于让后续任务能继续;调用方仍应收到该任务自己的异常。
压缩成功的文件再进入独立上传队列。正在处理的素材也必须计入未完成任务,避免发布动作提前提交。
10.2 不要从头到尾只显示“压缩中”
建议展示以下阶段:
| 阶段 | 用户提示 |
|---|---|
| queued | 等待压缩 |
| preparing | 读取视频信息中 |
| loading | 加载引擎或检测编码配置中 |
| compressing | 视频压缩中 35% |
| validating | 校验压缩结果中 |
| uploading | 上传中 60% |
| processing | 上传完成,服务端处理中 |
编码百分比不能代表整个流程百分比,更不能直接复用为上传百分比。第二次编码应标明“第 2 次压缩”,而不是不解释地把 100% 退回 0%。
还应说明当前引擎及软件回退原因。编码资源下载完成之后长时间没有网络请求,可能只是编码正在本地执行,并不一定是卡死。
10.3 用 AbortController 贯穿生命周期
const controller = new AbortController();
try {
await compress(file, { signal: controller.signal });
} finally {
releaseResources();
}
// 用户取消、页面卸载或任务超时
controller.abort(new DOMException('处理已取消', 'AbortError'));
仅拒绝外层 Promise 不等于底层运算已经停止。取消应实际传递给转换器;FFmpeg 路径需要终止 Worker,媒体输入需要释放,Object URL 要撤销,定时器和事件监听也要移除。
用户主动取消时,不应该又自动启动软件回退。
十一、诊断日志要能回答“到底哪里失败”
“浏览器不支持所需编解码配置”适合做简短提示,但不足以定位原因。
每个阶段至少记录:
console.warn('[视频压缩] 编码配置检测失败', {
stage: 'H.264 编码能力检测',
width,
height,
bitrate,
hardwareAcceleration,
attempt,
error,
});
对于 AAC,需要记录采样率和声道数;对于轨道解码,需要记录编码与检测结果;对于输出超限,需要记录实际字节数和目标字节数。
日志应避免输出用户文件内容、鉴权信息或完整媒体缓冲区。生产环境可以通过日志级别控制诊断输出。
十二、部署与验证不能只看类型检查
这类功能存在三层完全不同的成功标准:
- 编译成功:类型、模块解析与依赖预构建通过。
- 资源加载成功:编码模块、Worker、WASM 可以下载并正确初始化。
- 业务成功:真实视频转码、音画同步、大小校验及上传均正常。
现代媒体依赖可能使用 BigInt 等语法,因此构建目标必须与依赖和实际浏览器能力一致。开发环境可用也不代表生产路径、资源 MIME、Worker URL 或安全上下文配置一定正确。
建议覆盖至少这些场景:
- 无音轨、单音轨、多音轨素材。
- 竖屏、横屏、带旋转信息的视频。
- 原配置硬件优先成功、分辨率降级成功、默认编码成功、软件回退成功。
- 编码后仍超限、轨道被丢弃、结果校验失败。
- 排队中取消、编码中取消、组件卸载、超时。
- 真实目标设备播放,而不只是浏览器预览。
结语
浏览器端素材压缩的核心,不是找到一个万能压缩参数,而是建立一个可解释、可降级、可取消、可校验的处理系统:
- 图片通过 Canvas 实现等比缩放与质量压缩。
- 视频根据时长计算码率预算,而不是凭文件大小猜参数。
- WebCodecs 优先尝试硬件,再逐级降低分辨率和放宽编码偏好。
- FFmpeg WASM 仅作为必要的软件兜底。
- 最终上传的是通过真实内容校验的结果,而不是“编码器没有报错”的结果。
- 状态和日志揭示当前阶段,让用户和开发者都能理解等待的原因。
硬件优先改善的是成功路径的性能,多层回退改善的是兼容性,而严格的输出校验决定了最终结果是否可靠。
如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。


本文介绍浏览器端素材上传的完整处理链路,重点解决图片与视频超限问题。图片通过Canvas等比缩放与质量压缩,逐级优化至2MiB内;视频则优先使用WebCodecs硬件编码,结合分辨率降级、码率预算与FFmpeg WASM回退,确保在20MiB内完成转码。强调“先确认后处理”原则,避免无谓资源消耗,并通过多层能力检测保障兼容性与性能。核心思想:压缩成功 ≠ 上传成功,需独立校验。
浙公网安备 33010602011771号