Не так давно, когда у нас не было графического интерфейса, а были только консоли, люди создавали множество крутых приложений и игр, используя только символы 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.
Это моя первая попытка опубликоваться на медиуме. Интересно узнать ваше мнение.