Skip to content

Instantly share code, notes, and snippets.

@joeskeen
Last active November 20, 2025 19:08
Show Gist options
  • Select an option

  • Save joeskeen/1f998a804b08e94aaacadfd092acb34f to your computer and use it in GitHub Desktop.

Select an option

Save joeskeen/1f998a804b08e94aaacadfd092acb34f to your computer and use it in GitHub Desktop.
A vanilla HTML/CSS/JS implementation of rendering your webcam as ASCII art on a web page (and ability to turn that into a virtual webcam for video calls)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ASCII Webcam</title>
<style type="text/css">
html,
body {
background-color: black;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
max-width: 100vw;
max-height: 100vh;
}
video.original-video {
display: none;
}
div.ascii-video {
white-space: pre;
font-family: monospace;
font-size: 9px;
line-height: 8px;
color: green;
margin-left: auto;
margin-right: auto;
width: auto;
}
canvas.scaled-video {
display: none;
}
</style>
</head>
<body>
<video class="original-video"></video>
<canvas class="scaled-video"></canvas>
<div class="ascii-video"></div>
<script type="text/javascript">
(async () => {
// Define the green screen color to filter out
const green = { r: 68, g: 176, b: 145 };
const frameRate = 10;
const scaleWidth = 250;
const rampSize = 8;
const asciiCharsDarkToLight = createRamp(rampSize); // you can replace this with a custom string
const interval = Math.floor(1000 / frameRate);
const videoElement = document.querySelector("video.original-video");
const scaledVideo = document.querySelector("canvas.scaled-video");
const asciiVideo = document.querySelector("div.ascii-video");
const loadPromise = new Promise((resolve) => {
videoElement.onloadeddata = resolve;
});
const cameraChoice = (document.location.hash || "#").substring(1);
const stream = await navigator.mediaDevices.getUserMedia({
video: cameraChoice ? { deviceId: cameraChoice } : true,
});
console.log({ cameraChoice, stream });
videoElement.srcObject = stream;
videoElement.play();
await loadPromise;
const { videoWidth, videoHeight } = videoElement;
const scaleHeight = Math.floor((videoHeight * scaleWidth) / videoWidth);
console.log({ videoWidth, videoHeight, scaleWidth, scaleHeight });
scaledVideo.width = scaleWidth;
scaledVideo.height = scaleHeight;
const ctx = scaledVideo.getContext("2d", { willReadFrequently: true });
draw();
function draw() {
ctx.drawImage(videoElement, 0, 0, scaleWidth, scaleHeight);
const imageData = ctx.getImageData(
0,
0,
scaleWidth,
scaleHeight
).data;
const ascii = [[]];
for (let i = 0; i < scaleWidth * scaleHeight * 4; i += 4) {
const row = ascii[ascii.length - 1];
let r = imageData[i];
let g = imageData[i + 1];
let b = imageData[i + 2];
let a = imageData[i + 3];
if (i === 48) {
// console.log(`rgba(${r},${g},${b},${a})`);
}
// filter out green screen pixels using euclidian distance
const distance = Math.sqrt(
Math.pow(r - green.r, 2) +
Math.pow(g - green.g, 2) +
Math.pow(b - green.b, 2)
);
if (distance < 95) {
r = 0;
g = 0;
b = 0;
}
const brightness = (r + g + b) / 3;
const index = Math.floor(
(brightness / 255) * asciiCharsDarkToLight.length
);
const char = asciiCharsDarkToLight[index];
row.push(char);
if (i % (scaleWidth * 4) === 0) {
ascii.push([]);
}
}
// asciiVideo.innerHTML = ascii
// .map((row) => row.map((char) => `<span>${char}</span>`).join(""))
// .join("<br>");
asciiVideo.textContent = ascii.map((row) => row.join("")).join("\n");
setTimeout(draw, interval);
}
function createRamp(rampLength) {
// Expanded character set: ASCII, blocks, suits, etc.
const chars = [];
// include ASCII characters
for (let i = 32; i < 127; i++) chars.push(String.fromCharCode(i));
// include additional block characters
// const extraChars = " █▓▒░■□◆◇●○◼◻♠♣♥♦▲△▼▽◀▶◈◉◊";
const extraChars = " ♠♣♥♦";
extraChars.split("").forEach((c) => chars.push(c));
// Canvas setup
const canvas = document.createElement("canvas");
canvas.width = 100;
canvas.height = 100;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
// Analyze each character
const charMap = [];
chars.forEach((char) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = "bold 32px monospace";
ctx.fillStyle = "#fff";
ctx.textBaseline = "top";
ctx.fillText(char, 4, 4);
const imageData = ctx.getImageData(
0,
0,
canvas.width,
canvas.height
).data;
let darkPixels = 0;
for (let i = 0; i < imageData.length; i += 4) {
if (
imageData[i] > 16 ||
imageData[i + 1] > 16 ||
imageData[i + 2] > 16
) {
darkPixels++;
}
}
charMap.push({ char, darkPixels });
});
// Remove duplicates and blanks
const unique = [];
const seen = new Set();
charMap
.sort((a, b) => a.darkPixels - b.darkPixels)
.forEach(({ char, darkPixels }) => {
if (!seen.has(darkPixels)) {
unique.push({ char, darkPixels });
seen.add(darkPixels);
}
});
// Evenly sample across the darkness range
const minDark = unique[0].darkPixels;
const maxDark = unique[unique.length - 1].darkPixels;
const step = (maxDark - minDark) / (rampLength - 1);
console.log({ minDark, maxDark, step });
const ramp = [];
for (let i = 0; i < rampLength; i++) {
const target = minDark + i * step;
// Find character closest to target darkness
let closest = unique[0];
let minDiff = Math.abs(unique[0].darkPixels - target);
for (let j = 1; j < unique.length; j++) {
const diff = Math.abs(unique[j].darkPixels - target);
if (diff < minDiff) {
closest = unique[j];
minDiff = diff;
}
}
ramp.push(closest);
}
// Output result
console.log("Evenly distributed ramp with darkness values:");
ramp.forEach(({ char, darkPixels }) => {
console.log(`'${char}': ${darkPixels}`);
});
const rampString = ramp.map(({ char }) => char).join("");
console.log({ rampString });
return rampString;
}
})();
</script>
</body>
</html>
@joeskeen
Copy link
Author

joeskeen commented Nov 15, 2022

ASCII Art Webcam

image

Thanks to The Coding Train on YouTube for the P5.js tutorial upon which this is based! https://youtu.be/55iwMYv8tGI

Prerequisites

  • OBS Studio
  • NodeJS
  • A webcam

Running in the Browser

  • Save the HTML file locally as index.html
  • Open a command prompt/terminal window to the directory you saved it to
  • Run npx http-server .. This tells Node to pull down the package http-server and run it in the current directory. If this works, you will get output similar to this:
    Starting up http-server, serving .
    
    http-server version: 14.1.1
    
    http-server settings: 
    CORS: disabled
    Cache: 3600 seconds
    Connection Timeout: 120 seconds
    Directory Listings: visible
    AutoIndex: visible
    Serve GZIP Files: false
    Serve Brotli Files: false
    Default File Extension: none
    
    Available on:
      http://127.0.0.1:8080
      http://192.168.0.192:8080
    Hit CTRL-C to stop the server
    
  • Go to http://127.0.0.1 to see yourself in ASCII!

Use the ASCII version of yourself in video meetings

  • Have the npx http-server . command from above running in the background
  • Run OBS with the extra command-line argument --use-fake-ui-for-media-stream. This allows OBS to auto-accept the permission to microphone and webcam in Browser sources
  • In OBS, add a Browser source. Configure its URL to http://127.0.0.1. Set the width and height to 1920 and 1080, respectively.
  • If everything is running and configured correctly, you should see yourself in OBS.
  • On the bottom-right of OBS, click Start Virtual Webcam
  • Join your video call. Configure your video settings to use the "OBS Virtual Webcam" webcam.
  • People should now be able to see you as ASCII!

Customization and Notes

This attempts to detect a green screen behind you and remove it. If you don't have a green screen, or your green screen is a different color than mine, you can delete or make adjustments to lines 44, 91, and/or 95-104.

If you want to target a specific webcam, you can specify it on line 59. See getUserMedia documentation on MDN for information on getting media device IDs.

If you want to change the "resolution" of the ASCII art, change lines 46 and 25-26.

To change the number of frames per second, change line 45.

To change the background or foreground colors, change lines 11 and/or 27.

@joeskeen
Copy link
Author

Update in 2025:
I had to launch OBS with the following command-line parameters (you can edit the shortcut/desktop file to add these in by default):

Required: --enable-media-stream --use-fake-ui-for-media-stream --enable-gpu

Optional but helpful: --startvirtualcam

Good for debugging: --remote-debugging-port=9222 --remote-allow-origins=http://localhost:9222 (then you can open http://localhost:9222 in the browser to see the dev console and debug errors)

I also updated the Gist to allow you to specify a device ID by adding it as the hash in the URL. For instance: http://localhost:8080#MY_DEVICE_ID. You can get that device ID by running this in the dev console of your browser:

await navigator.mediaDevices.enumerateDevices()

Then find the one you are looking for and copy its deviceId value.

@joeskeen
Copy link
Author

joeskeen commented Nov 20, 2025

Also, if you don't want to host the file yourself, you can use the following URL to just use the Gist directly in OBS:

https://html-preview.github.io/?url=https://gist.githubusercontent.com/[GIST_OWNER]/[GIST_ID]/raw/[GIST_REVISION_ID]/ASCII_Webcam.html#[DEVICE_ID]

Currently, that URL is https://html-preview.github.io/?url=https://gist.githubusercontent.com/joeskeen/1f998a804b08e94aaacadfd092acb34f/raw/f7cc41974170bb5e3758f6e4c2b0368ecb056e46/ASCII-Webcam.html

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment