Search

Sign in to launch Copilot/Codex from the palette.

VIDEO TO ASCII: CONVERTING VIDEOS TO ANIMATED ASCII ART

Upload video, customize colors, watch frames convert to ASCII in real-time.

Video Processing 2026.01.01
 

The Constraint

I wanted an ASCII video toy where an uploaded file never leaves the browser. That means accepting browser CPU and memory limits rather than hiding them behind a server upload.

Implementation: Canvas extracts frames in the client. Pixel-region averaging maps luminance to characters. A <pre> renders the result and exports as an embeddable snippet.

Try It Now

No limit - higher = more detail

Font Size Controls

0.5x 3x

Architecture

The system uses a client-side approach with Canvas API for frame extraction. Each video is processed entirely in the browser, storing frames as ASCII string arrays. HTML text rendering displays frames using requestAnimationFrame for smooth 30fps playback with crisp, scalable output.

Why client-side? Uploaded files stay on the device. The tradeoff is explicit: longer clips and higher density use more memory and take longer to process.

Quality optimizations: Extended ASCII character set (70+ chars), luminance-based mapping with pixel region averaging, configurable density (10-300 chars), color customization, and precise font rendering with measured character spacing.

How It Works

Frame extraction: The component creates a hidden HTML video element and uses the Canvas API to extract frames. It seeks through the video at 30fps intervals, drawing each frame to a temporary canvas element. This leverages the browser's native video decoding capabilities.

Processing flow: Each frame is converted to ASCII using luminance-based character mapping. The processed frames are stored as string arrays in memory. A requestAnimationFrame loop displays frames at 30fps, creating smooth animation.

// Client-side video processing
async function processVideo(
  file: File,
  bgColor: string,
  textColor: string,
  density: number
) {
  // Create hidden video element
  const video = document.createElement("video");
  video.src = URL.createObjectURL(file);
  await video.load();

  const fps = 30;
  const frameInterval = 1 / fps;
  const totalFrames = Math.floor(video.duration * fps);
  const asciiFrames: string[] = [];

  // Create temporary canvas for frame extraction
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d")!;
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;

  // Extract and convert each frame
  for (let i = 0; i < totalFrames; i++) {
    video.currentTime = i * frameInterval;
    await new Promise((resolve) => {
      video.onseeked = resolve;
    });

    // Draw frame to canvas
    ctx.drawImage(video, 0, 0);
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

    // Convert to ASCII
    const ascii = convertFrameToASCII(imageData, density);
    asciiFrames.push(ascii);

    // Update progress
    updateProgress(i + 1, totalFrames);
  }

  // Start animation
  startAnimation(asciiFrames, bgColor, textColor);
}

function updateProgress(current: number, total: number) {
  console.log(`Processing frame ${current}/${total}`);
}

function startAnimation(
  frames: string[],
  bgColor: string,
  textColor: string
) {
  const display = document.getElementById("ascii-display")!;
  let frameIndex = 0;

  function animate() {
    display.textContent = frames[frameIndex];
    frameIndex = (frameIndex + 1) % frames.length;
    requestAnimationFrame(animate);
  }

  animate();
}

ASCII Conversion

Luminance mapping: Each pixel region is converted to a single ASCII character based on its luminance value. The formula 0.299*R + 0.587*G + 0.114*B accurately represents perceived brightness, matching how human eyes perceive grayscale.

Quality improvements: Instead of sampling a single pixel per character, we average multiple pixels across a region (4-9 pixels depending on density). This reduces noise and produces smoother gradients. The extended character set (70+ chars) provides fine-grained luminance steps for high-quality output.

Character selection: Characters are ordered from darkest to lightest. The luminance value (0-255) is normalized and mapped to the character array index, ensuring consistent visual representation across different input images.

