mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
* 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>
968 lines
42 KiB
Plaintext
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)
|