MI-GAN과 WebGPU로 브라우저 안에서 영상 워터마크 제거하기
브라우저에서 동영상 편집기를 만들면서 자주 받은 요청 중 하나가 워터마크와 불필요한 객체를 제거하는 기능이었습니다.
보통은 영상을 서버에 업로드하고 GPU 서버에서 인페인팅한 뒤 결과를 내려받습니다. 구현은 비교적 단순하지만 영상이 클수록 업로드가 오래 걸리고, 스토리지와 GPU 비용이 들며, 미공개 영상이나 민감한 자료를 외부로 전송해야 한다는 문제가 있습니다.
이번 프로젝트에서는 MI-GAN을 ONNX Runtime Web과 WebGPU로 실행해 이미지와 영상을 브라우저 안에서 직접 복원하도록 구현했습니다. 원본 미디어는 서버로 업로드하지 않습니다.
GitHub
https://github.com/MartinDelophy/ai-video-editor
온라인 데모
https://video-editor.ai-creator.top/
※ 본인 소유이거나 편집 권한이 있는 미디어에서만 사용해야 합니다. 제3자 콘텐츠의 소유권·출처 표시를 제거하면 라이선스나 플랫폼 정책을 위반할 수 있습니다.
1. 사용한 구성
- MI-GAN: 마스크 영역을 주변 문맥으로 채우는 경량 인페인팅 모델
- ONNX Runtime Web: 브라우저 ONNX 추론
- WebGPU: 지원 환경에서 GPU 가속
- Web Worker: 모델 추론을 UI 스레드에서 분리
- Canvas / OffscreenCanvas: 프레임 추출, 마스크 생성, 결과 합성
- WebCodecs 및 호환 인코딩 경로: 처리된 프레임을 영상으로 생성
- Cache Storage: 모델과 런타임 파일 재사용
이미지는 한 번만 추론하면 되지만 영상에는 시간축이 있습니다. 전체 흐름은 다음과 같습니다.
원본 미디어
→ 사용자가 복원 영역과 시간 구간 지정
→ 현재 시각에 활성화된 영역 계산
→ 비디오 프레임 디코딩
→ 마스크 생성
→ MI-GAN 추론
→ 원본 프레임과 복원 결과 합성
→ 진행형 미리보기 갱신
→ 프레임 인코딩
→ 새 미디어 에셋 생성
2. 이미지에서는 정규화 좌표로 다중 영역 관리
사용자는 이미지 위에 하나 이상의 사각형을 그립니다. 선택 영역은 미리보기 픽셀이 아니라 0~1 사이의 정규화 좌표로 저장합니다.
예:
const region = {
x: 0.72,
y: 0.82,
width: 0.22,
height: 0.10
};
이렇게 하면 화면 크기나 반응형 레이아웃이 바뀌어도 같은 영역을 원본 해상도에 정확하게 적용할 수 있습니다.
처리가 끝나면 바로 적용하지 않고 세로 비교선을 좌우로 움직여 원본과 복원 결과를 확인하도록 했습니다. 인페인팅은 숨겨진 정답을 되살리는 것이 아니라 주변 픽셀을 바탕으로 새로운 내용을 생성하는 기술이므로 적용 전 비교가 중요했습니다.
3. 영상은 “현재 프레임”이 아니라 “시간 구간”을 복원해야 함
첫 프로토타입에서는 현재 보이는 프레임만 복원했습니다. 단일 프레임 테스트는 정상처럼 보였지만 실제 영상에서는 잘못된 동작이었습니다.
워터마크는 한 프레임이 아니라 일정 시간 동안 존재합니다. 또한 앞부분에서는 왼쪽 아래에 있다가 뒷부분에서는 오른쪽 위로 이동할 수도 있습니다.
그래서 각 영역을 독립적인 시간 객체로 관리했습니다.
const repairRegion = {
id: "region-1",
startTime: 0,
endTime: 4,
keyframes: [
{ time: 0, x: 0.72, y: 0.82, width: 0.22, height: 0.10 },
{ time: 4, x: 0.08, y: 0.08, width: 0.22, height: 0.10 }
]
};
두 위치 사이의 사각형은 선형 보간합니다.
function interpolateRegion(a, b, ratio) {
return {
x: a.x + (b.x - a.x) * ratio,
y: a.y + (b.y - a.y) * ratio,
width: a.width + (b.width - a.width) * ratio,
height: a.height + (b.height - a.height) * ratio
};
}
이 구조로 고정 워터마크, 위치가 바뀌는 워터마크, 천천히 움직이는 오버레이를 하나의 작업에서 처리할 수 있습니다.
4. 이미지와 영상 모두 다중 영역 지원
한 화면에 로고와 자막형 워터마크가 같이 있을 수 있습니다. 현재 시각에 활성화된 모든 영역을 찾고 하나의 마스크로 합칩니다.
const activeRegions = regions
.filter((region) => {
return time >= region.startTime && time <= region.endTime;
})
.map((region) => resolveRegionAtTime(region, time));
각 영역은 시작/종료 시간과 위치 키프레임을 독립적으로 가집니다. 선택한 영역만 이동·리사이즈할 수 있고, 삭제 및 Undo/Redo도 영역 편집 히스토리에 포함했습니다.
5. MI-GAN 세션은 Web Worker에서 유지
모델 초기화와 추론을 메인 스레드에서 실행하면 편집기 전체가 멈춘 것처럼 보일 수 있습니다. 모델 세션은 Worker 안에서 생성하고 반복 작업에도 계속 재사용합니다.
const session = await ort.InferenceSession.create(modelUrl, {
executionProviders: ["webgpu", "wasm"]
});
WebGPU를 우선 사용하고 초기화 실패나 미지원 환경에서는 WASM으로 명시적으로 폴백합니다. UI에는 실제 사용 중인 백엔드를 표시합니다.
모델과 런타임 파일은 버전이 지정된 Cache Storage에 저장합니다. 같은 페이지에서 두 번째 복원을 실행할 때 다시 모델 다운로드 단계로 보이면 안 되기 때문에 Worker와 세션도 살아 있는 상태로 유지합니다.
6. 전체 프레임을 다시 만들지 않고 필요한 영역만 합성
작은 워터마크 하나를 지우기 위해 영상 전체를 모델 해상도로 축소하고 다시 생성하면 얼굴, 글자, 세부 질감까지 불필요하게 바뀔 수 있습니다.
처리 원칙은 다음과 같습니다.
- 모델 입력 크기에 맞춰 프레임과 마스크 변환
- 마스크 주변 문맥을 포함하도록 패딩
- 모델 결과 중 필요한 패치만 원본 해상도로 복원
- 마스크 밖 픽셀은 원본 그대로 유지
- 경계가 튀지 않도록 마스크 가장자리를 부드럽게 합성
복원 영역을 필요 이상으로 크게 잡지 않는 것이 속도와 결과 품질 모두에 도움이 됐습니다.
7. 가장 미묘했던 버그: 진행률과 화면이 서로 다른 속도로 움직임
초기 구현에서는 세 가지 시간이 따로 움직였습니다.
- 진행률: 처리 루프가 시작한 프레임 기준
- 화면: 브라우저가 실제 디코딩하고 그린 프레임 기준
- 타임라인: 별도 재생 시간 기준
그 결과 진행률은 40%인데 화면은 25% 근처에 머무는 현상이 있었습니다.
해결 방법은 “화면에 커밋된 프레임”을 하나의 UI 시계로 사용하는 것이었습니다.
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex += 1) {
const frame = await decodeFrame(frameIndex);
const repairedFrame = await repairFrameIfNeeded(frame, frameIndex);
await onFrameCommitted(repairedFrame, frameIndex);
reportProgress({
completedFrames: frameIndex + 1,
totalFrames
});
}
복원 구간 밖의 프레임도 반드시 onFrameCommitted를 호출해야 합니다. 추론을 생략한 구간에서도 화면과 플레이헤드는 계속 움직여야 하기 때문입니다.
미리보기 이미지는 decode()가 끝난 뒤 화면에 설정하고, requestAnimationFrame을 한 번 기다린 다음 완료 프레임 수를 증가시켰습니다.
await image.decode();
setPreviewFrame(image.src);
await new Promise(requestAnimationFrame);
setCompletedFrame(frameIndex + 1);
처리 중 왼쪽은 같은 시각의 원본 프레임, 오른쪽은 복원된 프레임을 보여줍니다. 비교선도 처리 과정에서 유지해 생성 중인 결과를 바로 확인할 수 있게 했습니다.
8. 프레임 복원과 영상 인코딩 진행률을 분리
프레임 복원이 100% 끝났다고 최종 파일이 준비된 것은 아닙니다. 인코더 초기화, 비디오 인코딩, 새 에셋 생성 단계가 남아 있습니다.
UI 단계는 다음처럼 분리했습니다.
1) 모델 준비
2) 프레임 복원
3) 비디오 인코더 준비
4) 복원 영상 인코딩
5) 새 에셋 생성
6) 완료
단계 이름 없이 하나의 퍼센트로 합치면 마지막 100%에서 멈춘 것처럼 보입니다. 현재 단계와 해당 단계의 진행률을 함께 보여주는 편이 이해하기 쉬웠습니다.
9. 취소는 텍스트 변경이 아니라 실제 리소스 정리
긴 영상을 처리할 때 취소는 필수입니다. “취소 중...”이라는 문구만 보여주고 백그라운드 처리가 계속되면 취소가 아닙니다.
취소 시 다음 작업을 실제로 중단합니다.
- 새 프레임 디코딩 예약 중단
- Worker에서 대기 중인 추론 요청 무효화
- VideoFrame과 ImageBitmap 닫기
- 임시 Blob URL 해제
- 인코더 및 미디어 리소스 종료
- 부분 생성 파일 다운로드 및 타임라인 삽입 방지
if (abortSignal.aborted) {
throw new DOMException("Repair cancelled", "AbortError");
}
사용자가 결과를 명시적으로 적용하기 전까지 원본 미디어는 변경하지 않습니다. 처리 완료 결과는 새로운 에셋으로 추가해 원본을 언제든 복구할 수 있게 했습니다.
10. 우측 설정 패널보다 독립 작업 공간이 적합했음
처음에는 모든 기능을 우측 속성 패널에 넣었습니다. 하지만 영역 선택, 시간 범위, 키프레임, 미리보기, 비교선까지 넣으니 공간이 부족했습니다.
최종 구조는 AI 복원 탭을 진입점으로만 두고 실제 작업은 큰 모달에서 처리합니다.
- 상단: 이동, 다시 그리기, 프리셋, Undo/Redo
- 중앙: 원본/복원 비교 캔버스
- 하단: 재생, 시간 눈금, 구간 핸들, 플레이헤드
- 오른쪽: 복원 영역 목록과 시간/위치 설정
- 푸터: 취소, 단일 프레임 테스트, 선택 구간 복원, 결과 적용
모바일에서는 같은 구조를 세로형 작업 공간으로 재배치했습니다.
11. 현재 한계와 다음 단계
MI-GAN은 브라우저에서 사용하기에 비교적 가볍고 빠르지만 모든 장면을 완벽하게 처리하지는 않습니다.
- 워터마크가 얼굴이나 복잡한 글자 위에 있으면 부자연스러울 수 있음
- 선택 영역이 너무 크면 생성 구조가 흔들릴 수 있음
- 빠르게 움직이는 장면은 프레임 간 일관성이 부족할 수 있음
- WebGPU 미지원 환경에서는 WASM 처리 시간이 길어질 수 있음
- 전체 처리 시간은 모델 추론 외에도 디코딩과 인코딩의 영향을 받음
프레임별 인페인팅은 시간적 일관성을 직접 보장하지 않습니다. 이후에는 optical flow, 영역 자동 추적, temporal consistency 모델을 결합할 수 있습니다.
마무리
이번 기능을 만들면서 가장 크게 느낀 점은 모델 추론 성공이 끝이 아니라 시작이라는 것입니다.
이미지 한 장을 복원하는 데모와 영상 편집기 안에서 다중 영역, 시간 구간, 동적 위치, 진행형 미리보기, 비교, 취소, 인코딩까지 제공하는 것은 완전히 다른 문제였습니다.
브라우저 로컬 AI, WebGPU, ONNX Runtime Web, 영상 프레임 처리나 편집기 UX를 연구하는 분들에게 도움이 되길 바랍니다.
GitHub
https://github.com/MartinDelophy/ai-video-editor
온라인 데모