// ASCII conversion algorithm
function convertToASCII(
  imageData: ImageData,
  config: { bgColor: string; textColor: string; density: number }
): string {
  const { width, height, data } = imageData;
  const { bgColor, textColor, density } = config;
  
  // Character gradient (70+ characters for quality)
  const chars = " .'^`",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$";
  
  // Calculate output dimensions
  const charWidth = Math.floor(width / density);
  const charHeight = Math.floor(height / (density * 2)); // Characters are taller
  
  let ascii = "";
  
  for (let y = 0; y < charHeight; y++) {
    for (let x = 0; x < charWidth; x++) {
      // Sample pixel from original image
      const px = Math.floor((x / charWidth) * width);
      const py = Math.floor((y / charHeight) * height);
      const idx = (py * width + px) * 4;
      
      // Calculate luminance (0-255)
      const r = data[idx];
      const g = data[idx + 1];
      const b = data[idx + 2];
      const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
      
      // Map luminance to character
      const charIndex = Math.floor((luminance / 255) * (chars.length - 1));
      ascii += chars[charIndex];
    }
    ascii += "\n";
  }
  
  return ascii;
}

// Render ASCII to canvas with colors
function renderASCII(
  ascii: string,
  canvas: HTMLCanvasElement,
  bgColor: string,
  textColor: string
) {
  const ctx = canvas.getContext("2d")!;
  ctx.fillStyle = bgColor;
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  
  ctx.fillStyle = textColor;
  ctx.font = "12px monospace";
  ctx.textBaseline = "top";
  
  const lines = ascii.split("\n");
  lines.forEach((line, y) => {
    ctx.fillText(line, 0, y * 12);
  });
}

Client Implementation

Frame extraction: The client creates a hidden video element, seeks through the video at 30fps intervals, and draws each frame to a temporary canvas. This approach leverages the browser's native video decoding capabilities.

HTML text rendering: Processed ASCII frames are stored as string arrays. A requestAnimationFrame loop updates a <pre> element at 30fps, creating smooth animation. Native HTML text rendering ensures crisp, scalable output that works as an embeddable component.

// Client-side video processing
async function processVideo(
  file: File,
  bgColor: string,
  textColor: string,
  density: number
) {
  // Create hidden video element
  const video = document.createElement("video");
  video.src = URL.createObjectURL(file);
  await video.load();

  const fps = 30;
  const frameInterval = 1 / fps;
  const totalFrames = Math.floor(video.duration * fps);
  const asciiFrames: string[] = [];

  // Create temporary canvas for frame extraction
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d")!;
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;

  // Extract and convert each frame
  for (let i = 0; i < totalFrames; i++) {
    video.currentTime = i * frameInterval;
    await new Promise((resolve) => {
      video.onseeked = resolve;
    });

    // Draw frame to canvas
    ctx.drawImage(video, 0, 0);
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

    // Convert to ASCII
    const ascii = convertFrameToASCII(imageData, density);
    asciiFrames.push(ascii);

    // Update progress
    updateProgress(i + 1, totalFrames);
  }

  // Start animation
  startAnimation(asciiFrames, bgColor, textColor);
}

function updateProgress(current: number, total: number) {
  console.log(`Processing frame ${current}/${total}`);
}

function startAnimation(
  frames: string[],
  bgColor: string,
  textColor: string
) {
  const display = document.getElementById("ascii-display")!;
  let frameIndex = 0;

  function animate() {
    display.textContent = frames[frameIndex];
    frameIndex = (frameIndex + 1) % frames.length;
    requestAnimationFrame(animate);
  }

  animate();
}

Quality Optimizations

  • Extended character set: 70+ characters ordered by visual density for smooth gradients
  • Luminance mapping: 0.299*R + 0.587*G + 0.114*B formula matches human eye perception
  • Pixel region averaging: Samples 4-9 pixels per character region instead of single pixel, reducing noise
  • Configurable density: 10-300 characters wide (tiny to high-quality), default 150
  • Crisp HTML text rendering: Native browser text rendering with optimized font smoothing for scalable output
  • Color customization: Background and text colors applied via CSS
  • Embeddable component: Self-contained HTML snippet with responsive sizing via ResizeObserver
  • Smooth animation: 30fps playback via requestAnimationFrame with frame timing control

Limitations

  • Browser memory: Large videos may cause performance issues (processing happens entirely client-side)
  • Processing time: Depends on video length and density (all processing happens in browser)
  • Formats: MP4, WebM, MOV (browser-supported formats)
  • Privacy: Files never leave the device (pro, but limits processing power)