Skip to content

How We Built Drawing Feedback Tools for Video Review

TL;DR

Drawing feedback tools overlay a canvas on video frames, capture pen/touch input, and store annotations as vector coordinates. Core challenges: rendering performance (60fps drawing), touch input accuracy, responsive canvas resizing, and coordinate mapping across different screen sizes. Solution: use OffscreenCanvas for rendering, normalize coordinates, and store drawings as relative positions (not absolute pixels).

Why Drawing Feedback

Text feedback like “fix the color grading” is vague. Drawing feedback like “circle this area and make it 10% brighter” is precise.

Our drawing tools let reviewers circle, arrow, or highlight exactly what needs fixing. This reduces revision iterations from 4-5 rounds to 2-3 rounds.

Architecture

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)

The key insight: drawing must happen on top of video without blocking playback. We use OffscreenCanvas to render off the main thread.

Setting Up the Canvas

HTML Structure

<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 Initialization

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;
    });
  }
}

Matching canvas size to video is critical. If canvas is 800x600 but video is 1920x1080, coordinates map incorrectly.

Input Handling

Mouse and Touch Events

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();
}

Coordinate Mapping

This is critical: canvas coordinates must map correctly regardless of zoom level or displayed size.

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];
}

Example: User zoomed video to 50% size (via CSS). Canvas on screen is 400x300. Canvas actual resolution is 800x600. User clicks at screen position (200, 150). Map to: (200/400) * 800 = 400 canvas pixels, (150/300) * 600 = 300 canvas pixels. Correct!

Drawing Tools Implementation

Pen Tool

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 = [];
}

Circle Tool

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));
}

Performance Optimization

Drawing at 60fps requires careful optimization.

Redraw Strategy

Every pixel of canvas is redrawn each frame. For hundreds of strokes, this is expensive.

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();
}

This redraws all drawings every frame. Expensive for 100+ drawings.

Optimization: layer drawing onto static 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
  }
}

Result: redraw time drops from 50ms to 5ms for 100 drawings.

Storing Annotations

Drawings must persist and be retrievable.

Vector Storage (Not Pixel-Based)

Don’t store canvas as image bitmap. Store as vectors:

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
  ];
}

Why relative coordinates? If video gets re-encoded at different resolution, annotations still map correctly.

Resolution Independence

Videos can be reviewed at any resolution. Drawings must scale correctly.

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;
    })
  };
}

Exporting Drawings

Creators need to export drawing feedback for external use.

Canvas to 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;
}

This allows exporting drawings as SVG that can be opened in Photoshop, Figma, or any design tool.

Performance Metrics

Track these to ensure drawing stays responsive:

At ELBA, drawing latency averages 20ms. Users perceive it as instant.

Lessons Learned

  1. Relative coordinates are essential for resolution independence.
  2. Optimize redraw with static canvas to maintain 60fps.
  3. Coordinate mapping is easy to get wrong—test extensively on mobile and different zoom levels.
  4. Vector storage beats raster for flexibility and re-use.
  5. Touch and mouse are different - handle both carefully.

Drawing feedback tools are one of YouViCo’s most-used features. Teams love the precision.

Ready to streamline your video collaboration?

Get started for free