从模型 Demo 到剪辑产品:基于 MI-GAN、ONNX 与 WebGPU 实现浏览器本地视频去水印
摘要
视频去水印通常依赖服务端 GPU:用户上传视频,服务端逐帧处理,再将结果下载回来。这种方案面临上传耗时、隐私顾虑、GPU 成本和高并发排队等问题。
我们在开源视频剪辑器 Timeline Studio 中实现了一套浏览器本地 AI 去水印方案。用户可以框选一个或多个水印区域,设置独立处理时段,并通过位置关键帧处理动态水印。浏览器使用 MI-GAN ONNX 模型和 WebGPU 逐帧修复视频,最后重新编码并保留原始音轨。
整个过程中,图片与视频帧均在用户设备上处理,无需上传服务器。
项目仓库:
https://github.com/MartinDelophy/ai-video-editor
在线体验:
https://video-editor.ai-creator.top/
一、为什么要在浏览器里做视频去水印
传统在线去水印产品一般采用以下流程:
- 用户上传完整视频;
- 服务端进行视频解码;
- GPU 逐帧执行图像修复;
- 重新编码视频;
- 用户下载结果。
这种方案实现起来比较直接,但在实际产品中存在几个明显问题:
- 高清视频上传时间较长;
- 用户可能不愿上传隐私或商业素材;
- 服务端 GPU 资源成本较高;
- 视频处理任务容易形成队列;
- 移动网络环境下上传容易中断;
- 上传和下载增加了完整处理时长。
WebGPU 和 ONNX Runtime Web 的逐渐成熟,让我们可以把一部分 AI 推理任务迁移到用户设备上。
浏览器本地处理的主要优势是:
- 素材无需上传;
- 减少服务端 GPU 成本;
- 模型缓存后可以重复使用;
- 处理结果可以直接进入剪辑器;
- 更适合隐私敏感的图片和视频。
二、项目与技术栈
Timeline Studio 是一个运行在浏览器中的多轨视频剪辑器,支持媒体导入、字幕、AI 配音、音频处理、时间线编辑、视频导出和浏览器本地 AI 能力。
本次去水印功能主要使用:
- React;
- Vite;
- MI-GAN;
- ONNX Runtime Web;
- WebGPU;
- Web Worker;
- Canvas;
- ImageBitmap;
- HTMLVideoElement;
- FFmpeg WASM;
- Blob 与 Object URL。
本地启动项目:
git clone https://github.com/MartinDelophy/ai-video-editor.git
cd ai-video-editor
npm install
npm run dev
浏览器 AI 推理依赖 WebGPU,建议使用较新版本的 Chrome 或 Edge。
三、整体架构
整个视频去水印过程分为以下阶段:
- 用户导入图片或视频;
- 在独立工作区中框选修复区域;
- 为每个区域设置开始时间和结束时间;
- 隐藏视频元素按时间逐帧解码;
- 将视频帧转换为
ImageBitmap; - 根据区域生成模型遮罩;
- Web Worker 调用 MI-GAN ONNX 模型;
- WebGPU 完成局部图像修复;
- 将修复结果融合回完整画面;
- 同步更新修复前后预览;
- 将全部结果帧重新编码;
- 合并原始音轨;
- 生成新素材并加入剪辑器。
模型推理和媒体上传完全解耦。视频帧只在浏览器内存、本地 Blob 和 Worker 之间传递。
四、为什么选择 MI-GAN
去水印并不是简单地删除一块像素。
水印遮挡了原始画面,系统需要根据周围内容推测被遮挡区域原本应该是什么。例如:
- 人脸皮肤;
- 头发纹理;
- 天空和云层;
- 建筑墙面;
- 衣服纹理;
- 草地和水面;
- 背景光影。
这类任务属于图像修复,也就是 Image Inpainting。
部分图像修复模型虽然效果很好,但权重较大,推理速度也比较慢,更适合运行在服务端 GPU 上。
我们选择 MI-GAN,主要考虑以下因素:
- 模型相对轻量;
- 局部区域修复速度较快;
- 适合矩形遮罩;
- 可以使用 ONNX Runtime Web;
- WebGPU 后端具备较好的浏览器运行能力;
- 比较适合角标、水印和局部对象移除。
为了避免模型运行阻塞 React 主线程,我们将推理放在 Web Worker 中执行。
主线程负责交互和媒体显示,Worker 负责:
- 模型加载;
- Tensor 构造;
- WebGPU Session 初始化;
- 图像预处理;
- MI-GAN 推理;
- 输出像素转换;
- 结果 Blob 生成。
五、修复区域使用相对坐标
用户在预览画面中框选水印时,我们不会直接保存屏幕像素,而是保存相对坐标:
const selection = {
x: 0.78,
y: 0.88,
width: 0.2,
height: 0.09,
};
这些值表示区域相对于完整画面的比例。
在模型处理时,再映射到真实视频帧:
const pixelRegion = {
x: Math.round(selection.x * frameWidth),
y: Math.round(selection.y * frameHeight),
width: Math.round(selection.width * frameWidth),
height: Math.round(selection.height * frameHeight),
};
这种方式可以保证:
- 桌面端和移动端配置一致;
- 预览缩放不会影响修复范围;
- 横屏、竖屏和方形视频都能正确映射;
- 大画布和普通画布可以共享区域数据;
- 视频导出时仍能使用原始分辨率。
六、视频区域需要独立时间范围
图片只需要修复一次,但视频中的水印可能只在某个时间段出现。
因此,每个视频修复区域都拥有独立的开始时间和结束时间:
const repairRegion = {
id: "repair-region-1",
start: 0,
end: 4,
selection: {
x: 0.78,
y: 0.88,
width: 0.2,
height: 0.09,
},
keyframes: [],
};
处理某一个视频帧时,根据当前时间筛选有效区域:
const activeRegions = repairRegions.filter((region) => {
return (
currentTime >= region.start &&
currentTime <= region.end
);
});
如果当前帧没有命中任何区域,就直接保留原始帧。
如果命中了一个或多个区域,则调用 MI-GAN 进行修复。
这样可以避免对整个视频无差别运行模型,减少不必要的计算。
七、支持多个水印区域
实际视频中可能同时包含多个需要移除的内容:
- 左上角平台 Logo;
- 右下角用户名;
- 底部 AI 生成标记;
- 视频日期;
- 摄像机时间;
- 其他小面积对象。
因此,图片和视频都支持多个区域。
当同一帧命中多个区域时,我们会依次进行修复:
let bitmap = originalBitmap;
for (const region of activeRegions) {
const result = await repairMiganFrame({
bitmap,
selection: region.selection,
signal,
});
bitmap.close();
bitmap = await createImageBitmap(result.blob);
}
后一个区域会基于前一个区域的输出继续处理。
这样最终得到的是一张已经完成全部区域修复的视频帧。
八、动态水印如何处理
时间范围只能解决水印什么时候出现,却不能解决水印移动的问题。
例如:
- 前三秒在左下角;
- 中间移动到顶部;
- 最后出现在右下角。
如果只使用一个固定矩形,模型就无法持续覆盖水印。
我们为每个修复区域增加了位置关键帧。
用户在不同时间点移动修复框,并记录当前位置:
const positionKeyframe = {
time: 2.5,
selection: {
x: 0.12,
y: 0.82,
width: 0.2,
height: 0.08,
},
};
处理两个关键帧之间的视频帧时,系统自动插值:
function interpolateSelection(before, after, currentTime) {
const duration = Math.max(
0.001,
after.time - before.time,
);
const ratio =
(currentTime - before.time) / duration;
const keys = ["x", "y", "width", "height"];
return Object.fromEntries(
keys.map((key) => [
key,
before.selection[key] +
(after.selection[key] -
before.selection[key]) *
ratio,
]),
);
}
用户不需要逐帧调整区域,只需要记录几个关键位置。
九、为什么需要隐藏视频元素
早期实现直接使用弹窗里可见的视频元素提取帧。
处理器会不断修改:
video.currentTime = targetTime;
这会带来一个问题:
左侧原始画面在模型推理开始前就已经跳到了下一帧,而右侧修复结果要等模型完成后才出现。
对比线左右因此不是同一个时间点。
最终我们将计算视频与展示视频分离。
隐藏视频负责:
- 跳转到目标时间;
- 等待
seeked; - 提取原始帧;
- 生成
ImageBitmap; - 为模型提供输入。
可见视频只负责显示已经完成的帧。
隐藏视频的逐帧读取逻辑类似:
async function seekVideoFrame(video, time, signal) {
if (signal.aborted) {
throw new DOMException(
"Canceled",
"AbortError",
);
}
const target = Math.max(
0,
Math.min(video.duration - 0.001, time),
);
if (
Math.abs(video.currentTime - target) < 0.0005
) {
return;
}
await new Promise((resolve, reject) => {
const onSeeked = () => {
cleanup();
resolve();
};
const onAbort = () => {
cleanup();
reject(
new DOMException(
"Canceled",
"AbortError",
),
);
};
const cleanup = () => {
video.removeEventListener(
"seeked",
onSeeked,
);
signal.removeEventListener(
"abort",
onAbort,
);
};
video.addEventListener(
"seeked",
onSeeked,
{
once: true },
);
signal.addEventListener(
"abort",
onAbort,
{
once: true },
);
video.currentTime = target;
});
}
跳转完成后,再读取当前视频帧:
await seekVideoFrame(
hiddenVideo,
sourceTime,
signal,
);
const bitmap =
await createImageBitmap(hiddenVideo);
十、修复前后拖动对比
用户仅仅看到修复结果,很难判断模型究竟改动了什么。
因此,我们实现了可以左右拖动的对比线:
- 左侧显示原始帧;
- 右侧显示修复帧;
- 用户可以拖动竖线检查局部差异;
- 图片和视频使用相同的交互。
修复结果覆盖在原始画面上,再通过 clip-path 控制可见区域:
.repair-compare-result {
position: absolute;
inset: 0;
clip-path: inset(
0
0
0
var(--compare-position)
);
}
这种方式可以保证两边拥有相同的尺寸、位置、缩放和纵横比。
视频处理完成后,还需要同步原视频和修复视频的:
currentTime;playbackRate;- 播放;
- 暂停;
- 时间轴跳转。
十一、解决画面与进度不同步
逐帧修复最容易被忽略的问题,是界面上存在多个不同的“进度”。
它们包括:
- 模型下载进度;
- WebGPU 初始化进度;
- 当前帧推理进度;
- 已完成帧数量;
- PNG 解码进度;
- 最终视频编码进度。
如果把这些进度混合为一个百分比,就会出现:
- 进度条已经向前;
- 画面还是上一帧;
- 播放头没有变化;
- 无修复区域的帧不计入进度;
- 最后突然跳到 91% 或 92%。
我们的解决方案是使用“已提交帧”作为唯一的视频进度时钟。
每一帧的顺序必须是:
- 完成 MI-GAN 推理;
- 生成结果 Blob;
- 完成结果图片解码;
- 更新左右画面;
- 更新时间和播放头;
- 更新已完成帧百分比。
核心逻辑如下:
await commitVisibleFrame({
blob: resultBlob,
index,
time: timelineTime,
});
reportCompletedFrame(index);
不需要修复的帧也必须推进进度:
if (!activeRegions.length) {
const originalFrame =
await captureOriginalFrame(video);
await commitVisibleFrame({
blob: originalFrame,
index,
time: timelineTime,
repaired: false,
});
reportCompletedFrame(index);
return originalFrame;
}
模型下载和 WebGPU 初始化只更新当前阶段名称,不再提前推动视频帧百分比。
十二、为什么要等待结果图片解码
MI-GAN 的结果会被转换为 PNG Blob。
创建 Object URL 并设置到 <img> 后,浏览器仍可能需要时间解码图片。
如果立即推进播放头和进度,就可能出现:
- 时间已经变化;
- 进度已经增加;
- 右侧仍显示上一帧。
因此,我们先等待图片解码:
const url =
URL.createObjectURL(frameBlob);
const decodedImage = new Image();
decodedImage.src = url;
try {
await decodedImage.decode();
} catch {
// 某些浏览器不支持 decode,
// 仍可继续使用 Object URL。
}
解码完成后再提交 UI:
const previousUrl =
previewRef.current?.url;
previewRef.current = {
blob: frameBlob,
url,
time: timelineTime,
};
setPreview(previewRef.current);
await new Promise((resolve) => {
requestAnimationFrame(resolve);
});
if (previousUrl) {
URL.revokeObjectURL(previousUrl);
}
旧帧要在新帧可见以后再释放,否则容易产生闪烁。
十三、视频重新编码与音轨保留
全部帧处理完成后,需要把 PNG 帧序列重新编码成视频。
编码时还需要保留:
- 原始视频时长;
- 原始音轨;
- 播放速度;
- 视频宽高;
- 帧率;
- 时间轴范围。
编码阶段与逐帧推理阶段应当分开显示。
我们最终采用以下阶段:
- 准备视频;
- 下载 MI-GAN 模型;
- 初始化 WebGPU;
- 逐帧修复;
- 准备视频合成器;
- 合成修复视频;
- 创建修复素材;
- 完成。
最终输出为 MP4 Blob,并作为新素材加入剪辑器。
原始素材不会被直接覆盖,只有用户点击“应用到视频”后,结果才会替换当前时间线片段。
十四、任务取消与资源回收
长视频处理必须支持真正的取消。
取消不能只是关闭弹窗,而是需要停止:
- 当前模型推理;
- 后续帧循环;
- 视频跳转;
- Canvas 截图;
- PNG 解码;
- 视频编码;
- 音轨处理;
- Worker 请求;
- 临时 Object URL。
我们使用 AbortController 贯穿完整处理链路:
const controller =
new AbortController();
await repairVideo({
signal: controller.signal,
});
function cancelRepair() {
controller.abort();
}
取消后不会:
- 自动应用半成品;
- 替换当前视频;
- 下载不完整文件;
- 继续在后台处理剩余帧。
同时需要释放:
ImageBitmap;- 临时视频元素;
- Object URL;
- Worker 请求;
- 编码器资源。
十五、产品交互设计
AI 去水印涉及的配置较多,如果全部塞进右侧属性面板,会让剪辑器显得非常拥挤。
我们采用了“能力入口 + 独立工作区”的方式。
右侧属性面板只展示:
- 去水印 / 对象移除;
- 高清修复。
点击后进入独立工作区,完成:
- 框选区域;
- 添加多个区域;
- 设置时间范围;
- 记录位置关键帧;
- 单帧测试;
- 逐帧修复;
- 撤销与重做;
- 前后对比;
- 应用或取消。
这种设计也更适合移动端。桌面端使用大弹窗,移动端则切换为全屏工作区。
十六、浏览器本地方案的边界
浏览器本地去水印并不能覆盖所有情况。
它更适合:
- 平台角标;
- 用户名水印;
- AI 生成标记;
- 日期和时间;
- 小面积文字;
- 局部静态对象;
- 缓慢移动的水印。
以下情况难度更高:
- 大面积遮挡人脸;
- 快速运动的水印;
- 遮挡复杂文字;
- 背景纹理变化剧烈;
- 超高分辨率长视频;
- 设备没有 WebGPU;
- 浏览器内存不足。
后续可以继续探索:
- 光流跟踪;
- 视频时序一致性模型;
- 前后帧融合;
- 分块推理;
- 更强的修复模型;
- WebCodecs 编码;
- 浏览器本地与云端混合处理。
十七、总结
从一个 MI-GAN 模型 Demo 到剪辑软件里的去水印功能,中间还有大量工程问题需要解决。
除了模型本身,我们还需要处理:
- WebGPU 推理;
- Worker 隔离;
- 多区域遮罩;
- 区域时间范围;
- 动态位置关键帧;
- 视频逐帧解码;
- 修复前后同步;
- 画面与进度同步;
- PNG 解码;
- Blob URL 回收;
- 视频重新编码;
- 原始音轨保留;
- 任务取消;
- 多语言;
- 移动端交互。
最终实现的完整链路是:
- 用户框选水印;
- 设置生效时间和位置关键帧;
- 隐藏视频逐帧解码;
- MI-GAN 在 WebGPU 上修复;
- 左右画面、播放头和进度同步更新;
- 重新编码并保留音轨;
- 结果加入剪辑器素材库;
- 用户确认后应用到当前视频。
浏览器已经不再只是 AI 服务的调用入口。
随着 WebGPU、ONNX Runtime Web、WebCodecs 和浏览器媒体能力不断成熟,越来越多过去依赖服务端 GPU 的 AI 视频能力,可以开始迁移到用户本地设备。
项目地址
GitHub:
https://github.com/MartinDelophy/ai-video-editor
在线体验:
https://video-editor.ai-creator.top/
如果项目对你有帮助,欢迎在 GitHub 点 Star、提交 Issue 或参与功能改进。