Files
hyperframes/docs/catalog/components/ascii-render-pass.mdx
miga-heygenandMiguel Ángel 7b5fdb1ad2 fix(docs): emit catalog source accordion titles as plain strings (#4006)
The generated catalog pages rendered the "Source" accordion title as a
JSX expression, `<Accordion title={`name.html`}>`. Mintlify's search
indexer stringifies expression attribute nodes rather than evaluating
them, so every one of the 399 catalog pages surfaced its Source section
in search results as "[object Object]" (visible on the Scroll Feed page,
among others).

The template literal carried no escaping benefit: every title is a
single-segment `name.html` path, and a double-quoted JSX string handles
it directly. Introduce `mdxStringAttribute`, which emits a plain
`name="value"` attribute and escapes `& " < { }` as HTML character
references (MDX decodes references inside quoted attribute values, and
unlike JSON.stringify it does not rely on backslash escapes, which JSX
strings do not honour). Use it for both the Source accordion title and
the preview iframe title, and regenerate the catalog pages.

Regenerated with `npx tsx scripts/generate-catalog-pages.ts`; the only
change across the 399 pages is the one Accordion title line each.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 11:55:00 -04:00

1046 lines
46 KiB
Plaintext

---
title: "ASCII Render Pass"
description: "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."
---
import { InstallCommand } from "/snippets/install-command.jsx";
import { VariablesExplorer } from "/snippets/variables-explorer.jsx";
## Install
<InstallCommand command="npx hyperframes add ascii-render-pass" item="ascii-render-pass" />
That writes one file: `compositions/components/ascii-render-pass.html`.
<VariablesExplorer
previewSrc="/public/catalog/components/ascii-render-pass.json"
compositionId="ascii-render-pass"
compositionSrc="compositions/components/ascii-render-pass.html"
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"}]}]}
>
```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>
```
</VariablesExplorer>
## 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>
```
## Source
<Accordion title="ascii-render-pass.html">
```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>
```
</Accordion>
{/* hf:generated-footer */}
Tagged `motion-primitive` `experiment` `texture` `ascii` `quantize`.
## Related topics
- [Browse the complete Catalog](/catalog)
- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
- [Build a richer composition](/go-further)