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