한눈에 보기
드로잉 피드백 도구는 비디오 프레임 위에 Canvas를 오버레이하고, 펜/터치 입력을 캡처하며, 주석을 벡터 좌표로 저장합니다. 핵심 과제는 렌더링 성능(60fps 드로잉), 터치 입력 정확도, 반응형 Canvas 크기 조정, 그리고 다양한 화면 크기에 걸친 좌표 매핑입니다. 해결책은 렌더링에 OffscreenCanvas를 사용하고, 좌표를 정규화하며, 드로잉을 절대 픽셀이 아닌 상대 위치로 저장하는 것입니다.
왜 드로잉 피드백인가
“색 보정을 고쳐주세요” 같은 텍스트 피드백은 모호합니다. “이 영역에 원을 그리고 10% 더 밝게 만들어주세요” 같은 드로잉 피드백은 정확합니다.
저희 드로잉 도구는 리뷰어가 무엇을 고쳐야 하는지 정확히 원, 화살표, 또는 하이라이트로 표시할 수 있게 합니다. 이를 통해 수정 반복 횟수가 45라운드에서 23라운드로 줄어듭니다.
아키텍처
Video Player (HLS.js)
↓ (Canvas overlay)
Drawing Canvas (OffscreenCanvas + MainThread Canvas)
↓ (Input events - touch/mouse)
Input Handler (Gesture Recognition)
↓
Drawing Engine (Path Storage + Rendering)
↓
Annotation Storage (Canvas as SVG/JSON)
핵심 인사이트는 드로잉이 재생을 막지 않으면서 비디오 위에서 이루어져야 한다는 것입니다. 저희는 OffscreenCanvas를 사용해 메인 스레드 바깥에서 렌더링합니다.
Canvas 설정하기
HTML 구조
<div class="video-container">
<video id="mainVideo"></video>
<!-- Canvas overlay for drawing -->
<canvas id="drawingCanvas"
style="position: absolute; top: 0; left: 0;"></canvas>
<!-- Toolbar for tools selection -->
<div class="drawing-toolbar">
<button id="penTool">Pen</button>
<button id="circleTool">Circle</button>
<button id="arrowTool">Arrow</button>
<button id="eraserTool">Eraser</button>
<input type="color" id="colorPicker" value="#FF0000">
<button id="clearCanvas">Clear</button>
</div>
</div>
Canvas 초기화
class DrawingEngine {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private drawingData: DrawingLayer;
private isDrawing: boolean = false;
constructor(canvasElement: HTMLCanvasElement, videoElement: HTMLVideoElement) {
this.canvas = canvasElement;
this.ctx = canvasElement.getContext('2d', {
antialias: true,
willReadFrequently: true
});
// Match canvas size to video dimensions
this.resizeCanvasToVideoSize(videoElement);
// Setup input handlers
this.setupInputHandlers();
}
private resizeCanvasToVideoSize(videoElement: HTMLVideoElement) {
videoElement.addEventListener('loadedmetadata', () => {
const { videoWidth, videoHeight } = videoElement;
this.canvas.width = videoWidth;
this.canvas.height = videoHeight;
// Store aspect ratio for coordinate mapping
this.aspectRatio = videoWidth / videoHeight;
});
}
}
Canvas 크기를 비디오에 맞추는 것이 매우 중요합니다. Canvas가 800x600인데 비디오가 1920x1080이라면 좌표가 잘못 매핑됩니다.
입력 처리
마우스 및 터치 이벤트
private setupInputHandlers() {
this.canvas.addEventListener('mousedown', (e) => this.handlePointerDown(e));
this.canvas.addEventListener('mousemove', (e) => this.handlePointerMove(e));
this.canvas.addEventListener('mouseup', (e) => this.handlePointerUp(e));
// Touch support
this.canvas.addEventListener('touchstart', (e) => this.handlePointerDown(e));
this.canvas.addEventListener('touchmove', (e) => this.handlePointerMove(e));
this.canvas.addEventListener('touchend', (e) => this.handlePointerUp(e));
}
private handlePointerDown(event: MouseEvent | TouchEvent) {
this.isDrawing = true;
const pos = this.getCanvasCoordinates(event);
if (this.currentTool === 'pen') {
this.startPath(pos);
} else if (this.currentTool === 'circle') {
this.startShape('circle', pos);
} else if (this.currentTool === 'arrow') {
this.startShape('arrow', pos);
}
}
private handlePointerMove(event: MouseEvent | TouchEvent) {
if (!this.isDrawing) return;
const pos = this.getCanvasCoordinates(event);
if (this.currentTool === 'pen') {
this.drawPath(pos);
} else if (this.currentTool === 'circle' || this.currentTool === 'arrow') {
this.updateShape(pos);
}
}
private handlePointerUp(event: MouseEvent | TouchEvent) {
this.isDrawing = false;
this.finalizeDrawing();
}
좌표 매핑
이 부분이 매우 중요합니다. Canvas 좌표는 줌 레벨이나 표시 크기와 관계없이 정확하게 매핑되어야 합니다.
private getCanvasCoordinates(event: MouseEvent | TouchEvent): [number, number] {
let clientX, clientY;
if (event instanceof MouseEvent) {
clientX = event.clientX;
clientY = event.clientY;
} else {
const touch = event.touches[0];
clientX = touch.clientX;
clientY = touch.clientY;
}
// Get canvas position on screen
const rect = this.canvas.getBoundingClientRect();
// Get relative position within canvas
const relativeX = clientX - rect.left;
const relativeY = clientY - rect.top;
// Map to actual canvas coordinates
// (in case canvas is displayed at different size via CSS)
const canvasX = (relativeX / rect.width) * this.canvas.width;
const canvasY = (relativeY / rect.height) * this.canvas.height;
return [canvasX, canvasY];
}
예시: 사용자가 비디오를 CSS로 50% 크기로 줌했다고 가정합니다. 화면상의 Canvas는 400x300입니다. Canvas 실제 해상도는 800x600입니다. 사용자가 화면 위치 (200, 150)를 클릭합니다. 매핑 결과: (200/400) * 800 = 400 Canvas 픽셀, (150/300) * 600 = 300 Canvas 픽셀. 정확합니다!
드로잉 도구 구현
펜 도구
private currentPath: [number, number][] = [];
private startPath(pos: [number, number]) {
this.currentPath = [pos];
this.ctx.beginPath();
this.ctx.moveTo(pos[0], pos[1]);
}
private drawPath(pos: [number, number]) {
this.currentPath.push(pos);
// Draw line to new position
this.ctx.lineTo(pos[0], pos[1]);
this.ctx.strokeStyle = this.currentColor;
this.ctx.lineWidth = 3;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
this.ctx.stroke();
}
private finalizeDrawing() {
// Store path for persistence
this.drawingData.addPath({
type: 'pen',
points: this.currentPath,
color: this.currentColor,
lineWidth: 3
});
this.currentPath = [];
}
원 도구
private circleStart: [number, number] | null = null;
private startShape(type: 'circle' | 'arrow', pos: [number, number]) {
this.circleStart = pos;
}
private updateShape(pos: [number, number]) {
if (!this.circleStart) return;
// Redraw canvas to clear previous preview
this.redrawCanvas();
// Draw circle preview
const radius = this.distance(this.circleStart, pos);
this.ctx.beginPath();
this.ctx.arc(this.circleStart[0], this.circleStart[1], radius, 0, 2 * Math.PI);
this.ctx.strokeStyle = this.currentColor;
this.ctx.lineWidth = 2;
this.ctx.stroke();
}
private finalizeDrawing() {
// Store circle for persistence
this.drawingData.addShape({
type: 'circle',
center: this.circleStart,
radius: this.distance(this.circleStart, this.lastPos),
color: this.currentColor
});
this.circleStart = null;
}
private distance(p1: [number, number], p2: [number, number]): number {
return Math.sqrt(Math.pow(p2[0] - p1[0], 2) + Math.pow(p2[1] - p1[1], 2));
}
성능 최적화
60fps로 드로잉하려면 세심한 최적화가 필요합니다.
다시 그리기 전략
매 프레임마다 Canvas의 모든 픽셀을 다시 그립니다. 수백 개의 획이 있다면 비용이 많이 듭니다.
private redrawCanvas() {
// Clear canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Redraw all persistent drawings
for (const drawing of this.drawingData.getAll()) {
if (drawing.type === 'pen') {
this.redrawPath(drawing);
} else if (drawing.type === 'circle') {
this.redrawCircle(drawing);
}
}
}
private redrawPath(path: PathDrawing) {
this.ctx.strokeStyle = path.color;
this.ctx.lineWidth = path.lineWidth;
this.ctx.beginPath();
this.ctx.moveTo(path.points[0][0], path.points[0][1]);
for (let i = 1; i < path.points.length; i++) {
this.ctx.lineTo(path.points[i][0], path.points[i][1]);
}
this.ctx.stroke();
}
이 방식은 매 프레임마다 모든 드로잉을 다시 그립니다. 100개 이상의 드로잉에서는 비용이 큽니다.
최적화: 정적 Canvas에 드로잉을 레이어링하기
private staticCanvas: HTMLCanvasElement;
private staticCtx: CanvasRenderingContext2D;
private drawingsDirty: boolean = true;
private drawToStaticCanvas() {
if (!this.drawingsDirty) return; // Skip if nothing changed
// Draw all persistent drawings to static canvas
this.staticCtx.clearRect(0, 0, this.staticCanvas.width, this.staticCanvas.height);
for (const drawing of this.drawingData.getAll()) {
// ... redraw to static canvas
}
this.drawingsDirty = false;
}
private render() {
// Clear main canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Copy static canvas (already drawn)
this.ctx.drawImage(this.staticCanvas, 0, 0);
// Draw current in-progress drawing
if (this.isDrawing) {
// ... draw current pen stroke only
}
}
결과: 100개의 드로잉에서 다시 그리기 시간이 50ms에서 5ms로 줄어듭니다.
주석 저장하기
드로잉은 영속적으로 보관되고 다시 불러올 수 있어야 합니다.
벡터 저장 (픽셀 기반이 아님)
Canvas를 이미지 비트맵으로 저장하지 마세요. 벡터로 저장하세요:
interface DrawingAnnotation {
id: string;
frameNumber: number;
timestamp: number;
creatorId: string;
drawings: DrawingElement[];
}
interface DrawingElement {
type: 'pen' | 'circle' | 'arrow';
color: string;
// For pen
points?: [number, number][]; // Relative coordinates (0-1)
// For circle
center?: [number, number];
radius?: number;
// For arrow
start?: [number, number];
end?: [number, number];
}
// Store relative coordinates (0-1) instead of absolute pixels
private normalizeCoordinates(canvasCoords: [number, number]): [number, number] {
return [
canvasCoords[0] / this.canvas.width,
canvasCoords[1] / this.canvas.height
];
}
// When restoring, scale to current canvas size
private denormalizeCoordinates(normalized: [number, number]): [number, number] {
return [
normalized[0] * this.canvas.width,
normalized[1] * this.canvas.height
];
}
왜 상대 좌표일까요? 비디오가 다른 해상도로 다시 인코딩되더라도 주석이 여전히 정확하게 매핑되기 때문입니다.
해상도 독립성
비디오는 어떤 해상도로든 리뷰될 수 있습니다. 드로잉은 정확하게 스케일링되어야 합니다.
private scaleDrawingToResolution(
drawing: DrawingAnnotation,
newWidth: number,
newHeight: number
) {
return {
...drawing,
drawings: drawing.drawings.map(elem => {
if (elem.type === 'pen') {
return {
...elem,
points: elem.points.map(p => [
p[0] * newWidth,
p[1] * newHeight
])
};
} else if (elem.type === 'circle') {
return {
...elem,
center: [elem.center[0] * newWidth, elem.center[1] * newHeight],
radius: elem.radius * Math.min(newWidth / 1920, newHeight / 1080)
};
}
return elem;
})
};
}
드로잉 내보내기
크리에이터는 외부에서 사용하기 위해 드로잉 피드백을 내보내야 합니다.
Canvas를 SVG로 변환
private canvasToSVG(): string {
let svg = `<svg width="${this.canvas.width}" height="${this.canvas.height}"
xmlns="http://www.w3.org/2000/svg">`;
for (const drawing of this.drawingData.getAll()) {
if (drawing.type === 'pen') {
const pathData = `M ${drawing.points.map(p => `${p[0]} ${p[1]}`).join(' L ')}`;
svg += `<path d="${pathData}" stroke="${drawing.color}"
stroke-width="3" fill="none" stroke-linecap="round"/>`;
} else if (drawing.type === 'circle') {
svg += `<circle cx="${drawing.center[0]}" cy="${drawing.center[1]}"
r="${drawing.radius}" stroke="${drawing.color}"
stroke-width="2" fill="none"/>`;
}
}
svg += '</svg>';
return svg;
}
이렇게 하면 드로잉을 SVG로 내보내 Photoshop, Figma, 또는 어떤 디자인 도구에서든 열 수 있습니다.
성능 지표
드로잉이 반응성을 유지하도록 다음 항목을 추적하세요:
- 드로잉 지연 시간(Drawing latency): 펜을 누른 순간부터 획이 보이기까지의 시간. 목표: <50ms(체감되지 않음).
- 다시 그리기 시간(Redraw time): 프레임당 모든 드로잉을 다시 그리는 시간. 목표: <5ms(60fps 예산은 16ms).
- 드로잉당 메모리(Memory per drawing): 저장된 주석의 크기. 목표: 드로잉당 <1KB.
- 내보내기 시간(Export time): Canvas를 SVG로 변환하는 시간. 목표: 50개 이상의 드로잉에서도 <100ms.
ELBA에서는 드로잉 지연 시간이 평균 20ms입니다. 사용자는 이를 즉각적인 것으로 인식합니다.
배운 점
- 상대 좌표는 필수입니다. 해상도 독립성을 위해서입니다.
- 정적 Canvas로 다시 그리기를 최적화하여 60fps를 유지하세요.
- 좌표 매핑은 틀리기 쉽습니다—모바일과 다양한 줌 레벨에서 충분히 테스트하세요.
- 벡터 저장이 래스터보다 낫습니다. 유연성과 재사용성 면에서요.
- 터치와 마우스는 다릅니다 - 둘 다 신중하게 처리하세요.
드로잉 피드백 도구는 YouViCo에서 가장 많이 사용되는 기능 중 하나입니다. 팀들은 그 정확성을 사랑합니다.