Motiva
Мои коллекции
← каталог

ASCII Image

Фотография, пересобранная из ASCII-символов. Пиксели читаются ОДИН раз при загрузке, уменьшение делает сам drawImage, а яркость считается по формуле восприятия — не как среднее каналов.

cssautorevealadvanced~3 КБreduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
cols?number110Символов по ширине (формат «110»).
contrast?number1.35Контраст перед раскладкой по символам (формат «1.35»).
brightness?number0Сдвиг яркости, −0.5…0.5 (формат «0»).
colored?booleanfalseКрасить символы цветом исходного пикселя.
ramp?stringRAMPНабор символов от тёмного к светлому.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/ascii-image.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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