← каталог
ASCII Image
Фотография, пересобранная из ASCII-символов. Пиксели читаются ОДИН раз при загрузке, уменьшение делает сам drawImage, а яркость считается по формуле восприятия — не как среднее каналов.
cssautorevealadvanced~3 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| cols? | number | 110 | Символов по ширине (формат «110»). |
| contrast? | number | 1.35 | Контраст перед раскладкой по символам (формат «1.35»). |
| brightness? | number | 0 | Сдвиг яркости, −0.5…0.5 (формат «0»). |
| colored? | boolean | false | Красить символы цветом исходного пикселя. |
| ramp? | string | RAMP | Набор символов от тёмного к светлому. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/ascii-image.jsonЧто нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
export interface AsciiImageProps {
/** Адрес картинки; должен отдавать CORS, иначе холст «протухнет» и пиксели не прочитать. */
src?: string;
/** Символов по ширине (формат «110»). */
cols?: number;
/** Контраст перед раскладкой по символам (формат «1.35»). */
contrast?: number;
/** Сдвиг яркости, −0.5…0.5 (формат «0»). */
brightness?: number;
/** Волна яркости, бегущая по кадру (формат «0.12»); 0 — статичная картинка. */
wave?: number;
/** Красить символы цветом исходного пикселя. */
colored?: boolean;
/** Набор символов от тёмного к светлому. */
ramp?: string;
}
const RAMP = " .:-=+*#%@";
/**
* Подложка тёмная в ОБЕИХ темах, и это осознанно. ASCII — терминальная эстетика: на светлом
* фоне тёмные участки картинки дают пробел, а светлые — светлый символ, и изображение
* пропадает целиком. Тот же принцип, что у блоков кода: они тоже остаются тёмными везде.
*/
const DARK = "oklch(0.12 0.02 275)";
/**
* Картинка, пересобранная из ASCII-символов на canvas.
*
* Ключ к тому, чтобы это не тормозило: тяжёлое делается ОДИН РАЗ.
*
* `getImageData` — операция синхронного чтения из видеопамяти: она заставляет браузер
* дождаться конца отрисовки и утащить пиксели обратно в оперативную память. Вызывать её
* в каждом кадре означает каждый кадр ломать конвейер. Здесь пиксели читаются один раз
* при загрузке и при изменении размера — картинка ведь не меняется, — а в кадре остаётся
* только выбор символа по уже посчитанной яркости.
*
* Уменьшение делает сам `drawImage` в крошечный закадровый холст размером ровно в сетку
* символов: браузер усредняет пиксели аппаратно и заметно лучше, чем ручной проход по
* массиву. То есть «сжать фото до 110×37» — это одна строка, а не двойной цикл.
*
* Клетка НЕ квадратная, и это влияет на два места сразу: кегль подбирается по измеренной
* ширине символа, а cover-фит считается в пикселях экрана. Пока фит считали в клетках,
* снимок растягивало по вертикали, и от вертикального кадра оставалась неопознаваемая полоса.
*
* Яркость символа берётся по формуле восприятия (0.2126 R + 0.7152 G + 0.0722 B), а не как
* среднее каналов: глаз видит зелёный почти втрое ярче синего, и по среднему небо получается
* светлее травы, хотя на фото наоборот.
*/
export function AsciiImage({
// кадр ГОРИЗОНТАЛЬНЫЙ намеренно: сцена широкая, а cover-фит от портретного снимка
// оставлял узкую полосу из середины, в которой сюжет не опознать
src = "https://motiva.pages.dev/img/1074-900x700.jpg",
cols = 110,
contrast = 1.35,
brightness = 0,
wave = 0.12,
colored = false,
ramp = RAMP,
}: AsciiImageProps = {}) {
const host = useRef<HTMLDivElement>(null);
const cfg = useRef({ cols, contrast, brightness, wave, colored, ramp });
cfg.current = { cols, contrast, brightness, wave, colored, ramp };
useEffect(() => {
const el = host.current;
if (!el) return;
const canvas = document.createElement("canvas");
Object.assign(canvas.style, { position: "absolute", inset: "0", width: "100%", height: "100%" });
el.appendChild(canvas);
const ctx = canvas.getContext("2d");
if (!ctx) return;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// закадровый холст размером ровно в сетку символов
const small = document.createElement("canvas");
const sctx = small.getContext("2d", { willReadFrequently: true })!;
const img = new Image();
img.crossOrigin = "anonymous";
let ready = false;
let gw = 0;
let gh = 0;
let lum: Float32Array = new Float32Array(0);
let rgb: Uint8ClampedArray = new Uint8ClampedArray(0);
let cellW = 0;
let cellH = 0;
let fontPx = 0;
let lo = 0;
let hi = 1;
const mono = () =>
getComputedStyle(el).getPropertyValue("--ds-font-mono") || "ui-monospace, monospace";
/**
* Ширина символа — доля от кегля, и у разных моноширинных шрифтов она разная (0.5–0.62).
* Поэтому МЕРЯЕМ её, а не берём «символ примерно вдвое выше своей ширины»: при кегле
* на глаз соседние символы наезжали друг на друга по горизонтали и смазывали детали.
*/
const advance = () => {
sctx.font = `100px ${mono()}`;
return (sctx.measureText("M").width || 60) / 100;
};
/** Тяжёлая часть: пересэмплировать и прочитать пиксели. Только на загрузке и ресайзе. */
const sample = () => {
if (!ready) return;
const c = cfg.current;
const w = el.clientWidth;
const h = el.clientHeight;
if (!w || !h) return;
// символ мельче ~5 px перестаёт читаться как знак — остаётся дизеринг, а не ASCII.
// На узком экране сетка РЕДЕЕТ, а не мельчает: колонки ограничены шириной кадра
gw = Math.max(8, Math.min(Math.round(c.cols), Math.floor(w / 5)));
cellW = w / gw;
// кегль подбирается так, чтобы символ занял РОВНО клетку по ширине; строки идут
// вплотную — так и набирают ASCII-арт
fontPx = cellW / advance();
cellH = fontPx;
// ceil, а не floor: последняя строка обрезается рамкой, зато снизу не остаётся
// полоски фона — «cover» обязан закрыть кадр целиком
gh = Math.max(4, Math.ceil(h / cellH));
small.width = gw;
small.height = gh;
sctx.clearRect(0, 0, gw, gh);
// cover-вписывание считается в ПИКСЕЛЯХ ЭКРАНА, а не в клетках: клетка выше, чем
// шире, и наивное `max(gw/W, gh/H)` растягивало снимок по вертикали в 1.7 раза
const k = Math.max((gw * cellW) / img.width, (gh * cellH) / img.height);
const dw = (img.width * k) / cellW;
const dh = (img.height * k) / cellH;
sctx.drawImage(img, (gw - dw) / 2, (gh - dh) / 2, dw, dh);
const data = sctx.getImageData(0, 0, gw, gh).data;
lum = new Float32Array(gw * gh);
rgb = new Uint8ClampedArray(gw * gh * 3);
const hist = new Uint32Array(256);
for (let i = 0, p = 0; i < lum.length; i++, p += 4) {
const r = data[p]!;
const g = data[p + 1]!;
const b = data[p + 2]!;
// формула восприятия: зелёный весит втрое больше синего
const l = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
lum[i] = l;
const bin = l < 0 ? 0 : l > 1 ? 255 : (l * 255) | 0;
hist[bin] = hist[bin]! + 1;
rgb[i * 3] = r;
rgb[i * 3 + 1] = g;
rgb[i * 3 + 2] = b;
}
// Растяжка уровней по 2-му и 98-му процентилю. Без неё тёмный снимок ложится
// в три символа с начала набора, светлый — в три с конца, и в обоих случаях
// сюжет не читается: набор символов — ресурс на 10 градаций, его надо тратить
// весь. Процентили, а не min/max: одна пересвеченная точка обнуляет растяжку.
// Считается ОДИН раз здесь же, в кадре остаётся вычитание и деление.
const n = lum.length;
const edge = n * 0.02;
let acc = 0;
lo = 0;
hi = 1;
for (let b = 0; b < 256; b++) {
acc += hist[b]!;
if (acc >= edge) { lo = b / 255; break; }
}
acc = 0;
for (let b = 255; b >= 0; b--) {
acc += hist[b]!;
if (acc >= edge) { hi = b / 255; break; }
}
// почти одноцветный кадр растягивать нечего — иначе шум разъедется на весь набор
if (hi - lo < 0.08) { lo = 0; hi = 1; }
};
let dpr = 1;
const resize = () => {
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(el.clientWidth * dpr);
canvas.height = Math.round(el.clientHeight * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
sample();
};
let t = 0;
const draw = () => {
const c = cfg.current;
const w = el.clientWidth;
const h = el.clientHeight;
ctx.clearRect(0, 0, w, h);
if (!ready || !gw) return;
ctx.font = `${fontPx}px ${mono()}`;
ctx.textBaseline = "top";
const chars = c.ramp;
const lastIdx = chars.length - 1;
for (let y = 0; y < gh; y++) {
for (let x = 0; x < gw; x++) {
const i = y * gw + x;
// волна лишь СДВИГАЕТ уже посчитанную яркость — пиксели не перечитываются
const shift = c.wave ? Math.sin((x / gw) * 6.2 + t) * c.wave : 0;
const nv = (lum[i]! - lo) / (hi - lo);
let v = (nv - 0.5) * c.contrast + 0.5 + c.brightness + shift;
v = v < 0 ? 0 : v > 1 ? 1 : v;
const ch = chars[Math.round(v * lastIdx)] ?? " ";
if (ch === " ") continue;
ctx.fillStyle = c.colored
? `rgb(${rgb[i * 3]} ${rgb[i * 3 + 1]} ${rgb[i * 3 + 2]})`
: `oklch(${0.45 + v * 0.5} 0.11 ${180 + v * 140})`;
ctx.fillText(ch, x * cellW, y * cellH);
}
}
};
let raf = 0;
const loop = () => {
t += 0.02;
draw();
raf = requestAnimationFrame(loop);
};
const start = () => {
if (raf || frozen || !cfg.current.wave) return;
raf = requestAnimationFrame(loop);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
img.onload = () => {
ready = true;
resize();
draw();
start();
};
img.src = src;
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(el);
const io = new IntersectionObserver(([e]) => (e!.isIntersecting && !document.hidden ? start() : stop()));
io.observe(el);
const onVis = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVis);
return () => {
stop();
io.disconnect();
ro.disconnect();
document.removeEventListener("visibilitychange", onVis);
img.onload = null;
canvas.remove();
};
}, [src]);
return (
<div
ref={host}
className="asi"
style={{ position: "absolute", inset: 0, overflow: "hidden", background: DARK }}
/>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0