mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(registry): add five video primitives
Five blocks aimed at the gaps in the catalog rather than at what was easiest to build. We are thick in passes, wipes, kinetic type and lower thirds, and thin in hero objects, depth and texture. cosmic-orb, a nebula sphere behind a refracting glass limb. A hero object, a shape the catalog had almost none of; it carries a title card alone. Analytic rather than raymarched, so cost is flat per pixel. gallery-tunnel, a corridor of panels flying toward camera. The cheapest real depth win available, and the only one whose panels are content slots, so it carries a shot rather than decorating one. lt-neon-border, two arcs of light travelling a rounded panel and snapping corner to corner. We ship eleven lower thirds and none had a travelling light. weight-wave, a crest of thickness moving along a headline. Every other kinetic type move animates transform, opacity or a mask; this animates the letterforms themselves. Ships Recursive subset to the two axes it uses. Requires the sweep-fingerprint fix, since font axes were not part of the layout audit and a duplexed font removes the last trace of motion. halftone-field, a dot grid tracking a flowing noise field. The catalog had no texture register, and this runs under type without competing with it. All five are closed form: state at frame N is computed from N, never from frame N-1. That is what makes them seekable, and it is verified per block by hashing a direct seek against stepped playback and against an out-of-order scrub, with a control frame to rule out a vacuously static pass. Frames were rendered and looked at rather than inferred from a passing check, which is not a visual gate. Two framework defects surfaced while building these and are fixed in the blocks that hit them. A timeline onUpdate callback does not fire on seek, because GSAP suppresses events there by default, so a canvas driven that way freezes on frame 0; the repaint hangs off a property setter instead. And a CSS-sized image uploads a 1x1 texture, because the browser samples the laid-out size rather than the natural one.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: "Cosmic Orb"
|
||||
description: "An iridescent soap-bubble planet: a slowly spinning sphere of nebula bands, dust lanes and twinkling stars wrapped in a refracting glass limb with chromatic aberration at the edge."
|
||||
---
|
||||
|
||||
<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/cosmic-orb.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/cosmic-orb.png" autoPlay muted loop playsInline />
|
||||
|
||||
## Install
|
||||
|
||||
```bash Terminal
|
||||
npx hyperframes add cosmic-orb
|
||||
```
|
||||
|
||||
That writes one file: `compositions/cosmic-orb.html`.
|
||||
|
||||
## Add it to your video
|
||||
|
||||
It runs for 10 seconds at 1920×1080. Paste this into your composition:
|
||||
|
||||
```html index.html
|
||||
<div
|
||||
data-composition-id="cosmic-orb"
|
||||
data-composition-src="compositions/cosmic-orb.html"
|
||||
data-start="0"
|
||||
data-duration="10"
|
||||
data-track-index="1"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
></div>
|
||||
```
|
||||
|
||||
Move it in time with `data-start`. Put it on a different timeline row with
|
||||
`data-track-index`. See [data attributes](/concepts/data-attributes) for the rest.
|
||||
|
||||
## Change how it looks
|
||||
|
||||
Set these CSS variables on the block:
|
||||
|
||||
- `hue` — Nebula hue. Defaults to `268`.
|
||||
- `accent` — Accent hue. Defaults to `196`.
|
||||
- `spin` — Spin rate (turns per second). Defaults to `0.035`.
|
||||
- `stars` — Star density. Defaults to `1`.
|
||||
- `glow` — Glow intensity. Defaults to `1`.
|
||||
- `size` — Orb size (fraction of frame height). Defaults to `0.86`.
|
||||
- `pulse` — Beat pulse (0-1, flat value when no envelope is set). Defaults to `0`.
|
||||
- `pulseEnvelope` — Beat pulse envelope: comma-separated 0-1 samples spread evenly across the clip. Defaults to ``.
|
||||
- `backdrop` — Backdrop. Defaults to `#04040a`.
|
||||
|
||||
{/* hf:generated-footer */}
|
||||
|
||||
Tagged `webgl` `shader` `background` `showcase` `3d`.
|
||||
|
||||
## Related topics
|
||||
|
||||
- [Browse the complete Catalog](/catalog)
|
||||
- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
|
||||
- [Build a richer composition](/go-further)
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: "Gallery Tunnel"
|
||||
description: "An infinite corridor of panels: a square tunnel receding to a vanishing point, its floor, ceiling and walls tiled with saturated colour slabs and caller-supplied images that swell toward camera and exit past you, the far end dissolving into fog."
|
||||
---
|
||||
|
||||
<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/gallery-tunnel.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/gallery-tunnel.png" autoPlay muted loop playsInline />
|
||||
|
||||
## Install
|
||||
|
||||
```bash Terminal
|
||||
npx hyperframes add gallery-tunnel
|
||||
```
|
||||
|
||||
That writes one file: `compositions/gallery-tunnel.html`.
|
||||
|
||||
## Add it to your video
|
||||
|
||||
It runs for 12 seconds at 1920×1080. Paste this into your composition:
|
||||
|
||||
```html index.html
|
||||
<div
|
||||
data-composition-id="gallery-tunnel"
|
||||
data-composition-src="compositions/gallery-tunnel.html"
|
||||
data-start="0"
|
||||
data-duration="12"
|
||||
data-track-index="1"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
></div>
|
||||
```
|
||||
|
||||
Move it in time with `data-start`. Put it on a different timeline row with
|
||||
`data-track-index`. See [data attributes](/concepts/data-attributes) for the rest.
|
||||
|
||||
## Change how it looks
|
||||
|
||||
Set these CSS variables on the block:
|
||||
|
||||
- `--images` — Image slots: comma-separated paths, tiled into the panels (e.g. assets/shot-1.png,assets/shot-2.png). Defaults to ``.
|
||||
- `--imagemix` — Image share of filled slots (0 = all colour, 1 = all images). Defaults to `0.5`.
|
||||
- `--speed` — Flight speed (segments per second). Defaults to `3`.
|
||||
- `--palette` — Slab palette: comma-separated hex colours. Defaults to `#FF6A00,#AB54F7,#EA3737,#0072E3,#00AA3C,#FFB200`.
|
||||
- `--grid` — Grid density: slots per wall face per axis (2 = 4 per face, 16 per segment). Defaults to `2`.
|
||||
- `--fill` — Slot fill rate. Defaults to `0.5`.
|
||||
- `--fog` — Fog distance in segments: depth at which the corridor reaches pure backdrop. Defaults to `14.25`.
|
||||
- `--edgecolor` — Edge line colour. Defaults to `#B0B0B0`.
|
||||
- `--edgeopacity` — Edge line opacity. Defaults to `0.5`.
|
||||
- `--backdrop` — Backdrop and fog colour. Defaults to `#000000`.
|
||||
|
||||
{/* hf:generated-footer */}
|
||||
|
||||
Tagged `webgl` `3d` `background` `showcase` `depth` `loop`.
|
||||
|
||||
## Related topics
|
||||
|
||||
- [Browse the complete Catalog](/catalog)
|
||||
- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
|
||||
- [Build a richer composition](/go-further)
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: "Halftone Field"
|
||||
description: "A full-bleed halftone light field: a grid of dots whose size and colour track a slow flowing simplex-noise field, so broad bands of light sweep across a mostly-black frame like a giant LED wall showing a plasma. Built to run under type."
|
||||
---
|
||||
|
||||
<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/halftone-field.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/halftone-field.png" autoPlay muted loop playsInline />
|
||||
|
||||
## Install
|
||||
|
||||
```bash Terminal
|
||||
npx hyperframes add halftone-field
|
||||
```
|
||||
|
||||
That writes one file: `compositions/halftone-field.html`.
|
||||
|
||||
## Add it to your video
|
||||
|
||||
It runs for 10 seconds at 1920×1080. Paste this into your composition:
|
||||
|
||||
```html index.html
|
||||
<div
|
||||
data-composition-id="halftone-field"
|
||||
data-composition-src="compositions/halftone-field.html"
|
||||
data-start="0"
|
||||
data-duration="10"
|
||||
data-track-index="1"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
></div>
|
||||
```
|
||||
|
||||
Move it in time with `data-start`. Put it on a different timeline row with
|
||||
`data-track-index`. See [data attributes](/concepts/data-attributes) for the rest.
|
||||
|
||||
## Change how it looks
|
||||
|
||||
Set these CSS variables on the block:
|
||||
|
||||
- `--frequency` — Field frequency (1 = broad swells, 10 = fine mottle). Defaults to `2`.
|
||||
- `--speed` — Flow speed (0 freezes the field). Defaults to `5`.
|
||||
- `--cellsize` — Cell size (dot pitch, 1 = 6px, 100 = 60px). Defaults to `54`.
|
||||
- `--gamma` — Gamma: midtone crush, the whole look. Low values flatten it to an even grey dot field. Defaults to `12`.
|
||||
- `--palettebias` — Palette bias: floor under every dot's size and palette position. 0 lets the darks go fully black. Defaults to `2`.
|
||||
- `--palette1` — Palette stop 1 (lows). Defaults to `#00AAFF`.
|
||||
- `--palette2` — Palette stop 2. Defaults to `#C2FF67`.
|
||||
- `--palette3` — Palette stop 3. Defaults to `#6600FF`.
|
||||
- `--palette4` — Palette stop 4 (crests). Defaults to `#000000`.
|
||||
- `--background` — Background. Defaults to `#000000`.
|
||||
- `--quantize` — Quantise to 30fps for a stepped LED-wall look. Defaults to `false`. Options: `false`, `true`.
|
||||
|
||||
{/* hf:generated-footer */}
|
||||
|
||||
Tagged `webgl` `shader` `background` `texture` `halftone`.
|
||||
|
||||
## Related topics
|
||||
|
||||
- [Browse the complete Catalog](/catalog)
|
||||
- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
|
||||
- [Build a richer composition](/go-further)
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: "Weight Wave"
|
||||
description: "A crest of thickness travels along a headline: each character swells from light to black and leans forward as the wave passes, driven by the wght and slnt variable-font axes rather than by transforms."
|
||||
---
|
||||
|
||||
<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/weight-wave.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/weight-wave.png" autoPlay muted loop playsInline />
|
||||
|
||||
## Install
|
||||
|
||||
```bash Terminal
|
||||
npx hyperframes add weight-wave
|
||||
```
|
||||
|
||||
That writes `compositions/weight-wave.html`, plus 2 supporting files under `compositions/assets/fonts/`.
|
||||
|
||||
## Add it to your video
|
||||
|
||||
It runs for 6 seconds at 1920×1080. Paste this into your composition:
|
||||
|
||||
```html index.html
|
||||
<div
|
||||
data-composition-id="weight-wave"
|
||||
data-composition-src="compositions/weight-wave.html"
|
||||
data-start="0"
|
||||
data-duration="6"
|
||||
data-track-index="1"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
></div>
|
||||
```
|
||||
|
||||
Move it in time with `data-start`. Put it on a different timeline row with
|
||||
`data-track-index`. See [data attributes](/concepts/data-attributes) for the rest.
|
||||
|
||||
{/* hf:generated-footer */}
|
||||
|
||||
Tagged `typography` `kinetic-type` `title` `variable-font` `text`.
|
||||
|
||||
## Related topics
|
||||
|
||||
- [Browse the complete Catalog](/catalog)
|
||||
- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
|
||||
- [Build a richer composition](/go-further)
|
||||
@@ -405,6 +405,9 @@
|
||||
"catalog/blocks/code-snippet-solarized-light",
|
||||
"catalog/blocks/code-snippet-visual-studio-dark",
|
||||
"catalog/blocks/code-snippet-visual-studio-light",
|
||||
"catalog/blocks/cosmic-orb",
|
||||
"catalog/blocks/cosmic-orb",
|
||||
"catalog/blocks/gallery-tunnel",
|
||||
"catalog/blocks/north-korea-locked-down",
|
||||
"catalog/blocks/nyc-paris-flight",
|
||||
"catalog/blocks/ui-3d-reveal",
|
||||
@@ -456,6 +459,7 @@
|
||||
"catalog/blocks/camcorder-hud",
|
||||
"catalog/blocks/flowchart",
|
||||
"catalog/blocks/flowchart-vertical",
|
||||
"catalog/blocks/halftone-field",
|
||||
"catalog/blocks/hw-frame",
|
||||
"catalog/blocks/hw-path-text",
|
||||
"catalog/blocks/hw-pipeline",
|
||||
@@ -466,6 +470,7 @@
|
||||
"catalog/blocks/mk-placeholder-grid",
|
||||
"catalog/blocks/mk-progress-stat",
|
||||
"catalog/blocks/mk-specs-list",
|
||||
"catalog/blocks/weight-wave",
|
||||
"catalog/blocks/yt-lcd-background",
|
||||
"catalog/blocks/yt-logo-intro",
|
||||
"catalog/blocks/yt-prism-title",
|
||||
|
||||
@@ -811,6 +811,36 @@
|
||||
"href": "/catalog/blocks/code-typing",
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/code-typing.png"
|
||||
},
|
||||
{
|
||||
"name": "cosmic-orb",
|
||||
"type": "block",
|
||||
"title": "Cosmic Orb",
|
||||
"description": "An iridescent soap-bubble planet: a slowly spinning sphere of nebula bands, dust lanes and twinkling stars wrapped in a refracting glass limb with chromatic aberration at the edge.",
|
||||
"tags": [
|
||||
"webgl",
|
||||
"shader",
|
||||
"background",
|
||||
"showcase",
|
||||
"3d"
|
||||
],
|
||||
"href": "/catalog/blocks/cosmic-orb",
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/cosmic-orb.png"
|
||||
},
|
||||
{
|
||||
"name": "cosmic-orb",
|
||||
"type": "block",
|
||||
"title": "Cosmic Orb",
|
||||
"description": "An iridescent soap-bubble planet: a slowly spinning sphere of nebula bands, dust lanes and twinkling stars wrapped in a refracting glass limb with chromatic aberration at the edge.",
|
||||
"tags": [
|
||||
"webgl",
|
||||
"shader",
|
||||
"background",
|
||||
"showcase",
|
||||
"3d"
|
||||
],
|
||||
"href": "/catalog/blocks/cosmic-orb",
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/cosmic-orb.png"
|
||||
},
|
||||
{
|
||||
"name": "cross-warp-morph",
|
||||
"type": "block",
|
||||
@@ -921,6 +951,22 @@
|
||||
"href": "/catalog/blocks/freeze-frame-dressing",
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/freeze-frame-dressing.png"
|
||||
},
|
||||
{
|
||||
"name": "gallery-tunnel",
|
||||
"type": "block",
|
||||
"title": "Gallery Tunnel",
|
||||
"description": "An infinite corridor of panels: a square tunnel receding to a vanishing point, its floor, ceiling and walls tiled with saturated colour slabs and caller-supplied images that swell toward camera and exit past you, the far end dissolving into fog.",
|
||||
"tags": [
|
||||
"webgl",
|
||||
"3d",
|
||||
"background",
|
||||
"showcase",
|
||||
"depth",
|
||||
"loop"
|
||||
],
|
||||
"href": "/catalog/blocks/gallery-tunnel",
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/gallery-tunnel.png"
|
||||
},
|
||||
{
|
||||
"name": "glitch",
|
||||
"type": "block",
|
||||
@@ -973,6 +1019,21 @@
|
||||
"href": "/catalog/components/grid-pixelate-wipe",
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/grid-pixelate-wipe.png"
|
||||
},
|
||||
{
|
||||
"name": "halftone-field",
|
||||
"type": "block",
|
||||
"title": "Halftone Field",
|
||||
"description": "A full-bleed halftone light field: a grid of dots whose size and colour track a slow flowing simplex-noise field, so broad bands of light sweep across a mostly-black frame like a giant LED wall showing a plasma. Built to run under type.",
|
||||
"tags": [
|
||||
"webgl",
|
||||
"shader",
|
||||
"background",
|
||||
"texture",
|
||||
"halftone"
|
||||
],
|
||||
"href": "/catalog/blocks/halftone-field",
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/halftone-field.png"
|
||||
},
|
||||
{
|
||||
"name": "hw-arrow",
|
||||
"type": "component",
|
||||
@@ -2164,6 +2225,21 @@
|
||||
"href": "/catalog/blocks/vpn-youtube-spot",
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vpn-youtube-spot.png"
|
||||
},
|
||||
{
|
||||
"name": "weight-wave",
|
||||
"type": "block",
|
||||
"title": "Weight Wave",
|
||||
"description": "A crest of thickness travels along a headline: each character swells from light to black and leans forward as the wave passes, driven by the wght and slnt variable-font axes rather than by transforms.",
|
||||
"tags": [
|
||||
"typography",
|
||||
"kinetic-type",
|
||||
"title",
|
||||
"variable-font",
|
||||
"text"
|
||||
],
|
||||
"href": "/catalog/blocks/weight-wave",
|
||||
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/weight-wave.png"
|
||||
},
|
||||
{
|
||||
"name": "whip-pan",
|
||||
"type": "block",
|
||||
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Cosmic Orb</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
#co-root {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#co-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #04040a;
|
||||
}
|
||||
#co-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="co-root"
|
||||
data-composition-id="cosmic-orb"
|
||||
data-root="true"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-start="0"
|
||||
data-duration="10"
|
||||
data-composition-variables='[
|
||||
{"id":"hue","type":"number","label":"Nebula hue","default":268,"min":0,"max":360,"step":1,"unit":"deg"},
|
||||
{"id":"accent","type":"number","label":"Accent hue","default":196,"min":0,"max":360,"step":1,"unit":"deg"},
|
||||
{"id":"spin","type":"number","label":"Spin rate","default":0.035,"min":0,"max":0.5,"step":0.005,"unit":"turns/s"},
|
||||
{"id":"stars","type":"number","label":"Star density","default":1,"min":0,"max":2,"step":0.05},
|
||||
{"id":"glow","type":"number","label":"Glow intensity","default":1,"min":0,"max":2,"step":0.05},
|
||||
{"id":"size","type":"number","label":"Orb size","default":0.86,"min":0.2,"max":1.2,"step":0.01},
|
||||
{"id":"pulse","type":"number","label":"Beat pulse","default":0,"min":0,"max":1,"step":0.01},
|
||||
{"id":"pulseEnvelope","type":"string","label":"Beat pulse envelope (comma-separated 0-1 samples)","default":"","placeholder":"0,0.2,1,0.4,0"},
|
||||
{"id":"backdrop","type":"color","label":"Backdrop","default":"#04040a"}
|
||||
]'
|
||||
>
|
||||
<div id="co-backdrop"></div>
|
||||
<canvas id="co-canvas" width="1920" height="1080"></canvas>
|
||||
|
||||
<!-- Driver clip: gives HyperFrames a timed element to own on track 0. -->
|
||||
<div
|
||||
id="co-drv"
|
||||
class="clip"
|
||||
data-start="0"
|
||||
data-duration="10"
|
||||
data-track-index="0"
|
||||
style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var DUR = 10;
|
||||
var W = 1920;
|
||||
var H = 1080;
|
||||
|
||||
var V = (window.__hyperframes && window.__hyperframes.getVariables()) || {};
|
||||
var CS = getComputedStyle(document.getElementById("co-root"));
|
||||
|
||||
// The runtime defines every declared variable as `--<id>` on the root,
|
||||
// so a host stylesheet can override one there too. Read the custom
|
||||
// property first, fall back to the declared value when it is unset.
|
||||
function raw(id) {
|
||||
var css = CS.getPropertyValue("--" + id.toLowerCase()).trim();
|
||||
return css !== "" ? css : V[id];
|
||||
}
|
||||
function num(id, fallback) {
|
||||
var n = parseFloat(raw(id));
|
||||
return isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
var HUE = num("hue", 268);
|
||||
var ACCENT = num("accent", 196);
|
||||
var SPIN = num("spin", 0.035);
|
||||
var STARS = num("stars", 1);
|
||||
var GLOW = num("glow", 1);
|
||||
var SIZE = num("size", 0.86);
|
||||
var PULSE = num("pulse", 0);
|
||||
var BACKDROP =
|
||||
typeof raw("backdrop") === "string" && raw("backdrop") ? raw("backdrop") : "#04040a";
|
||||
|
||||
document.getElementById("co-backdrop").style.background = BACKDROP;
|
||||
|
||||
// Beat hook. A precomputed envelope (comma-separated 0-1 samples spread
|
||||
// evenly across the clip) is sampled closed-form at time t — no audio
|
||||
// analysis, no accumulator. Empty envelope => the flat `pulse` value.
|
||||
var ENV = String(raw("pulseEnvelope") || "")
|
||||
.split(",")
|
||||
.map(function (s) {
|
||||
return parseFloat(s);
|
||||
})
|
||||
.filter(function (n) {
|
||||
return isFinite(n);
|
||||
});
|
||||
|
||||
function pulseAt(t) {
|
||||
if (ENV.length === 0) return PULSE;
|
||||
if (ENV.length === 1) return ENV[0];
|
||||
var x = Math.min(1, Math.max(0, t / DUR)) * (ENV.length - 1);
|
||||
var i = Math.floor(x);
|
||||
var j = Math.min(i + 1, ENV.length - 1);
|
||||
return ENV[i] + (ENV[j] - ENV[i]) * (x - i);
|
||||
}
|
||||
|
||||
var canvas = document.getElementById("co-canvas");
|
||||
var gl =
|
||||
canvas.getContext("webgl", {
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
depth: false,
|
||||
stencil: false,
|
||||
preserveDrawingBuffer: true,
|
||||
powerPreference: "high-performance",
|
||||
}) ||
|
||||
canvas.getContext("experimental-webgl", {
|
||||
alpha: true,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
|
||||
var VERT = [
|
||||
"attribute vec2 aPos;",
|
||||
"void main() { gl_Position = vec4(aPos, 0.0, 1.0); }",
|
||||
].join("\n");
|
||||
|
||||
var FRAG = [
|
||||
"precision highp float;",
|
||||
"uniform vec2 uRes;",
|
||||
"uniform float uTime;",
|
||||
"uniform float uRadius;",
|
||||
"uniform float uSpin;",
|
||||
"uniform float uHue;",
|
||||
"uniform float uAccent;",
|
||||
"uniform float uStars;",
|
||||
"uniform float uGlow;",
|
||||
"uniform float uPulse;",
|
||||
"const float TAU = 6.28318531;",
|
||||
"",
|
||||
"// One cheap hash for every random-looking thing in here.",
|
||||
"float h1(float x) { return fract(sin(x * 127.1) * 43758.5453); }",
|
||||
"float h2(vec2 p) { return h1(dot(p, vec2(1.0, 157.31))); }",
|
||||
"",
|
||||
"vec3 hue2rgb(float h) {",
|
||||
" return clamp(abs(mod(h * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);",
|
||||
"}",
|
||||
"",
|
||||
"vec3 rotX(vec3 p, float a) { float c = cos(a), s = sin(a); return vec3(p.x, c * p.y - s * p.z, s * p.y + c * p.z); }",
|
||||
"vec3 rotY(vec3 p, float a) { float c = cos(a), s = sin(a); return vec3(c * p.x + s * p.z, p.y, -s * p.x + c * p.z); }",
|
||||
"vec3 rotZ(vec3 p, float a) { float c = cos(a), s = sin(a); return vec3(c * p.x - s * p.y, s * p.x + c * p.y, p.z); }",
|
||||
"",
|
||||
"vec3 safeDir(vec3 v) {",
|
||||
" float l = dot(v, v);",
|
||||
" if (l < 1e-6) return vec3(0.0, 0.0, -1.0);",
|
||||
" return v * inversesqrt(l);",
|
||||
"}",
|
||||
"",
|
||||
"// Fixed axial tilt, then spin about the tilted polar axis.",
|
||||
"vec3 toBody(vec3 n, float spin) { return rotY(rotZ(rotX(n, -0.34), 0.42), -spin); }",
|
||||
"vec2 lonlat(vec3 d) { return vec2(atan(d.x, d.z), asin(clamp(d.y, -1.0, 1.0))); }",
|
||||
"",
|
||||
"float dens(float base) { return clamp(1.0 - (1.0 - base) * uStars, 0.0, 1.0); }",
|
||||
"",
|
||||
"// One star size-class: cell grid, seeded jitter, soft round core.",
|
||||
"float starField(vec2 q, float freq, float base, float rad) {",
|
||||
" vec2 g = q * freq;",
|
||||
" vec2 id = floor(g);",
|
||||
" float h = h2(id);",
|
||||
" if (h < dens(base)) return 0.0;",
|
||||
" vec2 c = vec2(h2(id + vec2(3.7, 1.3)), h2(id + vec2(9.1, 5.5)));",
|
||||
" float d = length(fract(g) - c);",
|
||||
" float core = smoothstep(rad, 0.0, d);",
|
||||
" // Twinkle: a per-star phase driven straight off uTime, no accumulator.",
|
||||
" float tw = 0.62 + 0.38 * sin(uTime * 2.4 + h * 53.7);",
|
||||
" return core * core * (0.45 + 0.55 * h1(h * 91.7)) * tw;",
|
||||
"}",
|
||||
"",
|
||||
"// 4-point diffraction glare, only worth paying for on the brightest class.",
|
||||
"float starGlare(vec2 q, float freq, float base) {",
|
||||
" vec2 g = q * freq;",
|
||||
" vec2 id = floor(g);",
|
||||
" float h = h2(id);",
|
||||
" if (h < dens(base)) return 0.0;",
|
||||
" vec2 c = vec2(h2(id + vec2(3.7, 1.3)), h2(id + vec2(9.1, 5.5)));",
|
||||
" vec2 dv = abs(fract(g) - c);",
|
||||
" float hx = exp(-dv.x * 70.0) * exp(-dv.y * 9.0);",
|
||||
" float hy = exp(-dv.y * 70.0) * exp(-dv.x * 9.0);",
|
||||
" return max(hx, hy) * 0.45;",
|
||||
"}",
|
||||
"",
|
||||
"// Three size-classes at ~6 / 11 / 19 cell frequency.",
|
||||
"float starsAll(vec2 q, float gain) {",
|
||||
" float s = starField(q, 6.0, 0.958, 0.20);",
|
||||
" s += starGlare(q, 6.0, 0.958);",
|
||||
" s += starField(q, 11.0, 0.920, 0.18) * 0.95;",
|
||||
" s += starField(q, 19.0, 0.865, 0.17) * 0.75;",
|
||||
" return s * gain;",
|
||||
"}",
|
||||
"",
|
||||
"vec3 nebula(vec2 q) {",
|
||||
" // Two sin-field turbulence layers.",
|
||||
" float t1 = sin(q.x * 3.1 + sin(q.y * 2.7) * 1.9) * sin(q.y * 4.3 + sin(q.x * 1.7) * 2.3);",
|
||||
" float t2 = sin(q.x * 7.9 + sin(q.y * 5.1) * 1.3) * sin(q.y * 9.7 + sin(q.x * 4.4) * 1.1);",
|
||||
" float turb = t1 * 0.66 + t2 * 0.34;",
|
||||
"",
|
||||
" // Galaxy bands, warped by the turbulence.",
|
||||
" float band = sin(q.y * 3.2 + turb * 1.7 + q.x * 0.55);",
|
||||
" float bands = pow(max(0.0, band * 0.5 + 0.5), 3.2);",
|
||||
"",
|
||||
" // Dust lanes: broad darkened swathes, not a thin curve.",
|
||||
" float lane = sin(q.y * 2.9 + q.x * 1.3 + t1 * 1.6);",
|
||||
" float dust = 0.22 + 0.78 * smoothstep(-0.45, 0.75, lane);",
|
||||
"",
|
||||
" // Core bulge.",
|
||||
" float dc = length(vec2((q.x - 0.55) * 0.5, (q.y + 0.12) * 1.1));",
|
||||
" float core = exp(-dc * dc * 2.6);",
|
||||
"",
|
||||
" // Two pocket glows.",
|
||||
" float dA = length(vec2((q.x + 1.95) * 0.62, (q.y - 0.52) * 1.45));",
|
||||
" float dB = length(vec2((q.x - 2.35) * 0.55, (q.y + 0.78) * 1.30));",
|
||||
" float pA = exp(-dA * dA * 4.0);",
|
||||
" float pB = exp(-dB * dB * 5.0);",
|
||||
"",
|
||||
" vec3 cNeb = hue2rgb(uHue / 360.0);",
|
||||
" vec3 cAcc = hue2rgb(uAccent / 360.0);",
|
||||
" vec3 cWarm = hue2rgb(uHue / 360.0 + 0.07);",
|
||||
"",
|
||||
" vec3 col = mix(cNeb, cWarm, 0.5 + 0.5 * turb) * bands * dust * 0.17;",
|
||||
" col += mix(cNeb, vec3(1.0), 0.50) * core * dust * 0.13;",
|
||||
" col += mix(cAcc, vec3(1.0), 0.60) * pA * 0.30 * uGlow;",
|
||||
" col += mix(cNeb, vec3(1.0), 0.50) * pB * 0.24 * uGlow;",
|
||||
" return col;",
|
||||
"}",
|
||||
"",
|
||||
"void main() {",
|
||||
" vec2 p = (gl_FragCoord.xy - uRes * 0.5) / uRadius;",
|
||||
" float r = length(p);",
|
||||
" float aa = 1.4 / uRadius;",
|
||||
" float spin = TAU * uSpin * uTime;",
|
||||
"",
|
||||
" vec3 cNeb = hue2rgb(uHue / 360.0);",
|
||||
" vec3 cAcc = hue2rgb(uAccent / 360.0);",
|
||||
"",
|
||||
" float mask = smoothstep(1.0 + aa, 1.0 - aa, r);",
|
||||
" float halo = exp(-max(0.0, r - 1.0) * 13.0) * (1.0 - mask);",
|
||||
"",
|
||||
" vec3 body = vec3(0.0);",
|
||||
" if (mask > 0.001) {",
|
||||
" float rc = min(r, 1.0);",
|
||||
" // Analytic sphere: the normal falls straight out of z = sqrt(1 - r^2).",
|
||||
" float z = sqrt(max(0.0, 1.0 - rc * rc));",
|
||||
" vec3 N = vec3(p.x, p.y, z);",
|
||||
" vec2 q = lonlat(toBody(safeDir(N), spin));",
|
||||
"",
|
||||
" float gain = 0.9 + 2.6 * uPulse;",
|
||||
" body = nebula(q);",
|
||||
" body += vec3(1.0) * starsAll(q, gain) * (0.95 + 0.15 * uGlow);",
|
||||
"",
|
||||
" // Limb darkening.",
|
||||
" body *= mix(0.32, 1.0, pow(z, 0.5));",
|
||||
"",
|
||||
" // Refract-sampled back layer, split per channel: the chromatic",
|
||||
" // aberration that reads as glass at the limb.",
|
||||
" vec3 I = vec3(0.0, 0.0, -1.0);",
|
||||
" vec3 dR = safeDir(refract(I, N, 1.0 / 1.09));",
|
||||
" vec3 dG = safeDir(refract(I, N, 1.0 / 1.13));",
|
||||
" vec3 dB = safeDir(refract(I, N, 1.0 / 1.17));",
|
||||
" vec2 qR = lonlat(toBody(dR, spin));",
|
||||
" vec2 qG = lonlat(toBody(dG, spin));",
|
||||
" vec2 qB = lonlat(toBody(dB, spin));",
|
||||
" vec3 back = vec3(starsAll(qR, gain), starsAll(qG, gain), starsAll(qB, gain));",
|
||||
" float fres = pow(1.0 - z, 1.8);",
|
||||
" body += back * fres * 2.2;",
|
||||
" body += nebula(qG) * fres * 0.8;",
|
||||
"",
|
||||
" // Aurora sheen riding the glass shell.",
|
||||
" float ang = atan(p.y, p.x);",
|
||||
" float sheen = smoothstep(0.72, 0.99, r) * smoothstep(1.0, 0.90, r);",
|
||||
" float w = 0.5 + 0.5 * sin(ang * 2.0 + spin * 1.6);",
|
||||
" body += (mix(cNeb, cAcc, w) * 0.55 + vec3(0.16)) * sheen * 0.30 * uGlow;",
|
||||
"",
|
||||
" // Three speculars, fixed in screen space: the key light does not spin.",
|
||||
" vec2 a1 = p - vec2(-0.34, 0.62);",
|
||||
" vec2 a2 = p - vec2(0.58, 0.60);",
|
||||
" vec2 a3 = p - vec2(0.24, -0.80);",
|
||||
" float sp = exp(-dot(a1, a1) * 30.0) * 0.30;",
|
||||
" sp += exp(-dot(a2, a2) * 150.0) * 0.80;",
|
||||
" sp += exp(-dot(a3, a3) * 70.0) * 0.30;",
|
||||
" body += vec3(1.0) * sp * (0.30 + 0.70 * pow(1.0 - z, 1.5)) * uGlow;",
|
||||
"",
|
||||
" // Fresnel void glow hugging the inside of the rim.",
|
||||
" body += mix(cAcc, vec3(1.0), 0.4) * pow(1.0 - z, 6.0) * 0.30 * uGlow;",
|
||||
" }",
|
||||
"",
|
||||
" vec3 haloCol = mix(cNeb, cAcc, 0.35);",
|
||||
" vec3 outCol = body * mask + haloCol * halo * 0.22 * uGlow;",
|
||||
" float outA = clamp(mask + halo * 0.16 * uGlow, 0.0, 1.0);",
|
||||
" gl_FragColor = vec4(clamp(outCol, 0.0, 4.0), outA);",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
var uni = {};
|
||||
var ready = false;
|
||||
|
||||
function compile(type, src) {
|
||||
var sh = gl.createShader(type);
|
||||
gl.shaderSource(sh, src);
|
||||
gl.compileShader(sh);
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
||||
throw new Error("cosmic-orb shader: " + gl.getShaderInfoLog(sh));
|
||||
}
|
||||
return sh;
|
||||
}
|
||||
|
||||
if (gl) {
|
||||
var prog = gl.createProgram();
|
||||
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
|
||||
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
|
||||
gl.linkProgram(prog);
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
||||
throw new Error("cosmic-orb link: " + gl.getProgramInfoLog(prog));
|
||||
}
|
||||
gl.useProgram(prog);
|
||||
|
||||
var buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
||||
var loc = gl.getAttribLocation(prog, "aPos");
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
[
|
||||
"uRes",
|
||||
"uTime",
|
||||
"uRadius",
|
||||
"uSpin",
|
||||
"uHue",
|
||||
"uAccent",
|
||||
"uStars",
|
||||
"uGlow",
|
||||
"uPulse",
|
||||
].forEach(function (n) {
|
||||
uni[n] = gl.getUniformLocation(prog, n);
|
||||
});
|
||||
|
||||
gl.viewport(0, 0, W, H);
|
||||
gl.uniform2f(uni.uRes, W, H);
|
||||
gl.uniform1f(uni.uRadius, (SIZE * H) / 2);
|
||||
gl.uniform1f(uni.uSpin, SPIN);
|
||||
gl.uniform1f(uni.uHue, HUE);
|
||||
gl.uniform1f(uni.uAccent, ACCENT);
|
||||
gl.uniform1f(uni.uStars, STARS);
|
||||
gl.uniform1f(uni.uGlow, GLOW);
|
||||
ready = true;
|
||||
}
|
||||
|
||||
// Every frame is computed from t alone: spin is spin_rate * t, the
|
||||
// pulse is sampled from the envelope at t. No accumulators, no clocks.
|
||||
function draw(t) {
|
||||
if (!ready) return;
|
||||
gl.uniform1f(uni.uTime, t);
|
||||
gl.uniform1f(uni.uPulse, pulseAt(t));
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
gl.flush();
|
||||
}
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
|
||||
// The canvas is repainted from a property SETTER, not from onUpdate:
|
||||
// gsap's seek(t) suppresses events by default, so an onUpdate callback
|
||||
// silently never fires on a scrub and the canvas freezes on frame 0.
|
||||
// Tweened values are always written during render, suppressed or not,
|
||||
// so this fires on every seek — and hands us the frame time directly.
|
||||
var driver = { _t: 0 };
|
||||
Object.defineProperty(driver, "t", {
|
||||
get: function () {
|
||||
return this._t;
|
||||
},
|
||||
set: function (v) {
|
||||
this._t = v;
|
||||
draw(v);
|
||||
},
|
||||
});
|
||||
tl.to(driver, { t: DUR, duration: DUR, ease: "none" }, 0);
|
||||
window.__timelines["cosmic-orb"] = tl;
|
||||
|
||||
draw(0);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "cosmic-orb",
|
||||
"type": "hyperframes:block",
|
||||
"title": "Cosmic Orb",
|
||||
"description": "An iridescent soap-bubble planet: a slowly spinning sphere of nebula bands, dust lanes and twinkling stars wrapped in a refracting glass limb with chromatic aberration at the edge.",
|
||||
"stability": "experimental",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 10,
|
||||
"tags": ["webgl", "shader", "background", "showcase", "3d"],
|
||||
"files": [
|
||||
{
|
||||
"path": "cosmic-orb.html",
|
||||
"target": "compositions/cosmic-orb.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
],
|
||||
"params": [
|
||||
{
|
||||
"key": "--hue",
|
||||
"label": "Nebula hue",
|
||||
"type": "number",
|
||||
"default": "268",
|
||||
"min": 0,
|
||||
"max": 360,
|
||||
"step": 1
|
||||
},
|
||||
{
|
||||
"key": "--accent",
|
||||
"label": "Accent hue",
|
||||
"type": "number",
|
||||
"default": "196",
|
||||
"min": 0,
|
||||
"max": 360,
|
||||
"step": 1
|
||||
},
|
||||
{
|
||||
"key": "--spin",
|
||||
"label": "Spin rate (turns per second)",
|
||||
"type": "number",
|
||||
"default": "0.035",
|
||||
"min": 0,
|
||||
"max": 0.5,
|
||||
"step": 0.005
|
||||
},
|
||||
{
|
||||
"key": "--stars",
|
||||
"label": "Star density",
|
||||
"type": "number",
|
||||
"default": "1",
|
||||
"min": 0,
|
||||
"max": 2,
|
||||
"step": 0.05
|
||||
},
|
||||
{
|
||||
"key": "--glow",
|
||||
"label": "Glow intensity",
|
||||
"type": "number",
|
||||
"default": "1",
|
||||
"min": 0,
|
||||
"max": 2,
|
||||
"step": 0.05
|
||||
},
|
||||
{
|
||||
"key": "--size",
|
||||
"label": "Orb size (fraction of frame height)",
|
||||
"type": "number",
|
||||
"default": "0.86",
|
||||
"min": 0.2,
|
||||
"max": 1.2,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"key": "--pulse",
|
||||
"label": "Beat pulse (0-1, flat value when no envelope is set)",
|
||||
"type": "number",
|
||||
"default": "0",
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"step": 0.01
|
||||
},
|
||||
{
|
||||
"key": "--pulseenvelope",
|
||||
"label": "Beat pulse envelope: comma-separated 0-1 samples spread evenly across the clip",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"key": "--backdrop",
|
||||
"label": "Backdrop",
|
||||
"type": "color",
|
||||
"default": "#04040a"
|
||||
}
|
||||
]
|
||||
}
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Gallery Tunnel</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.147.0/build/three.min.js"></script>
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
#gt-root {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#gt-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #000000;
|
||||
}
|
||||
#gt-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
}
|
||||
/* Off-screen decode well: the <img> elements live in the DOM so the page
|
||||
load event waits for them, but they are never composited themselves —
|
||||
WebGL reads them as textures. The well is clipped to 1px; the images
|
||||
inside keep their natural layout size on purpose, because texImage2D
|
||||
uploads an <img> at its LAID-OUT size — sizing them down with CSS
|
||||
uploads a 1x1 texture and every panel samples one dark pixel. */
|
||||
#gt-assets {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
Content slots. Every panel in the corridor is a slot, and `images` fills a
|
||||
share of them with your own artwork — drop the files in the project's
|
||||
assets/ and list them, either by editing the `images` default below or from
|
||||
the host composition:
|
||||
|
||||
<div
|
||||
data-composition-id="gallery-tunnel"
|
||||
data-composition-src="compositions/gallery-tunnel.html"
|
||||
data-start="0" data-duration="12" data-track-index="0"
|
||||
data-width="1920" data-height="1080"
|
||||
style="--images: assets/shot-1.png, assets/shot-2.png, assets/shot-3.png; --imagemix: 0.7"
|
||||
></div>
|
||||
|
||||
Any number of images works; they are dealt to slots by hash, so one image
|
||||
recurs at different depths rather than appearing once. Each is contain-fit
|
||||
inside its slot, so a 16:9 screenshot keeps its aspect. `imageMix` sets the
|
||||
image-vs-colour split (1 = every filled slot is an image), `fill` how many
|
||||
slots are filled at all.
|
||||
-->
|
||||
<div
|
||||
id="gt-root"
|
||||
data-composition-id="gallery-tunnel"
|
||||
data-root="true"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-start="0"
|
||||
data-duration="12"
|
||||
data-composition-variables='[
|
||||
{"id":"images","type":"string","label":"Image slots: comma-separated paths, tiled into the panels (e.g. assets/shot-1.png,assets/shot-2.png)","default":"","placeholder":"assets/shot-1.png,assets/shot-2.png"},
|
||||
{"id":"imageMix","type":"number","label":"Image share of filled slots (0 = all colour, 1 = all images)","default":0.5,"min":0,"max":1,"step":0.05},
|
||||
{"id":"speed","type":"number","label":"Flight speed","default":3,"min":0,"max":8,"step":0.1,"unit":"segments/s"},
|
||||
{"id":"palette","type":"string","label":"Slab palette: comma-separated hex colours","default":"#FF6A00,#AB54F7,#EA3737,#0072E3,#00AA3C,#FFB200"},
|
||||
{"id":"grid","type":"number","label":"Grid density: slots per wall face per axis (2 = 4 per face, 16 per segment)","default":2,"min":1,"max":4,"step":1},
|
||||
{"id":"fill","type":"number","label":"Slot fill rate","default":0.5,"min":0,"max":1,"step":0.05},
|
||||
{"id":"fog","type":"number","label":"Fog distance: depth at which the corridor reaches pure backdrop","default":14.25,"min":3,"max":14.5,"step":0.25,"unit":"segments"},
|
||||
{"id":"edgeColor","type":"color","label":"Edge line colour","default":"#B0B0B0"},
|
||||
{"id":"edgeOpacity","type":"number","label":"Edge line opacity","default":0.5,"min":0,"max":1,"step":0.05},
|
||||
{"id":"backdrop","type":"color","label":"Backdrop and fog colour","default":"#000000"}
|
||||
]'
|
||||
>
|
||||
<div id="gt-backdrop"></div>
|
||||
<canvas id="gt-canvas" width="1920" height="1080"></canvas>
|
||||
<div id="gt-assets" aria-hidden="true" data-layout-allow-overflow></div>
|
||||
|
||||
<!-- Driver clip: gives HyperFrames a timed element to own on track 0. -->
|
||||
<div
|
||||
id="gt-drv"
|
||||
class="clip"
|
||||
data-start="0"
|
||||
data-duration="12"
|
||||
data-track-index="0"
|
||||
style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var DUR = 12;
|
||||
var W = 1920;
|
||||
var H = 1080;
|
||||
|
||||
// Fixed tunnel geometry, measured off the reference.
|
||||
var TUNNEL_WIDTH = 2;
|
||||
var TUNNEL_HEIGHT = 1.8;
|
||||
var SEGMENT_DEPTH = 1;
|
||||
var NUM_SEGMENTS = 15;
|
||||
var LINE_RADIUS = 0.003;
|
||||
var FOV = 75;
|
||||
var INSET = 0.94; // slab gutter inside its cell, so tiles read as discrete
|
||||
var SKIN = 0.002; // push slabs just behind the wall plane so edges stay on top
|
||||
|
||||
var ROOT = document.getElementById("gt-root");
|
||||
var CS = getComputedStyle(ROOT);
|
||||
|
||||
// `data-composition-variables` is the single owner of every declared
|
||||
// default — parse it rather than repeating each default a second time
|
||||
// in JS. `window.__hyperframes` is NOT guaranteed to exist yet when this
|
||||
// inline script runs, so it can only ever be an override, never the
|
||||
// source. Precedence: host CSS custom property, runtime variable bag,
|
||||
// declared default.
|
||||
var DECL = {};
|
||||
JSON.parse(ROOT.getAttribute("data-composition-variables") || "[]").forEach(function (v) {
|
||||
DECL[v.id] = v.default;
|
||||
});
|
||||
var HF = window.__hyperframes;
|
||||
var V = (HF && HF.getVariables && HF.getVariables()) || {};
|
||||
|
||||
function raw(id) {
|
||||
var css = CS.getPropertyValue("--" + id.toLowerCase()).trim();
|
||||
if (css !== "") return css;
|
||||
if (V[id] !== undefined && V[id] !== "") return V[id];
|
||||
return DECL[id];
|
||||
}
|
||||
function num(id) {
|
||||
var n = parseFloat(raw(id));
|
||||
return isFinite(n) ? n : 0;
|
||||
}
|
||||
function str(id) {
|
||||
var s = raw(id);
|
||||
return typeof s === "string" ? s.trim() : "";
|
||||
}
|
||||
function list(id) {
|
||||
return str(id)
|
||||
.split(",")
|
||||
.map(function (s) {
|
||||
return s.trim();
|
||||
})
|
||||
.filter(function (s) {
|
||||
return s !== "";
|
||||
});
|
||||
}
|
||||
|
||||
var SPEED = num("speed");
|
||||
var GRID = Math.max(1, Math.min(4, Math.round(num("grid"))));
|
||||
var FILL = num("fill");
|
||||
var IMAGE_MIX = num("imageMix");
|
||||
var FOG_FAR = Math.max(3, Math.min(14.5, num("fog")));
|
||||
var EDGE_OPACITY = num("edgeOpacity");
|
||||
var EDGE_COLOR = str("edgeColor");
|
||||
var BACKDROP = str("backdrop");
|
||||
var PALETTE = list("palette");
|
||||
var IMAGE_SRCS = list("images");
|
||||
|
||||
document.getElementById("gt-backdrop").style.background = BACKDROP;
|
||||
|
||||
// How far behind the camera a segment travels before it recycles. Tying
|
||||
// it to the fog distance is the whole trick: the recycle boundary lands
|
||||
// at exactly FOG_FAR, so a segment reappears already fogged to pure
|
||||
// backdrop and nothing ever pops in at the far end. One owner for the
|
||||
// invariant — move the fog and the boundary follows.
|
||||
var BACK = Math.max(0, Math.min(1.5, NUM_SEGMENTS * SEGMENT_DEPTH - FOG_FAR));
|
||||
|
||||
// Deterministic 2D integer hash — the only source of "randomness".
|
||||
// Content at frame N is a pure function of (absolute segment, slot).
|
||||
function hash2(a, b) {
|
||||
var h = Math.imul(a | 0, 0x27d4eb2d) ^ Math.imul(b | 0, 0x165667b1);
|
||||
h = Math.imul(h ^ (h >>> 15), 0x2545f491);
|
||||
h ^= h >>> 13;
|
||||
return (h >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
var THREEJS = window.THREE;
|
||||
var canvas = document.getElementById("gt-canvas");
|
||||
var renderer = new THREEJS.WebGLRenderer({
|
||||
canvas: canvas,
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setSize(W, H, false);
|
||||
renderer.setClearColor(new THREEJS.Color(BACKDROP), 1);
|
||||
|
||||
var scene = new THREEJS.Scene();
|
||||
scene.fog = new THREEJS.Fog(new THREEJS.Color(BACKDROP), 1, FOG_FAR * SEGMENT_DEPTH);
|
||||
|
||||
// Camera sits still at the origin looking down -z; the corridor moves.
|
||||
// Same picture as a moving camera, and the modulo keeps every coordinate
|
||||
// bounded no matter how long the clip runs.
|
||||
var camera = new THREEJS.PerspectiveCamera(FOV, W / H, 0.05, 40);
|
||||
camera.position.set(0, 0, 0);
|
||||
camera.lookAt(0, 0, -1);
|
||||
|
||||
var PLANE = new THREEJS.PlaneGeometry(1, 1);
|
||||
|
||||
var COLOR_MATS = PALETTE.map(function (hex) {
|
||||
return new THREEJS.MeshBasicMaterial({ color: new THREEJS.Color(hex), fog: true });
|
||||
});
|
||||
if (COLOR_MATS.length === 0) {
|
||||
COLOR_MATS.push(new THREEJS.MeshBasicMaterial({ color: 0xffffff, fog: true }));
|
||||
}
|
||||
|
||||
// Caller-supplied content slots. Real <img> elements (so the page load
|
||||
// event waits for the decode) used directly as WebGL textures.
|
||||
var assets = document.getElementById("gt-assets");
|
||||
var IMAGES = IMAGE_SRCS.map(function (src, k) {
|
||||
var el = document.createElement("img");
|
||||
el.id = "gt-img-" + k;
|
||||
el.alt = "";
|
||||
el.src = src;
|
||||
assets.appendChild(el);
|
||||
|
||||
var tex = new THREEJS.Texture(el);
|
||||
tex.minFilter = THREEJS.LinearFilter;
|
||||
tex.magFilter = THREEJS.LinearFilter;
|
||||
tex.generateMipmaps = false;
|
||||
var rec = {
|
||||
aspect: 0,
|
||||
mat: new THREEJS.MeshBasicMaterial({ map: tex, fog: true }),
|
||||
};
|
||||
function ready() {
|
||||
if (!el.naturalWidth) return;
|
||||
rec.aspect = el.naturalWidth / el.naturalHeight;
|
||||
tex.needsUpdate = true;
|
||||
}
|
||||
el.addEventListener("load", ready);
|
||||
ready(); // a cache hit can complete before the listener is attached
|
||||
return rec;
|
||||
});
|
||||
|
||||
var edgeMat = new THREEJS.MeshBasicMaterial({
|
||||
color: new THREEJS.Color(EDGE_COLOR),
|
||||
fog: true,
|
||||
transparent: true,
|
||||
opacity: EDGE_OPACITY,
|
||||
});
|
||||
|
||||
var HW = TUNNEL_WIDTH / 2;
|
||||
var HH = TUNNEL_HEIGHT / 2;
|
||||
var cellZ = SEGMENT_DEPTH / GRID;
|
||||
var cellX = TUNNEL_WIDTH / GRID;
|
||||
var cellY = TUNNEL_HEIGHT / GRID;
|
||||
|
||||
// Four faces of a segment box. `place` maps a cell to a world position
|
||||
// inside the segment (origin at the segment's near boundary, growing
|
||||
// toward -z); `rot` orients the unit plane into that face, inward-facing.
|
||||
// `cw`/`ch` are the cell's extent in the plane's own local axes.
|
||||
var FACES = [
|
||||
{
|
||||
// floor
|
||||
rot: [-Math.PI / 2, 0, 0],
|
||||
cw: cellX,
|
||||
ch: cellZ,
|
||||
place: function (iu, iz) {
|
||||
return [-HW + (iu + 0.5) * cellX, -HH - SKIN, -(iz + 0.5) * cellZ];
|
||||
},
|
||||
},
|
||||
{
|
||||
// ceiling
|
||||
rot: [Math.PI / 2, 0, 0],
|
||||
cw: cellX,
|
||||
ch: cellZ,
|
||||
place: function (iu, iz) {
|
||||
return [-HW + (iu + 0.5) * cellX, HH + SKIN, -(iz + 0.5) * cellZ];
|
||||
},
|
||||
},
|
||||
{
|
||||
// left wall
|
||||
rot: [0, Math.PI / 2, 0],
|
||||
cw: cellZ,
|
||||
ch: cellY,
|
||||
place: function (iu, iz) {
|
||||
return [-HW - SKIN, -HH + (iu + 0.5) * cellY, -(iz + 0.5) * cellZ];
|
||||
},
|
||||
},
|
||||
{
|
||||
// right wall
|
||||
rot: [0, -Math.PI / 2, 0],
|
||||
cw: cellZ,
|
||||
ch: cellY,
|
||||
place: function (iu, iz) {
|
||||
return [HW + SKIN, -HH + (iu + 0.5) * cellY, -(iz + 0.5) * cellZ];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
var UP = new THREEJS.Vector3(0, 1, 0);
|
||||
// Edges are solid tube geometry, not lines: a cylinder thickens with
|
||||
// perspective the way a real moulding does, which is the whole reason
|
||||
// the corridor reads as built rather than drawn.
|
||||
function tube(group, ax, ay, az, bx, by, bz) {
|
||||
var dx = bx - ax,
|
||||
dy = by - ay,
|
||||
dz = bz - az;
|
||||
var len = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
if (len < 1e-6) return;
|
||||
var mesh = new THREEJS.Mesh(
|
||||
new THREEJS.CylinderGeometry(LINE_RADIUS, LINE_RADIUS, len, 6, 1),
|
||||
edgeMat,
|
||||
);
|
||||
mesh.position.set((ax + bx) / 2, (ay + by) / 2, (az + bz) / 2);
|
||||
mesh.quaternion.setFromUnitVectors(UP, new THREEJS.Vector3(dx / len, dy / len, dz / len));
|
||||
group.add(mesh);
|
||||
}
|
||||
|
||||
function buildEdges(group) {
|
||||
var D = SEGMENT_DEPTH;
|
||||
var k, j, u, z;
|
||||
// The four corner runs (and any interior wall divisions) come from the
|
||||
// side walls; floor/ceiling only contribute their interior lines, so
|
||||
// corners are drawn once and never double-blend at 50% opacity.
|
||||
for (k = 1; k < GRID; k++) {
|
||||
u = -HW + k * cellX;
|
||||
tube(group, u, -HH, 0, u, -HH, -D);
|
||||
tube(group, u, HH, 0, u, HH, -D);
|
||||
}
|
||||
for (k = 0; k <= GRID; k++) {
|
||||
u = -HH + k * cellY;
|
||||
tube(group, -HW, u, 0, -HW, u, -D);
|
||||
tube(group, HW, u, 0, HW, u, -D);
|
||||
}
|
||||
// Ring lines at each depth boundary. The far boundary belongs to the
|
||||
// next segment, so stop one short and the tiling stays seamless.
|
||||
for (j = 0; j < GRID; j++) {
|
||||
z = -j * cellZ;
|
||||
tube(group, -HW, -HH, z, HW, -HH, z);
|
||||
tube(group, -HW, HH, z, HW, HH, z);
|
||||
tube(group, -HW, -HH, z, -HW, HH, z);
|
||||
tube(group, HW, -HH, z, HW, HH, z);
|
||||
}
|
||||
}
|
||||
|
||||
var segs = [];
|
||||
for (var i = 0; i < NUM_SEGMENTS; i++) {
|
||||
var group = new THREEJS.Group();
|
||||
var panels = [];
|
||||
for (var f = 0; f < FACES.length; f++) {
|
||||
var face = FACES[f];
|
||||
for (var iz = 0; iz < GRID; iz++) {
|
||||
for (var iu = 0; iu < GRID; iu++) {
|
||||
var mesh = new THREEJS.Mesh(PLANE, COLOR_MATS[0]);
|
||||
var p = face.place(iu, iz);
|
||||
mesh.position.set(p[0], p[1], p[2]);
|
||||
mesh.rotation.set(face.rot[0], face.rot[1], face.rot[2]);
|
||||
mesh.visible = false;
|
||||
group.add(mesh);
|
||||
panels.push({ mesh: mesh, cw: face.cw * INSET, ch: face.ch * INSET });
|
||||
}
|
||||
}
|
||||
}
|
||||
buildEdges(group);
|
||||
scene.add(group);
|
||||
segs.push({ group: group, panels: panels });
|
||||
}
|
||||
|
||||
// Contain-fit so a supplied screenshot keeps its aspect inside the slot
|
||||
// instead of being squashed to the cell (wall cells are portrait).
|
||||
function fit(p, ar) {
|
||||
if (!ar) {
|
||||
p.mesh.scale.set(p.cw, p.ch, 1);
|
||||
return;
|
||||
}
|
||||
if (ar > p.cw / p.ch) p.mesh.scale.set(p.cw, p.cw / ar, 1);
|
||||
else p.mesh.scale.set(p.ch * ar, p.ch, 1);
|
||||
}
|
||||
|
||||
// Everything about frame t is computed from t alone. `d` is the distance
|
||||
// flown in segments; `a` is the ABSOLUTE segment number occupying pool
|
||||
// slot i right now, which is what content hashes and the every-other
|
||||
// rhythm key off — so a segment carries the same panels every time it is
|
||||
// looked at, no matter how the timeline is scrubbed.
|
||||
function draw(t) {
|
||||
var d = SPEED * t;
|
||||
for (var i = 0; i < NUM_SEGMENTS; i++) {
|
||||
var seg = segs[i];
|
||||
var a = i + NUM_SEGMENTS * Math.ceil((d - i - BACK) / NUM_SEGMENTS);
|
||||
seg.group.position.z = -(a - d) * SEGMENT_DEPTH;
|
||||
|
||||
var lit = a % 2 === 0;
|
||||
var panels = seg.panels;
|
||||
for (var s = 0; s < panels.length; s++) {
|
||||
var p = panels[s];
|
||||
if (!lit || hash2(a, s * 4 + 1) >= FILL) {
|
||||
p.mesh.visible = false;
|
||||
continue;
|
||||
}
|
||||
p.mesh.visible = true;
|
||||
if (IMAGES.length > 0 && hash2(a, s * 4 + 2) < IMAGE_MIX) {
|
||||
var im = IMAGES[Math.floor(hash2(a, s * 4 + 4) * IMAGES.length) % IMAGES.length];
|
||||
p.mesh.material = im.mat;
|
||||
fit(p, im.aspect);
|
||||
} else {
|
||||
var ci = Math.floor(hash2(a, s * 4 + 3) * COLOR_MATS.length) % COLOR_MATS.length;
|
||||
p.mesh.material = COLOR_MATS[ci];
|
||||
p.mesh.scale.set(p.cw, p.ch, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Repaint from a property SETTER, not from onUpdate: gsap's seek(t)
|
||||
// suppresses events by default, so an onUpdate callback silently never
|
||||
// fires on a scrub and the canvas freezes on frame 0. Tweened values are
|
||||
// always written during render, suppressed or not.
|
||||
var driver = { _t: 0 };
|
||||
Object.defineProperty(driver, "t", {
|
||||
get: function () {
|
||||
return this._t;
|
||||
},
|
||||
set: function (v) {
|
||||
this._t = v;
|
||||
draw(v);
|
||||
},
|
||||
});
|
||||
tl.to(driver, { t: DUR, duration: DUR, ease: "none" }, 0);
|
||||
window.__timelines["gallery-tunnel"] = tl;
|
||||
|
||||
draw(0);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "gallery-tunnel",
|
||||
"type": "hyperframes:block",
|
||||
"title": "Gallery Tunnel",
|
||||
"description": "An infinite corridor of panels: a square tunnel receding to a vanishing point, its floor, ceiling and walls tiled with saturated colour slabs and caller-supplied images that swell toward camera and exit past you, the far end dissolving into fog.",
|
||||
"stability": "experimental",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 12,
|
||||
"tags": ["webgl", "3d", "background", "showcase", "depth", "loop"],
|
||||
"files": [
|
||||
{
|
||||
"path": "gallery-tunnel.html",
|
||||
"target": "compositions/gallery-tunnel.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
],
|
||||
"params": [
|
||||
{
|
||||
"key": "--images",
|
||||
"label": "Image slots: comma-separated paths, tiled into the panels (e.g. assets/shot-1.png,assets/shot-2.png)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"key": "--imagemix",
|
||||
"label": "Image share of filled slots (0 = all colour, 1 = all images)",
|
||||
"type": "number",
|
||||
"default": "0.5",
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"step": 0.05
|
||||
},
|
||||
{
|
||||
"key": "--speed",
|
||||
"label": "Flight speed (segments per second)",
|
||||
"type": "number",
|
||||
"default": "3",
|
||||
"min": 0,
|
||||
"max": 8,
|
||||
"step": 0.1
|
||||
},
|
||||
{
|
||||
"key": "--palette",
|
||||
"label": "Slab palette: comma-separated hex colours",
|
||||
"type": "text",
|
||||
"default": "#FF6A00,#AB54F7,#EA3737,#0072E3,#00AA3C,#FFB200"
|
||||
},
|
||||
{
|
||||
"key": "--grid",
|
||||
"label": "Grid density: slots per wall face per axis (2 = 4 per face, 16 per segment)",
|
||||
"type": "number",
|
||||
"default": "2",
|
||||
"min": 1,
|
||||
"max": 4,
|
||||
"step": 1
|
||||
},
|
||||
{
|
||||
"key": "--fill",
|
||||
"label": "Slot fill rate",
|
||||
"type": "number",
|
||||
"default": "0.5",
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"step": 0.05
|
||||
},
|
||||
{
|
||||
"key": "--fog",
|
||||
"label": "Fog distance in segments: depth at which the corridor reaches pure backdrop",
|
||||
"type": "number",
|
||||
"default": "14.25",
|
||||
"min": 3,
|
||||
"max": 14.5,
|
||||
"step": 0.25
|
||||
},
|
||||
{
|
||||
"key": "--edgecolor",
|
||||
"label": "Edge line colour",
|
||||
"type": "color",
|
||||
"default": "#B0B0B0"
|
||||
},
|
||||
{
|
||||
"key": "--edgeopacity",
|
||||
"label": "Edge line opacity",
|
||||
"type": "number",
|
||||
"default": "0.5",
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"step": 0.05
|
||||
},
|
||||
{
|
||||
"key": "--backdrop",
|
||||
"label": "Backdrop and fog colour",
|
||||
"type": "color",
|
||||
"default": "#000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Halftone Field</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
#hlf-root {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#hlf-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #000000;
|
||||
}
|
||||
#hlf-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="hlf-root"
|
||||
data-composition-id="halftone-field"
|
||||
data-root="true"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-start="0"
|
||||
data-duration="10"
|
||||
data-composition-variables='[
|
||||
{"id":"frequency","type":"number","label":"Field frequency","default":2,"min":1,"max":10,"step":0.1},
|
||||
{"id":"speed","type":"number","label":"Flow speed","default":5,"min":0,"max":10,"step":0.1},
|
||||
{"id":"cellSize","type":"number","label":"Cell size","default":54,"min":1,"max":100,"step":1},
|
||||
{"id":"gamma","type":"number","label":"Gamma (midtone crush)","default":12,"min":1,"max":20,"step":0.1},
|
||||
{"id":"paletteBias","type":"number","label":"Palette bias","default":2,"min":0,"max":20,"step":0.5},
|
||||
{"id":"palette1","type":"color","label":"Palette stop 1 (lows)","default":"#00AAFF"},
|
||||
{"id":"palette2","type":"color","label":"Palette stop 2","default":"#C2FF67"},
|
||||
{"id":"palette3","type":"color","label":"Palette stop 3","default":"#6600FF"},
|
||||
{"id":"palette4","type":"color","label":"Palette stop 4 (crests)","default":"#000000"},
|
||||
{"id":"background","type":"color","label":"Background","default":"#000000"},
|
||||
{"id":"quantize","type":"boolean","label":"Quantise to 30fps (stepped)","default":false}
|
||||
]'
|
||||
>
|
||||
<div id="hlf-backdrop"></div>
|
||||
<canvas id="hlf-canvas" width="1920" height="1080"></canvas>
|
||||
|
||||
<!-- Driver clip: gives HyperFrames a timed element to own on track 0. -->
|
||||
<div
|
||||
id="hlf-drv"
|
||||
class="clip"
|
||||
data-start="0"
|
||||
data-duration="10"
|
||||
data-track-index="0"
|
||||
style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var DUR = 10;
|
||||
var W = 1920;
|
||||
var H = 1080;
|
||||
|
||||
var V = (window.__hyperframes && window.__hyperframes.getVariables()) || {};
|
||||
var CS = getComputedStyle(document.getElementById("hlf-root"));
|
||||
|
||||
// The runtime defines every declared variable as `--<id>` on the root,
|
||||
// so a host stylesheet can override one there too. Read the custom
|
||||
// property first, fall back to the declared value when it is unset.
|
||||
function raw(id) {
|
||||
var css = CS.getPropertyValue("--" + id.toLowerCase()).trim();
|
||||
return css !== "" ? css : V[id];
|
||||
}
|
||||
function num(id, fallback) {
|
||||
var n = parseFloat(raw(id));
|
||||
return isFinite(n) ? n : fallback;
|
||||
}
|
||||
function bool(id) {
|
||||
var v = raw(id);
|
||||
return v === true || /^(true|1|on|yes)$/i.test(String(v));
|
||||
}
|
||||
// #RGB / #RRGGBB -> [r, g, b] in 0..1. Anything else falls back.
|
||||
function rgb(id, fallback) {
|
||||
var s = String(raw(id) || "").trim();
|
||||
var m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s);
|
||||
if (!m) s = fallback;
|
||||
else s = "#" + m[1];
|
||||
var hex = s.slice(1);
|
||||
if (hex.length === 3) {
|
||||
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||
}
|
||||
var n = parseInt(hex, 16);
|
||||
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
|
||||
}
|
||||
// The reference exposes 1-10 / 1-100 / 1-20 dials and maps them onto
|
||||
// the ranges the shader actually wants.
|
||||
function mapLinear(x, a1, a2, b1, b2) {
|
||||
return b1 + ((x - a1) * (b2 - b1)) / (a2 - a1);
|
||||
}
|
||||
|
||||
var FREQ = mapLinear(num("frequency", 2), 1, 10, 0.3, 6);
|
||||
var SPEED = num("speed", 5) * 0.05;
|
||||
var CELL = mapLinear(num("cellSize", 54), 1, 100, 6, 60);
|
||||
var GAMMA = mapLinear(num("gamma", 12), 1, 20, 0.5, 8);
|
||||
var BIAS = num("paletteBias", 2) * 0.05;
|
||||
var PAL = [
|
||||
rgb("palette1", "#00AAFF"),
|
||||
rgb("palette2", "#C2FF67"),
|
||||
rgb("palette3", "#6600FF"),
|
||||
rgb("palette4", "#000000"),
|
||||
];
|
||||
var BG = rgb("background", "#000000");
|
||||
var QUANTIZE = bool("quantize");
|
||||
|
||||
document.getElementById("hlf-backdrop").style.background =
|
||||
"rgb(" +
|
||||
BG.map(function (c) {
|
||||
return Math.round(c * 255);
|
||||
}).join(",") +
|
||||
")";
|
||||
|
||||
var canvas = document.getElementById("hlf-canvas");
|
||||
var gl =
|
||||
canvas.getContext("webgl", {
|
||||
alpha: false,
|
||||
antialias: false,
|
||||
depth: false,
|
||||
stencil: false,
|
||||
preserveDrawingBuffer: true,
|
||||
powerPreference: "high-performance",
|
||||
}) ||
|
||||
canvas.getContext("experimental-webgl", {
|
||||
alpha: false,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
|
||||
var VERT = "attribute vec2 aPos;\nvoid main() { gl_Position = vec4(aPos, 0.0, 1.0); }";
|
||||
|
||||
// Single pass. The reference renders the colour field into a target and
|
||||
// samples it at each cell centre, but the field is analytic, so
|
||||
// evaluating it at the cell centre here is the same value without the
|
||||
// round trip through a texture.
|
||||
var FRAG = `
|
||||
precision highp float;
|
||||
uniform vec2 uRes;
|
||||
uniform float uTime;
|
||||
uniform float uFreq;
|
||||
uniform float uSpeed;
|
||||
uniform float uCell;
|
||||
uniform float uGamma;
|
||||
uniform float uBias;
|
||||
uniform vec3 uPal0;
|
||||
uniform vec3 uPal1;
|
||||
uniform vec3 uPal2;
|
||||
uniform vec3 uPal3;
|
||||
uniform vec3 uBg;
|
||||
|
||||
// 3D simplex noise — Ashima Arts / Stefan Gustavson (MIT).
|
||||
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
|
||||
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
|
||||
|
||||
float snoise(vec3 v) {
|
||||
const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
|
||||
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
|
||||
|
||||
vec3 i = floor(v + dot(v, C.yyy));
|
||||
vec3 x0 = v - i + dot(i, C.xxx);
|
||||
|
||||
vec3 g = step(x0.yzx, x0.xyz);
|
||||
vec3 l = 1.0 - g;
|
||||
vec3 i1 = min(g.xyz, l.zxy);
|
||||
vec3 i2 = max(g.xyz, l.zxy);
|
||||
|
||||
vec3 x1 = x0 - i1 + C.xxx;
|
||||
vec3 x2 = x0 - i2 + C.yyy;
|
||||
vec3 x3 = x0 - D.yyy;
|
||||
|
||||
i = mod289(i);
|
||||
vec4 p = permute(permute(permute(
|
||||
i.z + vec4(0.0, i1.z, i2.z, 1.0)) +
|
||||
i.y + vec4(0.0, i1.y, i2.y, 1.0)) +
|
||||
i.x + vec4(0.0, i1.x, i2.x, 1.0));
|
||||
|
||||
float n_ = 0.142857142857;
|
||||
vec3 ns = n_ * D.wyz - D.xzx;
|
||||
|
||||
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
|
||||
|
||||
vec4 x_ = floor(j * ns.z);
|
||||
vec4 y_ = floor(j - 7.0 * x_);
|
||||
|
||||
vec4 x = x_ * ns.x + ns.yyyy;
|
||||
vec4 y = y_ * ns.x + ns.yyyy;
|
||||
vec4 h = 1.0 - abs(x) - abs(y);
|
||||
|
||||
vec4 b0 = vec4(x.xy, y.xy);
|
||||
vec4 b1 = vec4(x.zw, y.zw);
|
||||
|
||||
vec4 s0 = floor(b0) * 2.0 + 1.0;
|
||||
vec4 s1 = floor(b1) * 2.0 + 1.0;
|
||||
vec4 sh = -step(h, vec4(0.0));
|
||||
|
||||
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
||||
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
||||
|
||||
vec3 p0 = vec3(a0.xy, h.x);
|
||||
vec3 p1 = vec3(a0.zw, h.y);
|
||||
vec3 p2 = vec3(a1.xy, h.z);
|
||||
vec3 p3 = vec3(a1.zw, h.w);
|
||||
|
||||
vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
|
||||
p0 *= norm.x;
|
||||
p1 *= norm.y;
|
||||
p2 *= norm.z;
|
||||
p3 *= norm.w;
|
||||
|
||||
vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
|
||||
m = m * m;
|
||||
return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
|
||||
}
|
||||
|
||||
// Full-saturation, full-value HSV: only the hue ramp survives.
|
||||
vec3 hue2rgb(float h) {
|
||||
return clamp(abs(mod(h * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// Piecewise-linear ramp across the four palette stops.
|
||||
vec3 ramp(float v) {
|
||||
float x = clamp(v, 0.0, 1.0) * 3.0;
|
||||
vec3 c = mix(uPal0, uPal1, clamp(x, 0.0, 1.0));
|
||||
c = mix(c, uPal2, clamp(x - 1.0, 0.0, 1.0));
|
||||
c = mix(c, uPal3, clamp(x - 2.0, 0.0, 1.0));
|
||||
return c;
|
||||
}
|
||||
|
||||
void main() {
|
||||
// One sample per cell, taken at the cell centre.
|
||||
vec2 cell = floor(gl_FragCoord.xy / uCell);
|
||||
vec2 centre = (cell + 0.5) * uCell;
|
||||
|
||||
vec2 st = centre / uRes;
|
||||
st.x *= uRes.x / uRes.y;
|
||||
|
||||
float hue = abs(snoise(vec3(st * uFreq, uTime * uSpeed)));
|
||||
vec3 field = hue2rgb(hue);
|
||||
|
||||
// The gamma is the whole look: it crushes the midtones so most of the frame
|
||||
// stays background and only the crests of the field open up.
|
||||
float gray = pow(dot(field, vec3(0.2126, 0.7152, 0.0722)), uGamma);
|
||||
float v = clamp(gray + uBias, 0.0, 1.0);
|
||||
|
||||
float radius = v * 0.5 * uCell;
|
||||
float d = length(gl_FragCoord.xy - centre);
|
||||
float mask = 1.0 - smoothstep(radius - 1.0, radius + 1.0, d);
|
||||
|
||||
gl_FragColor = vec4(mix(uBg, ramp(v), mask), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
var uni = {};
|
||||
var ready = false;
|
||||
|
||||
function compile(type, src) {
|
||||
var sh = gl.createShader(type);
|
||||
gl.shaderSource(sh, src);
|
||||
gl.compileShader(sh);
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
||||
throw new Error("halftone-field shader: " + gl.getShaderInfoLog(sh));
|
||||
}
|
||||
return sh;
|
||||
}
|
||||
|
||||
if (gl) {
|
||||
var prog = gl.createProgram();
|
||||
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
|
||||
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
|
||||
gl.linkProgram(prog);
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
||||
throw new Error("halftone-field link: " + gl.getProgramInfoLog(prog));
|
||||
}
|
||||
gl.useProgram(prog);
|
||||
|
||||
var buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
||||
var loc = gl.getAttribLocation(prog, "aPos");
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
[
|
||||
"uRes",
|
||||
"uTime",
|
||||
"uFreq",
|
||||
"uSpeed",
|
||||
"uCell",
|
||||
"uGamma",
|
||||
"uBias",
|
||||
"uPal0",
|
||||
"uPal1",
|
||||
"uPal2",
|
||||
"uPal3",
|
||||
"uBg",
|
||||
].forEach(function (n) {
|
||||
uni[n] = gl.getUniformLocation(prog, n);
|
||||
});
|
||||
|
||||
gl.viewport(0, 0, W, H);
|
||||
gl.uniform2f(uni.uRes, W, H);
|
||||
gl.uniform1f(uni.uFreq, FREQ);
|
||||
gl.uniform1f(uni.uSpeed, SPEED);
|
||||
gl.uniform1f(uni.uCell, CELL);
|
||||
gl.uniform1f(uni.uGamma, GAMMA);
|
||||
gl.uniform1f(uni.uBias, BIAS);
|
||||
gl.uniform3f(uni.uPal0, PAL[0][0], PAL[0][1], PAL[0][2]);
|
||||
gl.uniform3f(uni.uPal1, PAL[1][0], PAL[1][1], PAL[1][2]);
|
||||
gl.uniform3f(uni.uPal2, PAL[2][0], PAL[2][1], PAL[2][2]);
|
||||
gl.uniform3f(uni.uPal3, PAL[3][0], PAL[3][1], PAL[3][2]);
|
||||
gl.uniform3f(uni.uBg, BG[0], BG[1], BG[2]);
|
||||
ready = true;
|
||||
}
|
||||
|
||||
// Every frame is computed from t alone — the field is a closed-form
|
||||
// function of position and time. No accumulator, no clock, no PRNG.
|
||||
// `quantize` snaps t to a 30fps grid for the stepped LED-wall look.
|
||||
function draw(t) {
|
||||
if (!ready) return;
|
||||
gl.uniform1f(uni.uTime, QUANTIZE ? Math.floor(t * 30) / 30 : t);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
gl.flush();
|
||||
}
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
|
||||
// The canvas is repainted from a property SETTER, not from onUpdate:
|
||||
// gsap's seek(t) suppresses events by default, so an onUpdate callback
|
||||
// silently never fires on a scrub and the canvas freezes on frame 0.
|
||||
// Tweened values are always written during render, suppressed or not,
|
||||
// so this fires on every seek — and hands us the frame time directly.
|
||||
var driver = { _t: 0 };
|
||||
Object.defineProperty(driver, "t", {
|
||||
get: function () {
|
||||
return this._t;
|
||||
},
|
||||
set: function (v) {
|
||||
this._t = v;
|
||||
draw(v);
|
||||
},
|
||||
});
|
||||
tl.to(driver, { t: DUR, duration: DUR, ease: "none" }, 0);
|
||||
window.__timelines["halftone-field"] = tl;
|
||||
|
||||
draw(0);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "halftone-field",
|
||||
"type": "hyperframes:block",
|
||||
"title": "Halftone Field",
|
||||
"description": "A full-bleed halftone light field: a grid of dots whose size and colour track a slow flowing simplex-noise field, so broad bands of light sweep across a mostly-black frame like a giant LED wall showing a plasma. Built to run under type.",
|
||||
"stability": "experimental",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 10,
|
||||
"tags": ["webgl", "shader", "background", "texture", "halftone"],
|
||||
"files": [
|
||||
{
|
||||
"path": "halftone-field.html",
|
||||
"target": "compositions/halftone-field.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
],
|
||||
"params": [
|
||||
{
|
||||
"key": "--frequency",
|
||||
"label": "Field frequency (1 = broad swells, 10 = fine mottle)",
|
||||
"type": "number",
|
||||
"default": "2",
|
||||
"min": 1,
|
||||
"max": 10,
|
||||
"step": 0.1
|
||||
},
|
||||
{
|
||||
"key": "--speed",
|
||||
"label": "Flow speed (0 freezes the field)",
|
||||
"type": "number",
|
||||
"default": "5",
|
||||
"min": 0,
|
||||
"max": 10,
|
||||
"step": 0.1
|
||||
},
|
||||
{
|
||||
"key": "--cellsize",
|
||||
"label": "Cell size (dot pitch, 1 = 6px, 100 = 60px)",
|
||||
"type": "number",
|
||||
"default": "54",
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"step": 1
|
||||
},
|
||||
{
|
||||
"key": "--gamma",
|
||||
"label": "Gamma: midtone crush, the whole look. Low values flatten it to an even grey dot field",
|
||||
"type": "number",
|
||||
"default": "12",
|
||||
"min": 1,
|
||||
"max": 20,
|
||||
"step": 0.1
|
||||
},
|
||||
{
|
||||
"key": "--palettebias",
|
||||
"label": "Palette bias: floor under every dot's size and palette position. 0 lets the darks go fully black",
|
||||
"type": "number",
|
||||
"default": "2",
|
||||
"min": 0,
|
||||
"max": 20,
|
||||
"step": 0.5
|
||||
},
|
||||
{
|
||||
"key": "--palette1",
|
||||
"label": "Palette stop 1 (lows)",
|
||||
"type": "color",
|
||||
"default": "#00AAFF"
|
||||
},
|
||||
{
|
||||
"key": "--palette2",
|
||||
"label": "Palette stop 2",
|
||||
"type": "color",
|
||||
"default": "#C2FF67"
|
||||
},
|
||||
{
|
||||
"key": "--palette3",
|
||||
"label": "Palette stop 3",
|
||||
"type": "color",
|
||||
"default": "#6600FF"
|
||||
},
|
||||
{
|
||||
"key": "--palette4",
|
||||
"label": "Palette stop 4 (crests)",
|
||||
"type": "color",
|
||||
"default": "#000000"
|
||||
},
|
||||
{
|
||||
"key": "--background",
|
||||
"label": "Background",
|
||||
"type": "color",
|
||||
"default": "#000000"
|
||||
},
|
||||
{
|
||||
"key": "--quantize",
|
||||
"label": "Quantise to 30fps for a stepped LED-wall look",
|
||||
"type": "select",
|
||||
"default": "false",
|
||||
"options": [
|
||||
{ "label": "Smooth", "value": "false" },
|
||||
{ "label": "Stepped 30fps", "value": "true" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Recursive Project Authors (https://github.com/arrowtype/recursive)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "weight-wave",
|
||||
"type": "hyperframes:block",
|
||||
"title": "Weight Wave",
|
||||
"description": "A crest of thickness travels along a headline: each character swells from light to black and leans forward as the wave passes, driven by the wght and slnt variable-font axes rather than by transforms.",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 6,
|
||||
"tags": ["typography", "kinetic-type", "title", "variable-font", "text"],
|
||||
"license": "OFL-1.1",
|
||||
"variables": [
|
||||
{
|
||||
"id": "text",
|
||||
"type": "string",
|
||||
"label": "Headline",
|
||||
"default": "PRESSURE MOVES THROUGH TYPE",
|
||||
"maxLength": 64
|
||||
},
|
||||
{
|
||||
"id": "color",
|
||||
"type": "color",
|
||||
"label": "Text color",
|
||||
"default": "#f5f5f7"
|
||||
},
|
||||
{
|
||||
"id": "background",
|
||||
"type": "color",
|
||||
"label": "Background",
|
||||
"default": "#0b0b0d"
|
||||
},
|
||||
{
|
||||
"id": "weightFrom",
|
||||
"type": "number",
|
||||
"label": "Weight (rest)",
|
||||
"default": 400,
|
||||
"min": 300,
|
||||
"max": 1000,
|
||||
"step": 10
|
||||
},
|
||||
{
|
||||
"id": "weightTo",
|
||||
"type": "number",
|
||||
"label": "Weight (crest)",
|
||||
"default": 1000,
|
||||
"min": 300,
|
||||
"max": 1000,
|
||||
"step": 10
|
||||
},
|
||||
{
|
||||
"id": "crestWidth",
|
||||
"type": "number",
|
||||
"label": "Crest width",
|
||||
"default": 4,
|
||||
"min": 1,
|
||||
"max": 24,
|
||||
"step": 0.5,
|
||||
"unit": "chars"
|
||||
},
|
||||
{
|
||||
"id": "sweepSpeed",
|
||||
"type": "number",
|
||||
"label": "Sweep speed",
|
||||
"default": 0.5,
|
||||
"min": 0.05,
|
||||
"max": 3,
|
||||
"step": 0.05,
|
||||
"unit": "passes/s"
|
||||
},
|
||||
{
|
||||
"id": "slant",
|
||||
"type": "number",
|
||||
"label": "Slant at crest",
|
||||
"default": -12,
|
||||
"min": -15,
|
||||
"max": 0,
|
||||
"step": 0.5,
|
||||
"unit": "deg"
|
||||
},
|
||||
{
|
||||
"id": "direction",
|
||||
"type": "enum",
|
||||
"label": "Direction",
|
||||
"default": "ltr",
|
||||
"options": [
|
||||
{
|
||||
"value": "ltr",
|
||||
"label": "Left to right"
|
||||
},
|
||||
{
|
||||
"value": "rtl",
|
||||
"label": "Right to left"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "weight-wave.html",
|
||||
"target": "compositions/weight-wave.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "assets/fonts/Recursive-wght-slnt-latin.woff2",
|
||||
"target": "compositions/assets/fonts/Recursive-wght-slnt-latin.woff2",
|
||||
"type": "hyperframes:asset"
|
||||
},
|
||||
{
|
||||
"path": "assets/fonts/OFL.txt",
|
||||
"target": "compositions/assets/fonts/OFL.txt",
|
||||
"type": "hyperframes:asset"
|
||||
}
|
||||
]
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Weight Wave</title>
|
||||
<style>
|
||||
/*
|
||||
Real variable font, bundled locally — a CDN fetch would be a render-time
|
||||
network dependency, and a STATIC font would silently render every
|
||||
character at one weight (the whole effect vanishes while nothing errors).
|
||||
Subset of Recursive (SIL OFL 1.1) with every axis pinned except the two
|
||||
this block drives. Ranges below are the font's REAL fvar records, read
|
||||
out of the binary: wght 300..1000 (not 100..900) and slnt -15..0. The
|
||||
pinned three are MONO 0 (proportional sans, not the monospace face),
|
||||
CASL 0 (linear/neutral, not casual) and CRSV 0 (roman letterforms —
|
||||
Recursive's 0.5 default swaps in cursive singlestorey forms as soon as
|
||||
slnt goes negative, which would pop mid-wave instead of interpolating).
|
||||
*/
|
||||
@font-face {
|
||||
font-family: "Recursive Wave";
|
||||
font-style: normal;
|
||||
font-weight: 300 1000;
|
||||
font-display: block;
|
||||
src: url("assets/fonts/Recursive-wght-slnt-latin.woff2") format("woff2");
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
}
|
||||
|
||||
#ww-root {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Full-bleed fill lives on a CHILD: the producer can drop a background
|
||||
painted on the composition root itself (frame renders black). */
|
||||
#ww-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--background, #0b0b0d);
|
||||
}
|
||||
|
||||
#ww-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#ww-line {
|
||||
display: block;
|
||||
width: 1760px;
|
||||
text-align: center;
|
||||
white-space: pre;
|
||||
font-family: "Recursive Wave", sans-serif;
|
||||
font-size: 132px;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.005em;
|
||||
color: var(--color, #f5f5f7);
|
||||
/* Default instance: the resting end of the wave. If the variable font
|
||||
fails to load, every glyph stays here and the probe below shouts. */
|
||||
font-variation-settings:
|
||||
"wght" 400,
|
||||
"slnt" 0;
|
||||
}
|
||||
|
||||
/* Per-character axis stamping target: no transform, no opacity — the
|
||||
letterforms themselves change. */
|
||||
.ww-ch {
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="ww-root"
|
||||
data-composition-id="weight-wave"
|
||||
data-start="0"
|
||||
data-duration="6"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-composition-variables='[
|
||||
{"id":"text","type":"string","label":"Headline","default":"PRESSURE MOVES THROUGH TYPE","maxLength":64},
|
||||
{"id":"color","type":"color","label":"Text color","default":"#f5f5f7"},
|
||||
{"id":"background","type":"color","label":"Background","default":"#0b0b0d"},
|
||||
{"id":"weightFrom","type":"number","label":"Weight (rest)","default":400,"min":300,"max":1000,"step":10},
|
||||
{"id":"weightTo","type":"number","label":"Weight (crest)","default":1000,"min":300,"max":1000,"step":10},
|
||||
{"id":"crestWidth","type":"number","label":"Crest width","default":4,"min":1,"max":24,"step":0.5,"unit":"chars"},
|
||||
{"id":"sweepSpeed","type":"number","label":"Sweep speed","default":0.5,"min":0.05,"max":3,"step":0.05,"unit":"passes/s"},
|
||||
{"id":"slant","type":"number","label":"Slant at crest","default":-12,"min":-15,"max":0,"step":0.5,"unit":"deg"},
|
||||
{"id":"direction","type":"enum","label":"Direction","default":"ltr","options":[{"value":"ltr","label":"Left to right"},{"value":"rtl","label":"Right to left"}]}
|
||||
]'
|
||||
>
|
||||
<div id="ww-bg"></div>
|
||||
|
||||
<div id="ww-stage" class="clip" data-start="0" data-duration="6" data-track-index="0">
|
||||
<div id="ww-line"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
window.__timelines = window.__timelines || {};
|
||||
|
||||
var FONT_FAMILY = '"Recursive Wave", sans-serif';
|
||||
/* The bundled font's real fvar bounds — declared once here, and
|
||||
mirrored in the variable min/max above. A slider must not offer a
|
||||
weight the font cannot render. */
|
||||
var WGHT_MIN = 300;
|
||||
var WGHT_MAX = 1000;
|
||||
var SLNT_MIN = -15;
|
||||
var LINE_MAX_WIDTH = 1760;
|
||||
var BASE_FONT_SIZE = 132;
|
||||
var MIN_FONT_SIZE = 40;
|
||||
|
||||
var root = document.getElementById("ww-root");
|
||||
var line = document.getElementById("ww-line");
|
||||
var duration = Number(root.getAttribute("data-duration")) || 6;
|
||||
|
||||
var vars =
|
||||
window.__hyperframes && window.__hyperframes.getVariables
|
||||
? window.__hyperframes.getVariables()
|
||||
: {};
|
||||
|
||||
function num(value, fallback, min, max) {
|
||||
var n = Number(value);
|
||||
if (!Number.isFinite(n)) n = fallback;
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
var TEXT =
|
||||
typeof vars.text === "string" && vars.text.length
|
||||
? vars.text
|
||||
: "PRESSURE MOVES THROUGH TYPE";
|
||||
var W_FROM = num(vars.weightFrom, 400, WGHT_MIN, WGHT_MAX);
|
||||
var W_TO = num(vars.weightTo, 1000, WGHT_MIN, WGHT_MAX);
|
||||
var CREST = num(vars.crestWidth, 4, 1, 24);
|
||||
var SPEED = num(vars.sweepSpeed, 0.5, 0.05, 3);
|
||||
var SLANT = num(vars.slant, -12, SLNT_MIN, 0);
|
||||
var RTL = vars.direction === "rtl";
|
||||
|
||||
/* ── Build one span per character ─────────────────────────────────
|
||||
Spaces get a span too, so the crest travels at a constant rate in
|
||||
index space and the visual gap between words is honoured. */
|
||||
var chars = Array.prototype.slice.call(TEXT);
|
||||
var spans = chars.map(function (ch) {
|
||||
var el = document.createElement("span");
|
||||
el.className = "ww-ch";
|
||||
el.textContent = ch;
|
||||
line.appendChild(el);
|
||||
return el;
|
||||
});
|
||||
var N = spans.length;
|
||||
|
||||
/* Fit at the HEAVIEST instance so the line never overflows mid-sweep;
|
||||
the crest widens glyphs as it passes. */
|
||||
var fitCanvas = document.createElement("canvas").getContext("2d");
|
||||
var heaviest = Math.max(W_FROM, W_TO);
|
||||
var fontSize = BASE_FONT_SIZE;
|
||||
while (fontSize > MIN_FONT_SIZE) {
|
||||
fitCanvas.font = heaviest + " " + fontSize + "px " + FONT_FAMILY;
|
||||
if (fitCanvas.measureText(TEXT).width <= LINE_MAX_WIDTH) break;
|
||||
fontSize -= 2;
|
||||
}
|
||||
line.style.fontSize = fontSize + "px";
|
||||
|
||||
/* ── The wave ─────────────────────────────────────────────────────
|
||||
Closed form, no accumulators: every frame's state is a pure
|
||||
function of the timeline position p, so a direct seek to frame N
|
||||
and playing through to frame N produce identical pixels.
|
||||
|
||||
crest(p) = -CREST + wrap(p * duration * SPEED) * (N - 1 + 2*CREST)
|
||||
f_i = clamp(0, 1, 1 - |i - crest| / CREST)
|
||||
wght_i = round(lerp(W_FROM, W_TO, f_i))
|
||||
slnt_i = lerp(0, SLANT, f_i)
|
||||
|
||||
Per-character stagger is not a separate mechanism — it falls out of
|
||||
the crest's distance to each index. */
|
||||
var state = { p: 0 };
|
||||
var travel = N - 1 + 2 * CREST;
|
||||
|
||||
function paint() {
|
||||
var pass = state.p * duration * SPEED;
|
||||
var phase = pass - Math.floor(pass); // wraps cleanly: f_i == 0 at both ends
|
||||
var crest = -CREST + phase * travel;
|
||||
if (RTL) crest = N - 1 - crest;
|
||||
for (var i = 0; i < N; i++) {
|
||||
var f = 1 - Math.abs(i - crest) / CREST;
|
||||
if (f < 0) f = 0;
|
||||
else if (f > 1) f = 1;
|
||||
var wght = Math.round(W_FROM + (W_TO - W_FROM) * f);
|
||||
var slnt = SLANT * f;
|
||||
spans[i].style.fontVariationSettings =
|
||||
'"wght" ' + wght + ', "slnt" ' + slnt.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to(state, { p: 1, duration: duration, ease: "none", onUpdate: paint }, 0);
|
||||
paint();
|
||||
window.__timelines["weight-wave"] = tl;
|
||||
|
||||
/* ── The trap, made loud ──────────────────────────────────────────
|
||||
A static font (or a failed load) renders every character at one
|
||||
weight: the effect is invisible and nothing throws.
|
||||
|
||||
Measuring advance width does NOT detect that here. Recursive is
|
||||
DUPLEXED — "HAMBURGEFONSTIV" measures exactly 925px at wght 300 and
|
||||
at wght 1000, and identically at slnt 0 and -15. (Nice for layout:
|
||||
the headline never reflows as the crest passes. Fatal for a
|
||||
width-delta probe, which reads every weight as identical and cries
|
||||
wolf on a perfectly healthy font.)
|
||||
|
||||
So rasterize and count ink, which cannot be duplexed away, AND ask
|
||||
the font-loading API whether the family is actually there. Both are
|
||||
needed: ink alone gives a false PASS when the family is missing,
|
||||
because the fallback varies its ink with weight too. */
|
||||
function probeAxes() {
|
||||
var family = "Recursive Wave";
|
||||
var loaded = !!(document.fonts && document.fonts.check('100px "' + family + '"'));
|
||||
var c = document.createElement("canvas");
|
||||
c.width = 1000;
|
||||
c.height = 160;
|
||||
var ctx = c.getContext("2d", { willReadFrequently: true });
|
||||
function ink(weight) {
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
ctx.font = weight + ' 100px "' + family + '"';
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillText("HAMBURGEFONSTIV", 10, 120);
|
||||
var data = ctx.getImageData(0, 0, c.width, c.height).data;
|
||||
var n = 0;
|
||||
for (var i = 3; i < data.length; i += 4) if (data[i] > 8) n++;
|
||||
return n;
|
||||
}
|
||||
var light = ink(WGHT_MIN);
|
||||
var black = ink(WGHT_MAX);
|
||||
// Only wght is probed: it is the axis whose absence is measurable
|
||||
// from JS, and both axes ship in the same file — if wght is live,
|
||||
// the variable font loaded. slnt is verified visually.
|
||||
return {
|
||||
fontLoaded: loaded,
|
||||
wghtLive: black > light * 1.2,
|
||||
inkAtMinWeight: light,
|
||||
inkAtMaxWeight: black,
|
||||
};
|
||||
}
|
||||
|
||||
function publishProbe() {
|
||||
var status = probeAxes();
|
||||
status.ok = status.fontLoaded && status.wghtLive;
|
||||
window.__WW_FONT_STATUS = status;
|
||||
root.setAttribute("data-ww-font", status.ok ? "ok" : "missing-axes");
|
||||
if (!status.ok) {
|
||||
console.error(
|
||||
"weight-wave: the variable font is not usable (loaded=" +
|
||||
status.fontLoaded +
|
||||
", ink at wght " +
|
||||
WGHT_MIN +
|
||||
"=" +
|
||||
status.inkAtMinWeight +
|
||||
" vs " +
|
||||
WGHT_MAX +
|
||||
"=" +
|
||||
status.inkAtMaxWeight +
|
||||
"). assets/fonts/Recursive-wght-slnt-latin.woff2 did not load, or was " +
|
||||
"replaced with a static font — every character will render at one weight.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
document.fonts.ready.then(publishProbe);
|
||||
} else {
|
||||
publishProbe();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+489
-473
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user