> ## Documentation Index
> Fetch the complete documentation index at: /llms.txt
> Use this file to discover all available pages before exploring further.

# ASCII Render Pass

> A slotted scene renders as live ASCII: a canvas samples the source's luminance grid (rasterized once at mount) and draws glyphs from a density ramp charset; resolution steps coarse to fine as the piece resolves, then holds legible-fine.

# ASCII Render Pass

A slotted scene renders as live ASCII: a canvas samples the source's luminance grid (rasterized once at mount) and draws glyphs from a density ramp charset; resolution steps coarse to fine as the piece resolves, then holds legible-fine.

**ASCII Render Pass**

```html ascii-render-pass.html
<!doctype html>
<!--
  ascii-render-pass: HyperFrames video primitive (Wave M experiment, texture)

  A slotted scene renders as live ASCII: the source is rasterized ONCE at
  mount to an offscreen canvas, its luminance grid is sampled per cell, and
  each cell draws a glyph from a density ramp charset. Resolution animates
  coarse to fine on a stepped pitch ladder as the piece resolves, then
  holds legible-fine. The DOM source itself never displays; the canvas is
  the whole picture.

  The scene is a SLOT. Callers supply content by placing an inert template
  anywhere in the HOST page (templates never render, and the runtime wipes
  the host clip's own children on mount):

    <template data-slot="ascii-render-pass-scene"> ... </template>

  Slot content is rasterized through an SVG foreignObject snapshot, so it
  must be self-contained: inline styles or rules carried by host <style>
  tags (which are copied into the snapshot), data-URI images only, system
  or already-active fonts. External resources do not load inside the
  snapshot. When no slot exists, the primitive rasterizes a token monogram
  tile drawn synchronously from contract tokens (no snapshot involved).

  Variables (declared in data-composition-variables below):
    - charset (string): density ramp, dark to light, first glyph may be a
      space so dark cells stay empty. Default " .:-=+*#%@".
    - grid ("coarse" | "med" | "fine", default "med"): final cell pitch.
    - resolve_at (seconds): when the coarse-to-fine resolve begins.
    - accent ("green" | "blue" | "violet", default "green"): glyph ink;
      green rides --brand, blue rides --accent, violet rides --accent-2.
    - exit ("none" | "fade" | "up", default "none"): frame roots own
      transitions; the default holds the fine render to the cut.

  Envelope, fixed segments with elastic HOLD only (never timeScale):
    0.00s        canvas fades up over 0.5s at the coarsest pitch
    resolve_at   pitch ladder steps coarse to fine over 1.2s (5 steps,
                 powers of sqrt(2)); a per-cell hash shimmer rides the
                 steps and lands at zero on the final step
    then         legible-fine HOLD, truly still
    OUT = 0.45s  only when exit is not none

  Determinism (canvas 2D law): the raster, its luminance summed-area
  table, and every resolved color are fixed once at mount. Every painted
  frame is a pure function of (that raster, the timeline's current time):
  the timeline's onUpdate clears the canvas and redraws every glyph from
  scratch. Cell shimmer uses a stateless integer hash of (column, row,
  ladder step), not a stream; there is no Math.random, no wall clock, no
  incremental state. Eventful seeks (suppressEvents false, the engine's
  render path) land identical frames in any order and either direction.
  The no-slot monogram raster is drawn synchronously before the timeline
  registers; a slotted raster arrives from the snapshot decode and
  repaints the current frame, after which all frames are pure f(t).
-->
<html
  lang="en"
  data-composition-id="ascii-render-pass"
  data-composition-duration="4"
  data-composition-variables='[
    { "id": "charset", "type": "string", "role": "style", "label": "Charset", "description": "Density ramp, dark to light. A leading space keeps dark cells empty.", "default": " .:-=+*#%@" },
    { "id": "grid", "type": "enum", "role": "style", "label": "Grid", "description": "Final cell pitch the resolve lands on.", "default": "med", "options": [{ "value": "coarse", "label": "Coarse" }, { "value": "med", "label": "Medium" }, { "value": "fine", "label": "Fine" }] },
    { "id": "resolve_at", "type": "number", "role": "timing", "label": "Resolve at", "description": "Seconds from mount start when the coarse-to-fine resolve begins.", "default": 1.4, "min": 0.6, "max": 10, "step": 0.1, "unit": "s" },
    { "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Glyph ink: green rides --brand, blue rides --accent, violet rides --accent-2.", "default": "green", "options": [{ "value": "green", "label": "Green" }, { "value": "blue", "label": "Blue" }, { "value": "violet", "label": "Violet" }] },
    { "id": "exit", "type": "enum", "role": "timing", "label": "Exit", "description": "Optional departure. Default none: the fine render holds until the frame cuts.", "default": "none", "options": [{ "value": "none", "label": "None" }, { "value": "fade", "label": "Fade" }, { "value": "up", "label": "Up" }] }
  ]'
>
  <head>
    <meta charset="UTF-8" />
    <title>ASCII Render Pass</title>
  </head>
  <body>
    <template>
      <div id="root" data-composition-id="ascii-render-pass" data-duration="4" data-fps="30">
        <style>
          *,
          *::before,
          *::after {
            box-sizing: border-box;
          }

          #root {
            position: absolute;
            inset: 0;
            overflow: hidden;
            container-type: size;
            isolation: isolate;
            color: var(--fg, #f8fafc);
            font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, Consolas, monospace);
            pointer-events: none;
          }

          .arp-clip {
            position: absolute;
            inset: 0;
            overflow: hidden;
            background: var(--bg, transparent);
          }

          .arp-stage {
            position: absolute;
            inset: 0;
            will-change: transform, opacity;
          }

          .arp-canvas {
            position: absolute;
            inset: 0;
            width: 100%;
            height: 100%;
          }
        </style>

        <div
          id="ascii-render-pass-clip"
          class="arp-clip clip"
          data-start="0"
          data-duration="4"
          data-track-index="0"
        >
          <div class="arp-stage">
            <canvas class="arp-canvas" aria-hidden="true"></canvas>
          </div>
        </div>

        <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
        <script>
          (function () {
            "use strict";

            var root = document.getElementById("root");
            var stage = root.querySelector(".arp-stage");
            var canvas = root.querySelector(".arp-canvas");
            var vars =
              window.__hyperframes && window.__hyperframes.getVariables
                ? window.__hyperframes.getVariables()
                : {};

            function num(value, fallback, min, max) {
              var n = Number(value);
              if (!isFinite(n)) n = fallback;
              return Math.max(min, Math.min(max, n));
            }

            var charset =
              typeof vars.charset === "string" && vars.charset.length >= 2
                ? vars.charset
                : " .:-=+*#%@";
            var grid = vars.grid === "coarse" || vars.grid === "fine" ? vars.grid : "med";
            var exit = vars.exit === "fade" || vars.exit === "up" ? vars.exit : "none";
            // Each enum choice routes to a DIFFERENT contract token so the
            // variable stays meaningful under a theme.
            var accentColors = {
              green: "var(--brand, #71f5a7)",
              blue: "var(--accent, #61a8ff)",
              violet: "var(--accent-2, #c5a3ff)",
            };
            var accent = Object.prototype.hasOwnProperty.call(accentColors, vars.accent)
              ? vars.accent
              : "green";
            // Bundler mirrors composition variables as scoped CSS custom
            // props; when this unit's accent variable shadows the --accent
            // token ("blue" is a valid CSS color), fall back to the literal.
            var computedAccent = getComputedStyle(root).getPropertyValue("--accent").trim();
            if (
              computedAccent === "green" ||
              computedAccent === "blue" ||
              computedAccent === "violet"
            ) {
              accentColors.blue = "#61a8ff";
            }

            // Envelope.
            var duration = Math.max(0.001, parseFloat(root.dataset.duration || "4"));
            var OUT = exit === "none" ? 0 : 0.45;
            var RESOLVE = Math.min(1.2, Math.max(0.5, duration - OUT - 1.0));
            var FADE_IN = 0.5;
            var maxAt = Math.max(0.6, duration - RESOLVE - OUT - 0.05);
            var resolveAt = num(vars.resolve_at, 1.4, 0.6, maxAt);
            var OUT_START = duration - OUT;

            // Canvas raster basis, fixed once at mount. Elastic root: the
            // host box decides the resolution.
            var dpr = Math.min(2, Math.max(1, Number(window.devicePixelRatio) || 1));
            var box = root.getBoundingClientRect();
            var cssW = Math.max(1, Math.round(box.width) || 640);
            var cssH = Math.max(1, Math.round(box.height) || 360);
            canvas.width = Math.round(cssW * dpr);
            canvas.height = Math.round(cssH * dpr);
            var ctx = canvas.getContext("2d");

            // Resolve tokens to concrete values once at mount so nothing in
            // the paint path depends on style recalc during seeks.
            var hostDoc = root.ownerDocument;
            function resolveColor(cssColor, fallback) {
              var probe = hostDoc.createElement("span");
              probe.style.color = cssColor;
              root.appendChild(probe);
              var out = getComputedStyle(probe).color || fallback;
              probe.remove();
              return out;
            }
            var bgColor = resolveColor("var(--bg, #0b0c0e)", "#0b0c0e");
            var inkColor = resolveColor(accentColors[accent], "#71f5a7");
            var probeFont = hostDoc.createElement("span");
            probeFont.style.fontFamily =
              'var(--font-mono, ui-monospace, "SF Mono", Menlo, Consolas, monospace)';
            root.appendChild(probeFont);
            var monoFamily = getComputedStyle(probeFont).fontFamily || "monospace";
            probeFont.remove();

            // Source raster: cssW x cssH capped to 1280 wide, composited
            // over the resolved bg so luminance never depends on alpha.
            var rasterScale = Math.min(1, 1280 / cssW);
            var rw = Math.max(1, Math.round(cssW * rasterScale));
            var rh = Math.max(1, Math.round(cssH * rasterScale));
            var raster = hostDoc.createElement("canvas");
            raster.width = rw;
            raster.height = rh;
            var rctx = raster.getContext("2d");

            // Luminance summed-area table so any cell's average is O(1) and
            // a pure function of the fixed raster.
            var sat = null; // Float64Array (rw+1)*(rh+1)
            function buildSat() {
              var data = rctx.getImageData(0, 0, rw, rh).data;
              var table = new Float64Array((rw + 1) * (rh + 1));
              for (var y = 0; y < rh; y += 1) {
                var rowSum = 0;
                for (var x = 0; x < rw; x += 1) {
                  var i = (y * rw + x) * 4;
                  rowSum += (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255;
                  table[(y + 1) * (rw + 1) + (x + 1)] = table[y * (rw + 1) + (x + 1)] + rowSum;
                }
              }
              sat = table;
            }
            function cellLuma(x0, y0, x1, y1) {
              // Raster-space integer rect, clamped.
              if (x1 <= x0 || y1 <= y0) return 0;
              var s = rw + 1;
              var sum = sat[y1 * s + x1] - sat[y0 * s + x1] - sat[y1 * s + x0] + sat[y0 * s + x0];
              return sum / ((x1 - x0) * (y1 - y0));
            }

            function paintMonogram(target, w, h) {
              // Synchronous token monogram raster: a surface tile with one
              // display glyph, same vocabulary as the slot-less siblings.
              var m = Math.min(w, h);
              target.fillStyle = bgColor;
              target.fillRect(0, 0, w, h);
              var tile = m * 0.52;
              var x = (w - tile) / 2;
              var y = (h - tile) / 2;
              var r = m * 0.05;
              target.fillStyle = resolveColor(
                "color-mix(in srgb, var(--surface, #14171c) 72%, " + accentColors[accent] + ")",
                "#1a2a22",
              );
              target.beginPath();
              if (target.roundRect) target.roundRect(x, y, tile, tile, r);
              else target.rect(x, y, tile, tile);
              target.fill();
              target.fillStyle = resolveColor(
                "color-mix(in srgb, var(--fg, #f8fafc) 30%, " + accentColors[accent] + ")",
                "#a8f5c8",
              );
              target.font = "600 " + Math.round(m * 0.3) + "px " + monoFamily;
              target.textAlign = "center";
              target.textBaseline = "middle";
              target.fillText("H", w / 2, h / 2 + m * 0.012);
            }

            // SLOT. Rasterized through an SVG foreignObject snapshot of the
            // slot markup plus every host <style> tag and the resolved
            // contract tokens, so host-authored rules and theme values
            // apply inside the snapshot.
            var slotTemplate = null;
            try {
              slotTemplate = hostDoc.querySelector('template[data-slot="ascii-render-pass-scene"]');
            } catch (error) {
              slotTemplate = null;
            }

            var rasterReady = false;
            function finishRaster() {
              buildSat();
              rasterReady = true;
              root.setAttribute("data-arp-raster", "ready");
              paint(tl ? tl.time() : 0);
            }

            if (slotTemplate) {
              var tokenNames = [
                "--bg",
                "--fg",
                "--muted",
                "--surface",
                "--border",
                "--brand",
                "--accent",
                "--accent-2",
                "--font-display",
                "--font-body",
                "--font-mono",
                "--radius",
                "--space-1",
                "--space-2",
                "--space-3",
                "--dur-beat",
                "--ease-standard",
                "--ease-emphasis",
              ];
              var rootStyle = getComputedStyle(root);
              var tokenDecl = "";
              for (var ti = 0; ti < tokenNames.length; ti += 1) {
                var tv = rootStyle.getPropertyValue(tokenNames[ti]).trim();
                if (tv) tokenDecl += tokenNames[ti] + ":" + tv + ";";
              }
              var styleTexts = "";
              var styleTags = hostDoc.querySelectorAll("style");
              for (var si = 0; si < styleTags.length; si += 1) {
                styleTexts += styleTags[si].textContent || "";
              }
              var wrapper = hostDoc.createElement("div");
              wrapper.setAttribute(
                "style",
                "width:" +
                  rw +
                  "px;height:" +
                  rh +
                  "px;overflow:hidden;position:relative;" +
                  "display:grid;place-items:center;background:" +
                  bgColor +
                  ";" +
                  tokenDecl,
              );
              wrapper.appendChild(hostDoc.importNode(slotTemplate.content, true));
              var styleEl = hostDoc.createElement("style");
              styleEl.textContent = styleTexts;
              wrapper.insertBefore(styleEl, wrapper.firstChild);
              var serialized = new XMLSerializer().serializeToString(wrapper);
              var svgMarkup =
                // No xmlns on foreignObject: a default xmlns declaration
                // applies to the declaring element itself and would eject
                // foreignObject from the SVG namespace (renders blank). The
                // serialized wrapper div already carries the XHTML namespace.
                '<svg xmlns="http://www.w3.org/2000/svg" width="' +
                rw +
                '" height="' +
                rh +
                '"><foreignObject width="' +
                rw +
                '" height="' +
                rh +
                '">' +
                serialized +
                "</foreignObject></svg>";
              var snapshot = new Image();
              snapshot.onload = function () {
                rctx.fillStyle = bgColor;
                rctx.fillRect(0, 0, rw, rh);
                rctx.drawImage(snapshot, 0, 0, rw, rh);
                try {
                  finishRaster();
                } catch (error) {
                  // Tainted or unreadable snapshot: fall back to the monogram.
                  paintMonogram(rctx, rw, rh);
                  finishRaster();
                }
              };
              snapshot.onerror = function () {
                paintMonogram(rctx, rw, rh);
                finishRaster();
              };
              snapshot.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svgMarkup);
            } else {
              paintMonogram(rctx, rw, rh);
            }

            // Final cell pitch (display px) per grid enum; the ladder steps
            // down from 4x on powers of sqrt(2). Stepped, not continuous:
            // each step is a stable re-sample, the digital resolve.
            var rowsFinal = { coarse: 22, med: 36, fine: 54 };
            var cellFinal = Math.max(4, cssH / rowsFinal[grid]);
            var LADDER = [4, 2.83, 2, 1.41, 1];
            var LAST = LADDER.length - 1;

            function clamp01(v) {
              return v < 0 ? 0 : v > 1 ? 1 : v;
            }
            // Stateless integer hash for per-cell shimmer: pure function of
            // (col, row, step), no stream, no state.
            function hash01(cx, cy, step) {
              var h =
                Math.imul(cx + 1, 0x9e3779b1) ^
                Math.imul(cy + 1, 0x85ebca6b) ^
                Math.imul(step + 1, 0xc2b2ae35);
              h = Math.imul(h ^ (h >>> 15), 0x27d4eb2f);
              h = (h ^ (h >>> 13)) >>> 0;
              return h / 4294967296;
            }

            var glyphs = charset.split("");
            var GN = glyphs.length;

            // Pure repaint from (raster tables, t).
            function paint(timeSeconds) {
              ctx.fillStyle = bgColor;
              ctx.fillRect(0, 0, canvas.width, canvas.height);
              if (!rasterReady) return;
              var t = timeSeconds;
              var fade = clamp01(t / FADE_IN);
              fade = 1 - (1 - fade) * (1 - fade); // power2.out
              if (fade <= 0) return;
              var rp = clamp01((t - resolveAt) / RESOLVE);
              rp = rp * rp * (3 - 2 * rp); // smoothstep across the ladder
              var step = Math.min(LAST, Math.floor(rp * LAST + 1e-6));
              if (rp >= 1) step = LAST;
              var cell = cellFinal * LADDER[step];
              var cols = Math.ceil(cssW / cell);
              var rows = Math.ceil(cssH / cell);
              var cellDev = cell * dpr;
              ctx.font = "600 " + cellDev * 0.92 + "px " + monoFamily;
              ctx.textAlign = "center";
              ctx.textBaseline = "middle";
              ctx.fillStyle = inkColor;
              // Shimmer amplitude decays to exactly zero on the final step
              // so the HOLD is truly still.
              var shimmerAmp = step === LAST && rp >= 1 ? 0 : 0.16;
              for (var cy = 0; cy < rows; cy += 1) {
                for (var cx = 0; cx < cols; cx += 1) {
                  var x0 = Math.round(cx * cell * rasterScale);
                  var y0 = Math.round(cy * cell * rasterScale);
                  var x1 = Math.min(rw, Math.round((cx + 1) * cell * rasterScale));
                  var y1 = Math.min(rh, Math.round((cy + 1) * cell * rasterScale));
                  var luma = cellLuma(x0, Math.min(y0, rh), x1, Math.min(y1, rh));
                  var idx = Math.round(luma * (GN - 1));
                  if (idx <= 0 && glyphs[0] === " ") continue;
                  var alpha = (0.3 + 0.7 * luma) * fade;
                  if (shimmerAmp > 0) {
                    alpha *= 1 - shimmerAmp + shimmerAmp * hash01(cx, cy, step);
                  }
                  if (alpha <= 0.01) continue;
                  ctx.globalAlpha = clamp01(alpha);
                  ctx.fillText(glyphs[idx], (cx + 0.5) * cellDev, (cy + 0.5) * cellDev);
                }
              }
              ctx.globalAlpha = 1;
            }

            gsap.set(stage, { opacity: 1, y: "0cqh" });

            var tl = gsap.timeline({
              paused: true,
              onUpdate: function () {
                paint(tl.time());
              },
            });

            // Anchor tween: spans the full authored duration so onUpdate
            // (the canvas repaint) fires for every eventful seek in [0, D]
            // and holds keep tl.time() honest.
            tl.to({ t: 0 }, { t: 1, duration: duration, ease: "none" }, 0);

            // HOLD: legible-fine, truly still (shimmer amplitude is zero
            // past the final ladder step; the raster never changes).

            // OUT: optional departure; exit none holds until the frame cuts.
            if (exit === "up") {
              tl.to(stage, { y: "-4cqh", duration: OUT, ease: "power2.in" }, OUT_START);
              tl.to(stage, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_START);
            } else if (exit === "fade") {
              tl.to(stage, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_START);
            }

            tl.seek(0);
            paint(0);
            window.__timelines = window.__timelines || {};
            window.__timelines["ascii-render-pass"] = tl;
          })();
        </script>
      </div>
    </template>
  </body>
</html>
```

