在浏览器中实现视频四倍高清修复:NanoVSR 与 WebGPU 实战
项目地址:https://github.com/MartinDelophy/ai-video-editor
在线体验:https://video-editor.ai-creator.top/
前言
在 Timeline Studio 中,我们实现了一套完全运行在浏览器中的视频高清修复流程。
用户选择视频片段后,浏览器会提取画面,使用 NanoVSR 模型执行四倍超分辨率处理,再将生成的图片序列编码为 MP4,并保留原视频中的音频。
这套实现主要使用以下技术:
- NanoVSR 644K
- ONNX Runtime Web
- WebGPU
- Web Worker
- OffscreenCanvas
- FFmpeg.wasm
- React
本文介绍其中几个关键的工程设计。
整体处理流程
视频高清修复的完整流程如下:
读取视频片段
|
v
按照时间提取视频帧
|
v
每五帧组成一个处理分组
|
v
转换为 NanoVSR 输入张量
|
v
通过 WebGPU 执行 ONNX 推理
|
v
生成高清图片序列
|
v
使用 FFmpeg.wasm 编码视频
|
v
加入原视频音频
|
v
生成新的 MP4 素材
项目中的主要代码位于:
为什么使用五帧模型
如果把视频拆成图片,然后独立处理每一帧,虽然单帧看起来比较清晰,但连续播放时可能出现细节变化。
例如:
- 头发纹理在相邻帧中发生变化
- 细线和文字边缘轻微抖动
- 压缩纹理在播放时出现闪烁
- 相同物体的细节不够连续
为改善这个问题,图片和视频使用了不同的模型输入。
| 场景 | 输入张量 | 输出张量 |
|---|---|---|
| 图片 | [1, 1, 3, 180, 320] |
[1, 1, 3, 720, 1280] |
| 视频 | [1, 5, 3, 180, 320] |
[1, 5, 3, 720, 1280] |
视频模型一次接收五帧画面,可以同时参考前后帧的信息。
在代码中,视频按照五帧进行分组:
const groupStart = Math.floor(index / 5) * 5;
const count = Math.min(5, totalFrames - groupStart);
最后一组不足五帧时,使用最后一个有效画面补齐输入,但只输出实际需要的帧。
这样既能利用时序信息,也能限制一次推理所需的内存。
在 Web Worker 中运行模型
视频模型推理比较耗时。如果直接在页面主线程执行,可能影响界面渲染、视频播放和时间线操作。
因此,我们将 ONNX Runtime 放在独立的 Web Worker 中:
const worker = new Worker(
new URL("../workers/nanovsr.worker.js", import.meta.url),
{
type: "module" },
);
主线程负责:
- 加载视频
- 定位时间
- 创建 ImageBitmap
- 管理任务状态
- 接收处理进度
- 保存最终结果
Worker 负责:
- 加载模型
- 创建 WebGPU Session
- 构造输入张量
- 执行模型推理
- 生成高清图片
图片通过 transferable object 发送给 Worker:
worker.postMessage(
{
type: "enhance",
requestId,
bitmaps,
outputCount,
},
bitmaps,
);
这种方式会转移 ImageBitmap 的所有权,减少大尺寸图像在两个线程之间复制的次数。
处理完成后,需要及时释放相关资源:
bitmap.close?.();
result.sr.dispose?.();
对于视频任务,资源清理非常重要。如果 Bitmap、Canvas 或 Tensor 长时间保留,处理较长视频时内存占用会持续增加。
使用 WebGPU 执行 ONNX 推理
项目通过 ONNX Runtime Web 创建 WebGPU 推理会话:
const session = await ort.InferenceSession.create(model, {
executionProviders: ["webgpu"],
graphOptimizationLevel: "all",
});
同时设置高性能设备偏好:
ort.env.webgpu.powerPreference = "high-performance";
运行任务前会检查浏览器是否支持 WebGPU:
if (!self.navigator?.gpu) {
throw new Error("This feature requires WebGPU");
}
视频超分辨率的计算量明显高于普通图像分类任务。明确检查运行条件,可以避免在不合适的执行环境中启动耗时任务。
保持原视频的宽高比
模型输入尺寸固定为 320 乘 180,但用户导入的视频可能是横屏、竖屏或正方形。
如果直接把画面拉伸到固定尺寸,会改变人物和物体的比例。
项目采用等比例包含方式计算绘制区域:
const scale = Math.min(
INPUT_WIDTH / width,
INPUT_HEIGHT / height,
);
const drawWidth = width * scale;
const drawHeight = height * scale;
画面会等比例绘制到 320 乘 180 的模型画布中,剩余区域填充为黑色。
模型输出 1280 乘 720 的结果后,再按照预处理阶段记录的位置裁切有效区域。这样可以保持原素材的宽高比,也不会裁掉原始内容。
构造模型输入张量
每个输入帧会先绘制到 OffscreenCanvas,然后读取像素数据:
const pixels = context.getImageData(
0,
0,
INPUT_WIDTH,
INPUT_HEIGHT,
).data;
浏览器 Canvas 返回的是 RGBA 排列,而 NanoVSR 需要 RGB 通道分离的浮点张量。
转换过程如下:
tensor[rOffset + index] = pixels[pixel] / 255;
tensor[gOffset + index] = pixels[pixel + 1] / 255;
tensor[bOffset + index] = pixels[pixel + 2] / 255;
视频模型最终接收的张量形状为:
1, 5, 3, 180, 320
各个维度分别表示:
批次数量、帧数量、颜色通道、高度、宽度
模型下载与缓存
首次运行时,浏览器需要下载 ONNX 模型。
模型使用固定版本地址,而不是会持续变化的默认分支:
const MODEL_REVISION =
"d551be137b16ecdf12637387f2fb4776565e763f";
固定版本有几个好处:
- 线上代码和模型始终对应
- 缓存身份保持稳定
- 问题更容易复现
- 更新模型时可以明确控制版本
模型下载完成后会写入 Cache Storage:
const cache = await caches.open(
"timeline-studio-nanovsr-v1",
);
await cache.put(cacheKey, new Response(modelBytes));
缓存键由模型仓库、版本和文件路径组成,而不是直接使用下载地址。
const cacheKey =
"/__model-cache__/haixin/" +
"timeline-studio-onnx-models/" +
MODEL_REVISION +
"/" +
modelPath;
这样即使模型的下载节点发生变化,浏览器仍然可以识别已经存在的缓存。
创建完成的 ONNX Session 也会保存在 Worker 中:
const sessionPromises = new Map();
图片模型和视频模型分别初始化一次。用户再次使用高清修复时,可以直接复用已经加载的模型。
逐帧处理视频
项目目前以 12 帧每秒处理视频:
const frameRate = 12;
const totalFrames = Math.ceil(
sourceDuration * frameRate,
);
每一帧通过设置 <video> 的 currentTime 完成定位:
video.currentTime =
sourceStart + frameIndex / frameRate;
等待 seeked 事件后,从视频元素创建 ImageBitmap:
const bitmap = await createImageBitmap(video);
收集五张画面后,将它们发送给 Worker。
12 帧每秒是当前版本在处理速度、内存占用和播放效果之间采用的工程参数。对于运动速度较快的视频,后续可以增加动态帧率策略。
重新生成带音频的 MP4
NanoVSR 输出的是高清图片,而不是完整视频。
项目先把模型结果转换为 PNG Blob,然后交给 FFmpeg.wasm:
-framerate 12
-i frame-%06d.png
-c:v libx264
-preset veryfast
-crf 18
-pix_fmt yuv420p
-movflags faststart
output.mp4
输出视频使用:
- H.264 视频编码
- CRF 18
- yuv420p 像素格式
- faststart MP4
如果原视频包含音频,还会按照当前片段的开始位置和时长读取对应音轨:
-ss sourceStart
-t sourceDuration
-i original-video
-map 0:v:0
-map 1:a?
-c:a aac
-b:a 192k
-shortest
最终生成的视频同时包含高清画面和原片段音频,可以直接加入编辑器的素材库。
进度显示
一次高清修复包含多个处理阶段:
- 准备视频画面
- 下载或读取模型
- 初始化 WebGPU
- 执行模型推理
- 生成高清帧
- 加载编码器
- 合成 MP4
- 创建新素材
Worker 会发送结构化进度消息:
{
progress,
phaseKey,
frameIndex,
totalFrames,
backend: "webgpu",
}
界面不仅显示百分比,还会显示真实的帧数量:
正在处理视频帧
已处理 36 / 120 帧
用户可以知道当前任务是在加载模型、执行推理还是编码视频。
任务取消
处理视频可能需要一定时间,因此功能支持取消。
主线程通过 AbortController 管理任务:
const controller = new AbortController();
取消任务后:
- 停止继续读取视频帧
- 通知 Worker 停止后续分组
- 丢弃当前任务结果
- 释放已经创建的 Bitmap
- 编码阶段会停止 FFmpeg 实例
- 不生成不完整的视频文件
需要注意,已经开始执行的单次 WebGPU 推理通常无法立即中断。系统会等待当前五帧分组完成,但不会继续处理下一组。
先预览,再应用
模型处理完成后,结果不会立即覆盖当前视频。
用户可以在对比界面中:
- 同步播放处理前后的画面
- 拖动分界线查看同一位置
- 定位到视频中的指定时间
- 重新生成结果
- 确认后再应用到时间线
应用时,项目会同时保存原始素材和处理结果:
enhancement: {
mode: "nanovsr-644k",
enabled: true,
original,
processed,
backend: "webgpu",
frameRate,
totalFrames,
}
这种方式不会直接修改原文件,用户也可以随时切换回原始版本。
当前限制和后续方向
当前实现仍有一些可以继续优化的地方:
- 模型输入尺寸固定为 320 乘 180
- 视频处理帧率固定为 12 帧每秒
- 长视频会产生较多 PNG 中间数据
- FFmpeg.wasm 的虚拟文件系统会占用额外内存
- 运行环境需要支持 WebGPU
后续可以采用下面的流式处理方式:
VideoFrame
|
v
WebGPU 推理
|
v
VideoFrame
|
v
WebCodecs 编码
|
v
MP4 封装
这样可以减少 PNG 中间文件,避免一次保存全部处理结果,也更适合较长的视频。
对于更高分辨率素材,还可以引入:
- 分块推理
- 重叠区域融合
- 动态输入尺寸
- 根据设备能力调整处理参数
- 高运动画面的帧率补偿
总结
浏览器端视频高清修复并不只是运行一次模型,还需要完成一整套媒体处理流程:
- 视频定位与解码
- 五帧时序分组
- ONNX 张量预处理
- WebGPU 模型推理
- 宽高比适配
- 模型下载和缓存
- Bitmap 与 Tensor 资源释放
- 原始音频保留
- MP4 重新编码
- 进度与取消控制
- 处理前后对比
- 非覆盖式结果应用
Timeline Studio 已经将这些环节组合为一个可以直接使用的视频编辑功能。
如果你正在研究 WebGPU、浏览器媒体处理或本地模型推理,可以通过下面的地址查看完整源码: