Не так давно, когда у нас не было графического интерфейса, а были только консоли, люди создавали множество крутых приложений и игр, используя только символы ascii. Мне всегда нравилась эта идея, но в современном мире она не так полезна, но мы все равно можем получить массу удовольствия, играя с символами ascii, пытаясь что-то создать. Я решил пойти по этому пути и сделал видеопоток с веб-камеры доступным в виде символов ascii, и это оказалось довольно просто.

Что нам понадобится: браузер, vanilla JavaScript, Google, MDN
Что нам нужно сделать:
1. Получить видеопоток;
2. Получить цвета каждого пикселя;
3. Преобразовать цвет в оттенки серого;
4. Получить символ ascii на основе яркости цвета;

Давайте пошагово.
Сначала нам понадобится базовый ‹html›, который мы будем использовать для отображения видеопотока в формате ASCII.

<!doctype html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Ascii video stream</title>
</head>

<style>
  body {
    background: #00005e
  }

  video, canvas {
    display: none;
  }

  #text-video {
    font-family: Courier, serif; // use it to make chars width equal
    font-size: 6px;
    line-height: 4px;
    color: white;
  }
</style>

<body>
<div>
  <div>
    <video id="video">Video stream not available.</video>
    <canvas id="canvas-video"></canvas>
  </div>
  <div id="text-video"></div>
  <button id="stop">Stop</button>
</div>
</body>
</html>

Этот HTML-код очень прост и содержит 3 важные вещи: ‹video› для простого захвата видеопотока, ‹canvas›, который будет использоваться для отображения видео и обработки пикселей, и ‹div id="text-video"›, который будет отображать символы ascii.

Теперь перейдем к отображению видеопотока.

Это довольно простая и удобная вещь, которую можно сделать в несколько строк кода.

const video = document.getElementById('video');
const initTextVideo = () => {
  navigator.mediaDevices.getUserMedia({ video: true, audio: false })
    .then(function (stream) {
      video.srcObject = stream;
      video.play();
    })
    .catch(function (err) {
      console.log("An error occurred: " + err);
    });
}

Метод getUserMedia используется для получения пользовательского медиа, в зависимости от предоставленных параметров, в нашем случае мы будем использовать только видео. Подробнее о getUserMedia можно прочитать здесь. Согласно MDN: getUserMedia возвращает «Promise, который разрешается в объект MediaStream. Если пользователь отказывает в разрешении или соответствующий носитель недоступен, обещание отклоняется с помощью NotAllowedError или NotFoundError DOMException соответственно». После разрешения обещания мы можем установитьsrcObject ‹video› в поток, возвращаемый Promise, и вызвать метод play(), который попытается начать воспроизведение мультимедиа. .

Теперь пришло время для второго шага — получить пиксели и обработать их.

Сначала нарисуем видеопоток на ‹canvas›.

const canvas = document.getElementById('canvas-video');
const ctx = canvas.getContext('2d');
const width = 320 / 2, height = 240 / 2;
const clearphoto = (ctx) => {
  ctx.fillStyle = "#fff";
  ctx.fillRect(0, 0, width, height);
}

const render = (ctx) => {
  if (width && height) {
    canvas.width = width; // setting canvas resolution
    canvas.height = height;
    ctx.drawImage(video, 0, 0, width, height);
  } else {
    clearphoto(ctx);
  }
}

Это все, что нам нужно, чтобы начать рисовать видео на ‹canvas›. Просто, не так ли? С этим кодом. Основная часть здесь — ctx.drawImage(video, 0, 0, width, height). Этой строкой мы сообщаем холсту, что нужно получить видео и отрисовать его внутри холста.

Теперь мы подходим к самому интересному — преобразованию пикселей в ascii и их отрисовке.

const gradient = "_______.:!/r(l1Z4H9W8$@";
const preparedGradient = gradient.replaceAll('_', '\u00A0')
const getPixelsGreyScale = (ctx) => {
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const data = imageData.data;
  let row = 0
  const res = new Array(height).fill(0).map(() => []);
  for (let i = 0, c = 0; i < data.length; i += 4) {
    const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
    let curr = res[row]
    curr.push(avg)
    if (c < width) {
      c++
    }
    if (c === width) {
      c = 0
      row += 1
    }

  }
  return res
}

const getCharByScale = (scale) => {
  const val = Math.floor(scale / 255 * (gradient.length - 1))
  return preparedGradient[val]
}

const renderText = (node, textDarkScale) => {
  let txt = `<div>`
  for (let i = 0; i < textDarkScale.length; i++) {
    for (let k = 0; k < textDarkScale[i].length; k++) {
      txt = `${txt}${getCharByScale(textDarkScale[i][k])}`
    }
    txt += `<br>`
  }
  txt += `</div>`
  node.innerHTML = txt
}

Здесь у нас есть функция getPixelsGreyScale(), которая фактически выполняет всю работу. Сначала он принимает «ImageData», то есть массив, содержащий значения RGBA для каждого пикселя на холсте. Структура следующая: [r, g, b, a, r, g, b, a,….]. Как видите, это плоский массив, и каждые 4 его элемента относятся к 1 пикселю. И следующее, что мы делаем в этой функции — превращаем rgba в одно значение (от 0 до 255), а также разбиваем плоский массив на «строки».

getCharByScale()используется для выбора символа ascii из списка символов на основе светлоты цвета. Он просто нормализует диапазон значений 0–255 до 0-gradient.length.

renderText() — это гораздо более простое событие. Мы просто берем массив массивов яркости пикселей и с помощью getCharByScale()подготавливаем HTML-код и вставляем его в переданный узел.

Осталось одно — добавить бесконечный цикл для рендеринга персонажей.

const interval = setInterval(() => {
  requestAnimationFrame(() => {
    render(ctx)
    const chars = getPixelsGreyScale(ctx)
    renderText(textVideo, chars)
  })
})

Вот и все… Суть можно проверить на примере здесь.

P.S.

Это моя первая попытка опубликоваться на медиуме. Интересно узнать ваше мнение.