Files
hyperframes/docs/catalog/components/ink-bleed-reveal.mdx
T
Miguel ÁngelandMiguel Angel Simon Sierra 1ae2067b8d feat(catalog): put the variables panel back, on payloads (#3199)
* feat(catalog): put the variables panel back, on payloads

The panel drove its preview by loading an .html from docs/public, a type the
host does not publish, so it showed an empty frame in production and was
parked when the catalog was re-landed.

It now mounts the same JSON payload the plain player uses and re-mounts it as
values change, injecting them as window.__hfVariables into the composition head
before any of its scripts run, which is where the runtime reads overrides from.
Doing it in the markup rather than after load is what stops the composition
initialising with the wrong values first.

172 items with variables get the panel back; the playhead carries across a
change so a tweak mid-shot does not jump back to frame zero.

* fix(docs): drop the unused url form and the needless escapes

* fix(docs): the panel cannot reference a binding beside the export

* feat(catalog): make importing an SVG the obvious move

A reader arrives at this control with a shape, not with path data, and the
panel asked for the coordinates first. Import is now the primary action in a
drop target you can see is a drop target, and the raw path sits behind a
disclosure for anyone who wants it.

* feat(cli): let a fruitless catalog search report the gap

An agent that searches by meaning and finds nothing worth installing knows
something we do not: the name of a move the catalog is missing. There was no
way to tell us, so that knowledge was lost at the end of every run.

hyperframes feedback --search-miss "<query>" --wanted "<the move>" records it.
It carries no rating, so it never lands in the rating metric, and it is a
separate deliberate command rather than something catalog --query does on its
own: plain search still sends nothing, which is what the CLI promises.

--rating stops being required at the arg level, since a miss has no rating to
give. The check moved into the run body, where an absent one is now handled
rather than crashing on undefined.

* feat(cli): carry tuned variable values into the install snippet

Someone who tunes a block on its catalog page had no way to keep those values:
the install command was the same one everybody gets, and the tuning stayed on
the page.

hyperframes add <item> --vars '<json>' now prints a mount element carrying
data-variable-values, so the values land where the block is used.

They ride on the host rather than being written into the installed file. That
keeps the composition on disk byte-identical to the registry's, so a later
reinstall can still tell an edit from an update, and it lets two mounts of the
same block carry different values.

* fix(catalog): serve the item's own directory so runtime paths resolve

Some compositions assemble their asset URLs at run time —
"compositions/components/" + texture + ".png" for the texture masks, a font the
compiler pulled into _remote_media — and no scan of the markup can see a string
that does not exist until a script concatenates it. Those items either rendered
black or were dropped to a video that had never been uploaded.

Each item that needs it now has its prepared directory published, and its
payload carries a <base> pointing at it, so any relative path the composition
invents resolves. caption-texture renders its masks again, and
variable-font-flex has a preview at all for the first time: its MP4 and poster
are both 403.

Both layouts are published, because which one a composition asks for differs
per item, and a directory only earns that if it is under 2 MB. The 12 MB
texture sheet keeps the recorded video it already had.

* fix(catalog): let the variables panel actually drive the composition

Every control on the panel was inert. The values reached the composition and
nothing repainted, because the payload had already been compiled: compiling
inlines a mounted component and resolves its variables into the markup and CSS,
so by the time a reader turns a knob there is nothing left to change.

An item that declares variables now ships uncompiled, keeping the mount the
runtime loads at run time, which is the only state where data-variable-values
still means anything. The component travels inline as a data URI rather than a
sibling file, because .html is the one type the docs host will not publish. The
demo's own pinned values come off, so the reader's choices reach the mount
instead of losing to the values the demo picked to show itself off.

Measured on the rendered frame rather than the DOM: green rgb(98,207,144),
blue rgb(6,6,199), violet rgb(177,147,230), and back to green.

docs/public/catalog drops from 48 MB to 35 MB along the way, since an
uncompiled payload carries far less than an inlined one.

* feat(catalog): keep variable changes in the url

A reader who tuned a piece lost it on reload, and had nothing to send anyone.
The values now live in the query string, scoped by composition id so two links
never read each other,and only the ones that differ from the defaults are
written, so changing one knob gives a short URL rather than every variable
spelled out.

replaceState rather than pushState: dragging a slider should not leave a trail
of history entries. An unreadable value is ignored rather than thrown, so a
truncated or hand-edited link opens the piece at its defaults.

* fix(catalog): only rewrite the url when a value actually changed

* refactor(catalog): memoise the declared defaults on their content

* feat(catalog): offer an install command carrying the tuned values

The Install block is generated before anyone touches a knob, so it can only
ever print the plain command. Someone who spent a minute tuning a piece copied
it and got the defaults back.

The panel now carries its own command in the Snippet tab, with --vars holding
exactly the values that differ. An untouched piece still offers the same short
command, so nothing gets noisier for the common case.

* fix(catalog): a piece with nothing to render is a skip, not a failure

caption-blend-difference is a stylesheet and a paragraph of prose — a class you
add to your own captions, with no standalone scene to show. The generator
treated that as a build failure, so every run ended by reporting something
broken when nothing was.

It now reports the shape it is and keeps its recorded video, which is the only
honest preview such an item has. A genuine render failure still throws.

* fix(catalog): restore variables from the url on a cold load

A shared link opened at the defaults. The first render happens on the server,
where there is no window to read the query string from, and React then hydrates
against that markup and never revisits it — so the values only appeared once you
touched a control.

The URL is read again after mount, which is the first moment it exists. The
value is also escaped once now rather than twice: URLSearchParams already
decodes on the way out, and decoding a second time turned an SVG path full of
percent-escapes into something that no longer parsed, besides doubling the
length of every link.

* fix(catalog): mount the preview with the values a link carried

The frame was built from the declared defaults and the shared values were
posted to it afterwards, which is too late for anything the composition reads
once at init: a path arrived after the mark had already been drawn from the
default one, so a link looked right in the panel and wrong on screen.

* feat(catalog): the install command follows the values you tuned

Copying the Install line gave the plain command back, because that block is
generated before anyone touches a knob and had no way to know what changed. The
tuned command only existed in the panel Snippet tab, which is not where anyone
looks for it.

The line now reads the same query string the panel writes, so the two agree
without either component knowing the other exists, and a shared link carries the
right command too. replaceState fires no event, so the panel announces its own
writes.

* fix(catalog): send a text variable to the preview once it is finished

Every other control in the explorer reports a whole value on every event: a
slider at any position is a position, a swatch is a colour. A text field is
not. Typing v3 into a badge posted v first, so the preview remounted and
rendered a composition built from half a word.

The post now waits while a text field has focus and goes out when the edit is
committed, with Enter or by clicking away. The field itself is unchanged and
still tracks every keystroke.

---------

Co-authored-by: Miguel Angel Simon Sierra <miguelangelsi07@gmail.com>
2026-08-10 22:40:01 -04:00

968 lines
42 KiB
Plaintext

---
title: "Ink Bleed Reveal"
description: "Liquid ink blooms through paper under a gooey blur-plus-threshold filter, merges, then contracts as a crisp slotted mark resolves beneath it, with static seeded paper grain riding the result."
---
import { InstallCommand } from "/snippets/install-command.jsx";
import { VariablesExplorer } from "/snippets/variables-explorer.jsx";
<VariablesExplorer
previewSrc="/public/catalog/components/ink-bleed-reveal.json"
compositionId="ink-bleed-reveal"
compositionSrc="compositions/components/ink-bleed-reveal.html"
variables={[{"id":"blobs","type":"number","role":"style","label":"Blobs","description":"How many ink blobs bleed and merge (4 to 6).","default":5,"min":4,"max":6,"step":1},{"id":"accent","type":"enum","role":"style","label":"Accent","description":"Ink tint: 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 revealed mark holds until the frame cuts.","default":"none","options":[{"value":"none","label":"None"},{"value":"fade","label":"Fade"},{"value":"up","label":"Up"}]}]}
>
```html ink-bleed-reveal.html
<!doctype html>
<!--
ink-bleed-reveal: HyperFrames video primitive (texture / reveals). Wave M7.
Liquid ink blooms through paper to reveal a mark. Several ink blobs bleed
outward from the center and merge like liquid under the gooey chain
(Gaussian blur + a hard alpha threshold: the blur softens the silhouettes
together, the threshold snaps the merged alpha back to a crisp liquid
edge), then the puddle contracts and dies as the crisp mark resolves
beneath it. The chain is the classic SVG feGaussianBlur + feColorMatrix
recipe, but computed in a canvas work buffer each repaint: Chrome's live
filter raster patches damaged tiles with seek-history-dependent seam
antialiasing, while a cleared, fully redrawn canvas is a pure function of
(table, time) and lands byte-identical frames. A seeded, static
paper-grain overlay (feTurbulence, fixed seed, never animated) rides the
whole piece.
The mark 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, so the slot lives at document level):
<template data-slot="ink-bleed-reveal-mark"> ... </template>
With no slot, the primitive renders a token monogram: one display glyph in
ink, drawn entirely from contract tokens. Light paper is the home register,
but every color rides a token so themes restate it.
Variables (declared in data-composition-variables below):
- blobs (number 4-6, default 5): how many ink blobs bleed and merge.
- accent ("green" | "blue" | "violet", default "green"): ink tint; green
rides --brand, blue rides --accent, violet rides --accent-2. The ink
body is the accent mixed toward --fg so it reads as pigment, not paint.
- exit ("none" | "fade" | "up", default "none"): frame roots own
transitions; the default holds the revealed mark to the last pixel.
Envelope, fixed IN and OUT with elastic HOLD only (never timeScale):
IN_BASE = 2.80s ink blooms, merges, contracts; mark resolves crisp
HOLD = max(0, D - IN - OUT); truly still (grain is static by
construction: painted by a fixed-seed feTurbulence, never
animated, so held frames are byte-identical)
OUT_BASE = 0.45s only when exit != none
If D < IN_BASE + OUT_BASE, IN and OUT compress together.
Determinism (closed-form field law): there is NO physics state. One
per-blob parameter table is computed exactly once during synchronous
timeline construction from fixed LCG seed 0x1b1eedca; every painted frame
positions each blob as a pure function of (table row, timeline time) inside
the anchor tween's onUpdate. Zero per-blob tweens. No Math.random, no wall
clock, no requestAnimationFrame. Eventful seeks (suppressEvents=false, the
engine's render path) land identical frames in any order and either
direction; by the end of IN every blob's scale is exactly zero and the goo
layer's opacity is zero, so the HOLD is the crisp mark on still paper.
Mount contract: the runtime clones only this template. #root fills the
host box (no data-width/data-height, container-type: size, cqmin units),
is styled via #root only, and registers one paused timeline under the
LITERAL "ink-bleed-reveal" key. All DOM state is set with explicit
endpoints (gsap.set + fromTo) so any seek order lands identical frames.
-->
<html
lang="en"
data-composition-id="ink-bleed-reveal"
data-composition-duration="4"
data-composition-variables='[
{ "id": "blobs", "type": "number", "role": "style", "label": "Blobs", "description": "How many ink blobs bleed and merge (4 to 6).", "default": 5, "min": 4, "max": 6, "step": 1 },
{ "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Ink tint: 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 revealed mark 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>Ink Bleed Reveal</title>
</head>
<body>
<template>
<div id="root" data-composition-id="ink-bleed-reveal" 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, #1d1b16);
font-family: var(--font-display, "Inter", system-ui, sans-serif);
pointer-events: none;
}
.ibr-clip {
position: absolute;
inset: 0;
overflow: hidden;
background: var(--bg, transparent);
}
.ibr-stage {
position: absolute;
inset: 0;
will-change: transform, opacity;
}
/* The gooey layer is a canvas: blur + alpha threshold are computed
in-buffer each repaint (ctx.filter blur, then the colormatrix
threshold applied to the readback). Chrome's live SVG/CSS filter
raster patches damaged tiles with seek-history-dependent seam
antialiasing, which breaks byte-identical frames; a cleared and
fully redrawn canvas is a pure function of (table, time). */
.ibr-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.ibr-mark {
position: absolute;
inset: 0;
display: grid;
place-items: center;
will-change: transform, opacity;
}
.ibr-monogram {
display: grid;
place-items: center;
width: 44cqmin;
height: 44cqmin;
border: 0.5cqmin solid color-mix(in srgb, var(--ibr-accent) 58%, var(--fg, #1d1b16));
border-radius: 50%;
}
.ibr-monogram-glyph {
color: color-mix(in srgb, var(--ibr-accent) 55%, var(--fg, #1d1b16));
font-size: 22cqmin;
font-weight: 600;
line-height: 1;
letter-spacing: 0.02em;
}
/* Seeded static paper grain: feTurbulence with a FIXED seed paints
this layer once per raster; nothing about it is animated, so it
adds tooth without touching determinism. */
.ibr-grain {
position: absolute;
inset: 0;
filter: url(#ibr-grain-filter);
opacity: 0.5;
mix-blend-mode: multiply;
}
</style>
<div
id="ink-bleed-reveal-clip"
class="ibr-clip clip"
data-start="0"
data-duration="4"
data-track-index="0"
>
<svg aria-hidden="true" width="0" height="0" style="position: absolute">
<defs>
<filter
id="ibr-grain-filter"
x="0%"
y="0%"
width="100%"
height="100%"
color-interpolation-filters="sRGB"
>
<feTurbulence
type="fractalNoise"
baseFrequency="0.82"
numOctaves="2"
seed="11"
stitchTiles="stitch"
result="noise"
/>
<feColorMatrix
in="noise"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.14 0.14 0.14 0 0"
/>
</filter>
</defs>
</svg>
<div class="ibr-stage">
<div class="ibr-mark">
<div class="ibr-monogram">
<span class="ibr-monogram-glyph" aria-hidden="true">H</span>
</div>
</div>
<canvas class="ibr-canvas" aria-hidden="true"></canvas>
<div class="ibr-grain"></div>
</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(".ibr-stage");
var canvas = root.querySelector(".ibr-canvas");
var mark = root.querySelector(".ibr-mark");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
var blobCount = Math.round(Number(vars.blobs));
if (!Number.isFinite(blobCount)) blobCount = 5;
blobCount = Math.min(6, Math.max(4, blobCount));
// Each enum choice routes to a DIFFERENT contract token so the
// variable stays meaningful under a theme.
var accentColors = {
green: "var(--brand, #14a35e)",
blue: "var(--accent, #2f6fed)",
violet: "var(--accent-2, #7a5cd6)",
};
var accent = Object.prototype.hasOwnProperty.call(accentColors, vars.accent)
? vars.accent
: "green";
var exit = vars.exit === "fade" || vars.exit === "up" ? vars.exit : "none";
root.style.setProperty("--ibr-accent", accentColors[accent]);
// SLOT. Caller templates live at HOST DOCUMENT level (the mount
// wipes host-clip children, and templates never render). The
// scoped document proxy filters queries to this composition's
// subtree, so the lookup deliberately goes through ownerDocument.
var hostDoc = root.ownerDocument;
var slotTemplate = null;
try {
slotTemplate = hostDoc.querySelector('template[data-slot="ink-bleed-reveal-mark"]');
} catch (error) {
slotTemplate = null;
}
if (slotTemplate) {
mark.querySelector(".ibr-monogram").remove();
mark.appendChild(hostDoc.importNode(slotTemplate.content, true));
}
// Envelope: fixed IN/OUT, elastic HOLD, never time-scaled.
var IN_BASE = 2.8;
var OUT_BASE = exit === "none" ? 0 : 0.45;
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "4"));
var totalBase = Math.max(0.001, IN_BASE + OUT_BASE);
var scale = duration < totalBase ? duration / totalBase : 1;
var IN = IN_BASE * scale;
var OUT = OUT_BASE * scale;
var HOLD = Math.max(0, duration - (IN + OUT));
var OUT_START = IN + HOLD;
// Geometry basis is fixed once at mount from the host box, like
// the particle-image-reveal canvas raster: the same host always
// yields the same pixel coordinates and blur radius. The display
// canvas rides the host raster; the goo itself is computed in a
// low-resolution work buffer (the blur radius dwarfs the lost
// detail, and the upscale softens the ink edge pleasantly).
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);
var minDim = Math.min(cssW, cssH);
var cx = cssW / 2;
var cy = cssH / 2;
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
var ctx = canvas.getContext("2d");
var LOW = 3;
var work = hostDoc.createElement("canvas");
work.width = Math.max(1, Math.ceil(cssW / LOW));
work.height = Math.max(1, Math.ceil(cssH / LOW));
var workCtx = work.getContext("2d", { willReadFrequently: true });
// Gooey ratio: the blur radius vs. threshold slope is what makes
// this read as liquid instead of blur. Same math as the classic
// SVG chain (feGaussianBlur stdDeviation, then feColorMatrix
// alpha row 22 / -9), computed in-buffer for byte-stable seeks.
var blurLow = Math.max(2, (minDim * 0.028) / LOW);
// Resolve the ink color to a concrete value once at mount so
// canvas fills never depend on style recalc during seeks.
var probe = hostDoc.createElement("span");
probe.style.color =
"color-mix(in srgb, " + accentColors[accent] + " 62%, var(--fg, #1d1b16))";
root.appendChild(probe);
var inkColor = getComputedStyle(probe).color || "#1d1b16";
probe.remove();
// The LCG and every per-blob decision live here. This IIFE runs
// once before timeline construction; afterwards only the table is
// read. Blob state at any time is a pure closed-form function of
// (table row, timeline time). No velocities, no integration.
var blobRows = (function () {
var state = 0x1b1eedca;
function next() {
state = (Math.imul(1664525, state) + 1013904223) >>> 0;
return state / 4294967296;
}
var rows = [];
for (var i = 0; i < blobCount; i += 1) {
var angle = (i / blobCount) * Math.PI * 2 + next() * 0.9;
rows.push({
dirX: Math.cos(angle),
dirY: Math.sin(angle),
// How far this blob bleeds outward from the drop point.
spread: (0.16 + 0.16 * next()) * minDim,
// Base diameter; the first blob is the fat mother drop.
size: (i === 0 ? 0.34 : 0.14 + 0.12 * next()) * minDim,
// Bloom stagger and death schedule, fractions of IN.
birth: i === 0 ? 0 : 0.04 + 0.3 * next(),
die: 0.5 + 0.16 * next(),
// Wobble makes the bleed organic; amplitude rides (1 - die)
// so it is exactly zero once the blob is gone.
wobbleAmp: (0.02 + 0.03 * next()) * minDim,
wobblePhase: next() * Math.PI * 2,
wobbleFreq: 2 + Math.floor(next() * 3),
});
}
return rows;
})();
function clamp01(v) {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
function easeOutCubic(v) {
return 1 - Math.pow(1 - v, 3);
}
function easeInOut(v) {
return v < 0.5 ? 2 * v * v : 1 - Math.pow(-2 * v + 2, 2) / 2;
}
// Pure repaint: clear the work buffer, draw every blob from its
// closed-form (row, p) state, blur, threshold the readback, then
// blit up to the display canvas. Every step is a pure function
// of (table, timeline time); no history survives a frame.
function paint(timeSeconds) {
var p = IN > 0 ? clamp01(timeSeconds / IN) : 1;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// The layer fades over the mark as the last blobs die,
// guaranteeing an exactly-empty canvas through the HOLD.
var layerAlpha = 1 - clamp01((p - 0.86) / 0.12);
if (layerAlpha === 0) return;
workCtx.filter = "none";
workCtx.clearRect(0, 0, work.width, work.height);
workCtx.filter = "blur(" + blurLow + "px)";
workCtx.fillStyle = inkColor;
var drew = false;
for (var i = 0; i < blobRows.length; i += 1) {
var row = blobRows[i];
// Bloom: the blob surfaces and bleeds outward.
var grow = easeOutCubic(clamp01((p - row.birth) / 0.34));
// Settle: the ink pulls back to the drop point and dies.
// Every die ramp completes by p = 0.97 exactly.
var die = easeInOut(clamp01((p - row.die) / (0.97 - row.die)));
var r = (row.size / 2) * grow * (1 - die);
if (r <= 0.01) continue;
var reach = row.spread * grow * (1 - die);
var wob =
Math.sin(p * Math.PI * 2 * row.wobbleFreq + row.wobblePhase) *
row.wobbleAmp *
grow *
(1 - die);
var x = cx + row.dirX * reach + wob * row.dirY;
var y = cy + row.dirY * reach - wob * row.dirX;
workCtx.beginPath();
workCtx.arc(x / LOW, y / LOW, r / LOW, 0, Math.PI * 2);
workCtx.fill();
drew = true;
}
workCtx.filter = "none";
if (!drew) return;
// The colormatrix threshold (alpha row 22 / -9) applied to the
// blurred buffer: this snap is what turns overlap into goo.
var image = workCtx.getImageData(0, 0, work.width, work.height);
var data = image.data;
for (var j = 3; j < data.length; j += 4) {
var a = (data[j] / 255) * 22 - 9;
data[j] = a <= 0 ? 0 : a >= 1 ? 255 : Math.round(a * 255);
}
workCtx.putImageData(image, 0, 0);
ctx.globalAlpha = layerAlpha;
ctx.drawImage(work, 0, 0, canvas.width, canvas.height);
ctx.globalAlpha = 1;
}
gsap.set(stage, { opacity: 1, y: "0cqh" });
gsap.set(mark, { opacity: 0, scale: 0.94, transformOrigin: "50% 50%" });
var tl = gsap.timeline({
paused: true,
onUpdate: function () {
paint(tl.time());
},
});
// Anchor tween: an inert plain-object tween spanning the full
// authored duration so tl.time() covers [0, D] and onUpdate
// (the blob painter) fires for every eventful seek anywhere in
// the piece, including the hold.
tl.to({ p: 0 }, { p: 1, duration: duration, ease: "none" }, 0);
// IN: the crisp mark resolves beneath the contracting puddle.
// Both endpoints authored; the ink supplies the drama, the mark
// lands with one restrained settle.
tl.fromTo(
mark,
{ opacity: 0 },
{ opacity: 1, duration: IN * 0.34, ease: "power2.out" },
IN * 0.52,
);
tl.fromTo(
mark,
{ scale: 0.94 },
{ scale: 1, duration: IN * 0.4, ease: "power2.out" },
IN * 0.52,
);
// HOLD: truly still. The goo canvas is one clear rect (every
// blob dead, layer alpha zero) and the grain is a static seeded
// raster.
// 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["ink-bleed-reveal"] = tl;
})();
</script>
</div>
</template>
</body>
</html>
```
</VariablesExplorer>
## Install
<InstallCommand command="npx hyperframes add ink-bleed-reveal" item="ink-bleed-reveal" />
That writes one file: `compositions/components/ink-bleed-reveal.html`.
## Paste it into your composition
Open `compositions/components/ink-bleed-reveal.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 |
| --- | --- | --- | --- |
| `blobs` | `5` | 4 to 6, step 1 | How many ink blobs bleed and merge (4 to 6). |
| `accent` | `green` | `green`, `blue`, `violet` | Ink tint: green rides --brand, blue rides --accent, violet rides --accent-2. |
| `exit` | `none` | `none`, `fade`, `up` | Optional departure. Default none: the revealed mark 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="ink-bleed-reveal"
data-composition-src="compositions/components/ink-bleed-reveal.html"
data-variable-values='{"blobs":5,"accent":"green","exit":"none"}'
></div>
```
## Source
<Accordion title={`ink-bleed-reveal.html`}>
```html
<!doctype html>
<!--
ink-bleed-reveal: HyperFrames video primitive (texture / reveals). Wave M7.
Liquid ink blooms through paper to reveal a mark. Several ink blobs bleed
outward from the center and merge like liquid under the gooey chain
(Gaussian blur + a hard alpha threshold: the blur softens the silhouettes
together, the threshold snaps the merged alpha back to a crisp liquid
edge), then the puddle contracts and dies as the crisp mark resolves
beneath it. The chain is the classic SVG feGaussianBlur + feColorMatrix
recipe, but computed in a canvas work buffer each repaint: Chrome's live
filter raster patches damaged tiles with seek-history-dependent seam
antialiasing, while a cleared, fully redrawn canvas is a pure function of
(table, time) and lands byte-identical frames. A seeded, static
paper-grain overlay (feTurbulence, fixed seed, never animated) rides the
whole piece.
The mark 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, so the slot lives at document level):
<template data-slot="ink-bleed-reveal-mark"> ... </template>
With no slot, the primitive renders a token monogram: one display glyph in
ink, drawn entirely from contract tokens. Light paper is the home register,
but every color rides a token so themes restate it.
Variables (declared in data-composition-variables below):
- blobs (number 4-6, default 5): how many ink blobs bleed and merge.
- accent ("green" | "blue" | "violet", default "green"): ink tint; green
rides --brand, blue rides --accent, violet rides --accent-2. The ink
body is the accent mixed toward --fg so it reads as pigment, not paint.
- exit ("none" | "fade" | "up", default "none"): frame roots own
transitions; the default holds the revealed mark to the last pixel.
Envelope, fixed IN and OUT with elastic HOLD only (never timeScale):
IN_BASE = 2.80s ink blooms, merges, contracts; mark resolves crisp
HOLD = max(0, D - IN - OUT); truly still (grain is static by
construction: painted by a fixed-seed feTurbulence, never
animated, so held frames are byte-identical)
OUT_BASE = 0.45s only when exit != none
If D < IN_BASE + OUT_BASE, IN and OUT compress together.
Determinism (closed-form field law): there is NO physics state. One
per-blob parameter table is computed exactly once during synchronous
timeline construction from fixed LCG seed 0x1b1eedca; every painted frame
positions each blob as a pure function of (table row, timeline time) inside
the anchor tween's onUpdate. Zero per-blob tweens. No Math.random, no wall
clock, no requestAnimationFrame. Eventful seeks (suppressEvents=false, the
engine's render path) land identical frames in any order and either
direction; by the end of IN every blob's scale is exactly zero and the goo
layer's opacity is zero, so the HOLD is the crisp mark on still paper.
Mount contract: the runtime clones only this template. #root fills the
host box (no data-width/data-height, container-type: size, cqmin units),
is styled via #root only, and registers one paused timeline under the
LITERAL "ink-bleed-reveal" key. All DOM state is set with explicit
endpoints (gsap.set + fromTo) so any seek order lands identical frames.
-->
<html
lang="en"
data-composition-id="ink-bleed-reveal"
data-composition-duration="4"
data-composition-variables='[
{ "id": "blobs", "type": "number", "role": "style", "label": "Blobs", "description": "How many ink blobs bleed and merge (4 to 6).", "default": 5, "min": 4, "max": 6, "step": 1 },
{ "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Ink tint: 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 revealed mark 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>Ink Bleed Reveal</title>
</head>
<body>
<template>
<div id="root" data-composition-id="ink-bleed-reveal" 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, #1d1b16);
font-family: var(--font-display, "Inter", system-ui, sans-serif);
pointer-events: none;
}
.ibr-clip {
position: absolute;
inset: 0;
overflow: hidden;
background: var(--bg, transparent);
}
.ibr-stage {
position: absolute;
inset: 0;
will-change: transform, opacity;
}
/* The gooey layer is a canvas: blur + alpha threshold are computed
in-buffer each repaint (ctx.filter blur, then the colormatrix
threshold applied to the readback). Chrome's live SVG/CSS filter
raster patches damaged tiles with seek-history-dependent seam
antialiasing, which breaks byte-identical frames; a cleared and
fully redrawn canvas is a pure function of (table, time). */
.ibr-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.ibr-mark {
position: absolute;
inset: 0;
display: grid;
place-items: center;
will-change: transform, opacity;
}
.ibr-monogram {
display: grid;
place-items: center;
width: 44cqmin;
height: 44cqmin;
border: 0.5cqmin solid color-mix(in srgb, var(--ibr-accent) 58%, var(--fg, #1d1b16));
border-radius: 50%;
}
.ibr-monogram-glyph {
color: color-mix(in srgb, var(--ibr-accent) 55%, var(--fg, #1d1b16));
font-size: 22cqmin;
font-weight: 600;
line-height: 1;
letter-spacing: 0.02em;
}
/* Seeded static paper grain: feTurbulence with a FIXED seed paints
this layer once per raster; nothing about it is animated, so it
adds tooth without touching determinism. */
.ibr-grain {
position: absolute;
inset: 0;
filter: url(#ibr-grain-filter);
opacity: 0.5;
mix-blend-mode: multiply;
}
</style>
<div
id="ink-bleed-reveal-clip"
class="ibr-clip clip"
data-start="0"
data-duration="4"
data-track-index="0"
>
<svg aria-hidden="true" width="0" height="0" style="position: absolute">
<defs>
<filter
id="ibr-grain-filter"
x="0%"
y="0%"
width="100%"
height="100%"
color-interpolation-filters="sRGB"
>
<feTurbulence
type="fractalNoise"
baseFrequency="0.82"
numOctaves="2"
seed="11"
stitchTiles="stitch"
result="noise"
/>
<feColorMatrix
in="noise"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.14 0.14 0.14 0 0"
/>
</filter>
</defs>
</svg>
<div class="ibr-stage">
<div class="ibr-mark">
<div class="ibr-monogram">
<span class="ibr-monogram-glyph" aria-hidden="true">H</span>
</div>
</div>
<canvas class="ibr-canvas" aria-hidden="true"></canvas>
<div class="ibr-grain"></div>
</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(".ibr-stage");
var canvas = root.querySelector(".ibr-canvas");
var mark = root.querySelector(".ibr-mark");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
var blobCount = Math.round(Number(vars.blobs));
if (!Number.isFinite(blobCount)) blobCount = 5;
blobCount = Math.min(6, Math.max(4, blobCount));
// Each enum choice routes to a DIFFERENT contract token so the
// variable stays meaningful under a theme.
var accentColors = {
green: "var(--brand, #14a35e)",
blue: "var(--accent, #2f6fed)",
violet: "var(--accent-2, #7a5cd6)",
};
var accent = Object.prototype.hasOwnProperty.call(accentColors, vars.accent)
? vars.accent
: "green";
var exit = vars.exit === "fade" || vars.exit === "up" ? vars.exit : "none";
root.style.setProperty("--ibr-accent", accentColors[accent]);
// SLOT. Caller templates live at HOST DOCUMENT level (the mount
// wipes host-clip children, and templates never render). The
// scoped document proxy filters queries to this composition's
// subtree, so the lookup deliberately goes through ownerDocument.
var hostDoc = root.ownerDocument;
var slotTemplate = null;
try {
slotTemplate = hostDoc.querySelector('template[data-slot="ink-bleed-reveal-mark"]');
} catch (error) {
slotTemplate = null;
}
if (slotTemplate) {
mark.querySelector(".ibr-monogram").remove();
mark.appendChild(hostDoc.importNode(slotTemplate.content, true));
}
// Envelope: fixed IN/OUT, elastic HOLD, never time-scaled.
var IN_BASE = 2.8;
var OUT_BASE = exit === "none" ? 0 : 0.45;
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "4"));
var totalBase = Math.max(0.001, IN_BASE + OUT_BASE);
var scale = duration < totalBase ? duration / totalBase : 1;
var IN = IN_BASE * scale;
var OUT = OUT_BASE * scale;
var HOLD = Math.max(0, duration - (IN + OUT));
var OUT_START = IN + HOLD;
// Geometry basis is fixed once at mount from the host box, like
// the particle-image-reveal canvas raster: the same host always
// yields the same pixel coordinates and blur radius. The display
// canvas rides the host raster; the goo itself is computed in a
// low-resolution work buffer (the blur radius dwarfs the lost
// detail, and the upscale softens the ink edge pleasantly).
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);
var minDim = Math.min(cssW, cssH);
var cx = cssW / 2;
var cy = cssH / 2;
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
var ctx = canvas.getContext("2d");
var LOW = 3;
var work = hostDoc.createElement("canvas");
work.width = Math.max(1, Math.ceil(cssW / LOW));
work.height = Math.max(1, Math.ceil(cssH / LOW));
var workCtx = work.getContext("2d", { willReadFrequently: true });
// Gooey ratio: the blur radius vs. threshold slope is what makes
// this read as liquid instead of blur. Same math as the classic
// SVG chain (feGaussianBlur stdDeviation, then feColorMatrix
// alpha row 22 / -9), computed in-buffer for byte-stable seeks.
var blurLow = Math.max(2, (minDim * 0.028) / LOW);
// Resolve the ink color to a concrete value once at mount so
// canvas fills never depend on style recalc during seeks.
var probe = hostDoc.createElement("span");
probe.style.color =
"color-mix(in srgb, " + accentColors[accent] + " 62%, var(--fg, #1d1b16))";
root.appendChild(probe);
var inkColor = getComputedStyle(probe).color || "#1d1b16";
probe.remove();
// The LCG and every per-blob decision live here. This IIFE runs
// once before timeline construction; afterwards only the table is
// read. Blob state at any time is a pure closed-form function of
// (table row, timeline time). No velocities, no integration.
var blobRows = (function () {
var state = 0x1b1eedca;
function next() {
state = (Math.imul(1664525, state) + 1013904223) >>> 0;
return state / 4294967296;
}
var rows = [];
for (var i = 0; i < blobCount; i += 1) {
var angle = (i / blobCount) * Math.PI * 2 + next() * 0.9;
rows.push({
dirX: Math.cos(angle),
dirY: Math.sin(angle),
// How far this blob bleeds outward from the drop point.
spread: (0.16 + 0.16 * next()) * minDim,
// Base diameter; the first blob is the fat mother drop.
size: (i === 0 ? 0.34 : 0.14 + 0.12 * next()) * minDim,
// Bloom stagger and death schedule, fractions of IN.
birth: i === 0 ? 0 : 0.04 + 0.3 * next(),
die: 0.5 + 0.16 * next(),
// Wobble makes the bleed organic; amplitude rides (1 - die)
// so it is exactly zero once the blob is gone.
wobbleAmp: (0.02 + 0.03 * next()) * minDim,
wobblePhase: next() * Math.PI * 2,
wobbleFreq: 2 + Math.floor(next() * 3),
});
}
return rows;
})();
function clamp01(v) {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
function easeOutCubic(v) {
return 1 - Math.pow(1 - v, 3);
}
function easeInOut(v) {
return v < 0.5 ? 2 * v * v : 1 - Math.pow(-2 * v + 2, 2) / 2;
}
// Pure repaint: clear the work buffer, draw every blob from its
// closed-form (row, p) state, blur, threshold the readback, then
// blit up to the display canvas. Every step is a pure function
// of (table, timeline time); no history survives a frame.
function paint(timeSeconds) {
var p = IN > 0 ? clamp01(timeSeconds / IN) : 1;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// The layer fades over the mark as the last blobs die,
// guaranteeing an exactly-empty canvas through the HOLD.
var layerAlpha = 1 - clamp01((p - 0.86) / 0.12);
if (layerAlpha === 0) return;
workCtx.filter = "none";
workCtx.clearRect(0, 0, work.width, work.height);
workCtx.filter = "blur(" + blurLow + "px)";
workCtx.fillStyle = inkColor;
var drew = false;
for (var i = 0; i < blobRows.length; i += 1) {
var row = blobRows[i];
// Bloom: the blob surfaces and bleeds outward.
var grow = easeOutCubic(clamp01((p - row.birth) / 0.34));
// Settle: the ink pulls back to the drop point and dies.
// Every die ramp completes by p = 0.97 exactly.
var die = easeInOut(clamp01((p - row.die) / (0.97 - row.die)));
var r = (row.size / 2) * grow * (1 - die);
if (r <= 0.01) continue;
var reach = row.spread * grow * (1 - die);
var wob =
Math.sin(p * Math.PI * 2 * row.wobbleFreq + row.wobblePhase) *
row.wobbleAmp *
grow *
(1 - die);
var x = cx + row.dirX * reach + wob * row.dirY;
var y = cy + row.dirY * reach - wob * row.dirX;
workCtx.beginPath();
workCtx.arc(x / LOW, y / LOW, r / LOW, 0, Math.PI * 2);
workCtx.fill();
drew = true;
}
workCtx.filter = "none";
if (!drew) return;
// The colormatrix threshold (alpha row 22 / -9) applied to the
// blurred buffer: this snap is what turns overlap into goo.
var image = workCtx.getImageData(0, 0, work.width, work.height);
var data = image.data;
for (var j = 3; j < data.length; j += 4) {
var a = (data[j] / 255) * 22 - 9;
data[j] = a <= 0 ? 0 : a >= 1 ? 255 : Math.round(a * 255);
}
workCtx.putImageData(image, 0, 0);
ctx.globalAlpha = layerAlpha;
ctx.drawImage(work, 0, 0, canvas.width, canvas.height);
ctx.globalAlpha = 1;
}
gsap.set(stage, { opacity: 1, y: "0cqh" });
gsap.set(mark, { opacity: 0, scale: 0.94, transformOrigin: "50% 50%" });
var tl = gsap.timeline({
paused: true,
onUpdate: function () {
paint(tl.time());
},
});
// Anchor tween: an inert plain-object tween spanning the full
// authored duration so tl.time() covers [0, D] and onUpdate
// (the blob painter) fires for every eventful seek anywhere in
// the piece, including the hold.
tl.to({ p: 0 }, { p: 1, duration: duration, ease: "none" }, 0);
// IN: the crisp mark resolves beneath the contracting puddle.
// Both endpoints authored; the ink supplies the drama, the mark
// lands with one restrained settle.
tl.fromTo(
mark,
{ opacity: 0 },
{ opacity: 1, duration: IN * 0.34, ease: "power2.out" },
IN * 0.52,
);
tl.fromTo(
mark,
{ scale: 0.94 },
{ scale: 1, duration: IN * 0.4, ease: "power2.out" },
IN * 0.52,
);
// HOLD: truly still. The goo canvas is one clear rect (every
// blob dead, layer alpha zero) and the grain is a static seeded
// raster.
// 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["ink-bleed-reveal"] = tl;
})();
</script>
</div>
</template>
</body>
</html>
```
</Accordion>
{/* hf:generated-footer */}
Tagged `motion-primitive` `texture` `reveal` `gooey` `svg-filter` `deterministic` `experiment`.
## Related topics
- [Browse the complete Catalog](/catalog)
- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
- [Build a richer composition](/go-further)