Copy npx hyperframes add ascii-render-pass to the clipboard

That writes one file: `compositions/components/ascii-render-pass.html`.

## Paste it into your composition

Open `compositions/components/ascii-render-pass.html` and copy what is inside into your own composition.

A component has no size or duration of its own. It takes both from the composition
you paste it into.

## Variables

Every one of these has a default, so the piece works untouched. Set the ones you
want to change on the element:

| Variable | Default | Accepts | What it does |
| - | - | - | - |
| `charset` | ` .:-=+*#%@` | string | Density ramp, dark to light. A leading space keeps dark cells empty. |
| `grid` | `med` | `coarse`, `med`, `fine` | Final cell pitch the resolve lands on. |
| `resolve_at` | `1.4` | 0.6s to 10s, step 0.1s | Seconds from mount start when the coarse-to-fine resolve begins. |
| `accent` | `green` | `green`, `blue`, `violet` | Glyph ink: green rides --brand, blue rides --accent, violet rides --accent-2. |
| `exit` | `none` | `none`, `fade`, `up` | Optional departure. Default none: the fine render holds until the frame cuts. |

Set them with `data-variable-values` on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:

```html wrap
<div
  data-composition-id="ascii-render-pass"
  data-composition-src="compositions/components/ascii-render-pass.html"
  data-variable-values='{"charset":" .:-=+*#%@","grid":"med","resolve_at":1.4,"accent":"green","exit":"none"}'
></div>
```

{/* hf:generated-footer */}

Tagged `motion-primitive` `experiment` `texture` `ascii` `quantize`.

## Related topics

* [Browse the complete Catalog](/catalog.md)
* [Add assets and Catalog items in the browser editor](/studio/assets-and-blocks.md)
* [Put blocks together into one video](/concepts/compositions.md)
