Files
hyperframes/packages/engine/src/utils/shaderTransitions.ts
T
Vance IngallsandClaude Opus 4.6 99a903be2f feat(hdr): layered HDR compositing, shader transitions, and HDR image support (#268)
* feat(hdr): shader transitions, --hdr flag, and SDR rendering fixes

- 15 GLSL→TypeScript shader transitions on rgb48le buffers
- Dual-scene compositing with scene detection via window.__hf.transitions
- --hdr flag gates ffprobe probing (zero overhead on SDR compositions)
- Cross-transfer conversion (PQ↔HLG) via OOTF-corrected composite LUT
- Buffer.from() copy in writeFrame() fixes streaming encoder race condition
- SDR rendering fixes (three stacked bugs)
- Object.assign fix for window.__hf preservation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: tighten shader smoke thresholds + assert .scene contract

- Tighten the all-transitions smoke test thresholds: at progress=0 we now
  require the center pixel R-channel > 35000 (was > 25000) and at
  progress=1 < 15000 (was < 25000). The old midpoint of 25000 sat exactly
  halfway between the test from-pixel (40000) and to-pixel (10000), so a
  half-blended transition would silently pass.
- Add a runtime assertion in HyperShader.init() that every scene id
  resolves to a DOM element with the .scene class. Without this, missing
  ids silently no-op when textures + querySelectorAll(.scene) run later.

Addresses deferred review feedback from PR #268.

* fix(hdr): restore VIRTUAL_TIME_SHIM and applyRenderModeHints in renderOrchestrator

Commit c6b4619c ("feat(hdr): shader transitions, --hdr flag, and SDR
rendering fixes") accidentally removed two pieces of the deterministic
rendering pipeline:

1. The `VIRTUAL_TIME_SHIM` injected via `createFileServer.preHeadScripts`,
   which freezes `Date.now()` and `requestAnimationFrame` so RAF-driven
   animations advance only when `window.__hf.seek(t)` is called.
2. The `applyRenderModeHints` function and its post-`compileForRender`
   call site, which auto-forces screenshot capture mode for compositions
   the compiler flagged as needing it (RAF, iframes, etc.).

Without (1), RAF animations advanced by wall-clock between the main-loop
seek and the per-DOM-layer seek inside `compositeToBuffer`, producing the
sawtooth PSNR pattern on `raf-ball-render-compat` (high PSNR at integer
seconds, ~24 dB everywhere else). Without (2), `iframe-render-compat`
lost its automatic fallback to screenshot mode and the child-document
motion stopped being captured.

Both helpers are still produced by `htmlCompiler` and exercised by
`renderOrchestrator.test.ts` — the orchestrator just stopped calling
them. Restored:

- Re-import `VIRTUAL_TIME_SHIM` from `./fileServer.js`
- Pass `preHeadScripts: [VIRTUAL_TIME_SHIM]` to both `createFileServer`
  call sites (probe + main render)
- Re-add `applyRenderModeHints` (matching the test expectations) and
  call it immediately after `compileForRender`
- Persist `renderModeHints` in `summary.json` and the
  "Compiled composition metadata" log line

Fixes the `iframe-render-compat` and `raf-ball-render-compat` regression
failures on `feat/hdr-layered-compositing`.

Made-with: Cursor

* test(engine): expand sampleRgb48le coverage + audit Uint16Array alignment

Adds:
- 8 new sampleRgb48le bilinear-interpolation tests covering boundary
  pixels, sub-pixel weights, edge clamping, and odd-byte-offset Buffers.
- uint16-alignment-audit.test.ts documenting the alignment requirement
  for Uint16Array views over Buffer slices vs. readUInt16LE/writeUInt16LE.

Background: ~105 hot-loop sites in shader transitions still use
readUInt16LE/writeUInt16LE. Switching to Uint16Array views would cut
overhead but requires guaranteed even byteOffsets — these tests document
the contract before any future refactor lands.

* fix(engine,producer): mask DOM layers during HDR layered compositing

The HDR layered compositor blits z-ordered layers over a shared canvas. DOM
layers used a full-page screenshot from `captureAlphaPng`, which captures
*every* painted pixel on the page — root background, sibling-scene content,
overlay UI elements that aren't part of the current layer. Those opaque
pixels were then blitted over the canvas, overwriting any HDR content
composited beneath in earlier layers.

The previous workaround toggled `display:none` on hide ids via
`hideVideoElements`/`showVideoElements`. That correctly hid native videos
but did nothing about the root composition's background or about overlay
elements that the layer grouping considered part of a different layer.

This commit replaces the workaround with a precise CSS mask installed
before each DOM screenshot:

1. `applyDomLayerMask` injects a stylesheet that hides every `body *` and
   re-shows the layer's elements (and their descendants and their injected
   `__render_frame_*` siblings) with `visibility: visible !important`. CSS
   visibility is *not* multiplicative through descendants — a child with
   `visibility: visible` overrides an ancestor's `visibility: hidden`, so
   deeply nested layer content still paints even though every intermediate
   ancestor is hidden by the mass-hide rule.
2. Non-layer data-start ids are inline-hidden with
   `visibility: hidden !important`. Inline `!important` beats stylesheet
   `!important`, so this overrides the show rule for elements that fall
   under a show selector but should NOT paint — most importantly HDR
   videos and other-layer SDR videos that live as descendants of `#root`.
3. `removeDomLayerMask` tears the stylesheet down and clears the inline
   `visibility`/`opacity` properties so subsequent video frame injection
   gets a clean slate.

Crucially the mask only sets `visibility`, never `opacity`. CSS opacity
*is* multiplicative — `opacity: 0` on `#root` would zero out every
descendant including layer videos, even with `visibility: visible`. We
also extend `initTransparentBackground` to force the composition root
(`[data-composition-id]`) transparent in addition to `html`/`body`,
because compositions almost always set `#root { background: ... }` and
that background paints across the whole viewport otherwise.

Both compositing paths use the new helpers:
- The per-layer DOM branch (`compositeToBuffer`) for normal frames.
- The transition path (single DOM screenshot per scene) so transition
  frames also get a clean per-scene capture.

Adds extensive `KEEP_TEMP=1`-gated diagnostics to `compositeToBuffer`:
per-layer pixel-add accounting, dumps of every captured DOM PNG, and a
periodic raw `rgb48le` snapshot of the composite buffer. These were
essential to diagnosing the root-overwrite bug and stay zero-cost in
normal renders. Also stops the workDir / per-video frame-dir cleanup
when `KEEP_TEMP=1` so the dumps survive past frame N.

Made-with: Cursor

* fix(engine): preserve GSAP-applied opacity across DOM-layer captures

SDR clips inside an HDR composition were rendering at full opacity even
when the user had animated their wrapper opacity (e.g. fade-in or
yoyo). Two bugs in the per-layer screenshot path conspired to drop the
GSAP-applied opacity on the floor:

1. removeDomLayerMask was unconditionally calling
   `el.style.removeProperty("opacity")` on every wrapper after each
   layer capture. applyDomLayerMask only ever sets `visibility`, so the
   only inline opacity present is the value GSAP wrote. Stripping it
   between layer captures means that on the next capture (at the same
   timestamp), GSAP's `totalTime(t, false)` no-ops because the timeline
   is already at that time — the opacity is never restored, and the
   wrapper renders fully opaque.

2. injectVideoFramesBatch was reading the source <video>'s computed
   opacity via `parseFloat(computedStyle.opacity) || 1` and copying it
   onto the injected <img>. Because syncVideoFrameVisibility forces the
   <video> to `opacity: 0 !important` to hide it during capture, the
   computed value is always 0, which `|| 1` then silently flips to
   full opacity. The <img> is a sibling of the <video> inside the same
   wrapper, so it should inherit opacity from the wrapper directly
   instead of having a value hard-set on it.

Fix both: drop the opacity removal in removeDomLayerMask, skip opacity
when copying visual properties from <video> to <img>, and explicitly
clear any stale inline opacity on the <img> so it inherits from the
wrapper that GSAP is animating.

Made-with: Cursor

* fix(producer): correct hdrLayerStartTimes typo to hdrVideoStartTimes

The diagnostic logging block in executeRenderJob's HDR layer composite
path referenced an undeclared `hdrLayerStartTimes` map. The correct
variable, declared and populated earlier in the same function, is
`hdrVideoStartTimes`. The typo was introduced alongside the DOM-layer
masking work and broke the producer build/typecheck on CI.

Made-with: Cursor

* fix(engine): restore video opacity copy to injected frame img

Commit 188ebcca removed the opacity copy from `injectVideoFramesBatch` on
the assumption that the <img> sibling would inherit GSAP's opacity from
a shared wrapper. That breaks any composition where GSAP animates opacity
directly on the <video> element itself: the <img> has no animated
ancestor and renders at full opacity throughout any fade, even when the
user's intent is partial or zero opacity.

The CI `style-7-prod` and `style-8-prod` regressions caught this:
the <video id="aroll"> fade-in from 3.0-3.5s rendered as a hard cut
because the <img> inherited opacity 1 regardless of GSAP's tween.

Restore the old explicit copy from `computedStyle.opacity` to the
<img>'s inline opacity, with the `|| 1` fallback intentionally
preserved. The fallback is load-bearing: GSAP's seek does not re-apply
tweens that have already completed, so post-fade frames read opacity 0
from the stale `opacity: 0 !important` we apply to hide the native
<video>. The `|| 1` recovers the tween's end-state opacity 1 for
those frames, matching the final on-screen intent and the existing
baseline renders.

Handles both DOM shapes:
- GSAP on wrapper: video's own computed opacity is 1, img set to 1,
  wrapper's opacity applies via stacking as before.
- GSAP on <video>: video's computed opacity is the tween value, copied
  to img directly since they are siblings.

Fixes:
- style-7-prod: 0 failed frames (was 2 @ t=3.17, 3.33)
- style-8-prod: 0 failed frames (was 2 @ t=3.05, 3.24)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 19:00:58 -07:00

1131 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shader Transition Math Utilities
*
* Sampling helpers and math primitives for rgb48le shader transitions.
* Functions are ported from GLSL to operate on 16-bit little-endian pixel
* buffers (6 bytes per pixel: R, G, B each stored as UInt16LE).
*/
// ── PQ linearization ─────────────────────────────────────────────────────────
// Shader transitions were ported from sRGB GLSL where pixel values distribute
// linearly across the visible range. In PQ space, dark content clusters near
// zero, causing UV-warping shaders to produce black artifacts. Converting to
// linear light before the shader and back to PQ after gives correct results.
const PQ_M1 = 0.1593017578125;
const PQ_M2 = 78.84375;
const PQ_C1 = 0.8359375;
const PQ_C2 = 18.8515625;
const PQ_C3 = 18.6875;
/** PQ EOTF: decode PQ signal (0-1) → linear light (0-1, normalized to 10000 nits). */
function pqEotf(signal: number): number {
const sp = Math.pow(Math.max(0, signal), 1 / PQ_M2);
const num = Math.max(sp - PQ_C1, 0);
const den = PQ_C2 - PQ_C3 * sp;
return den > 0 ? Math.pow(num / den, 1 / PQ_M1) : 0;
}
/** PQ OETF: encode linear light (0-1) → PQ signal (0-1). */
function pqOetf(linear: number): number {
const lp = Math.pow(Math.max(0, linear), PQ_M1);
return Math.pow((PQ_C1 + PQ_C2 * lp) / (1 + PQ_C3 * lp), PQ_M2);
}
/** HLG OETF inverse: decode HLG signal (0-1) → linear scene light (0-1). */
function hlgEotf(signal: number): number {
const a = 0.17883277;
const b = 1 - 4 * a;
const c = 0.5 - a * Math.log(4 * a);
if (signal <= 0.5) {
return (signal * signal) / 3;
}
return (Math.exp((signal - c) / a) + b) / 12;
}
/** HLG OETF: encode linear scene light (0-1) → HLG signal (0-1). */
function hlgOetf(linear: number): number {
const a = 0.17883277;
const b = 1 - 4 * a;
const c = 0.5 - a * Math.log(4 * a);
if (linear <= 1 / 12) {
return Math.sqrt(3 * linear);
}
return a * Math.log(12 * linear - b) + c;
}
// ── Precomputed LUTs for fast HDR↔linear conversion ─────────────────────────
// 65536-entry lookup tables eliminate per-pixel Math.pow calls. Built once on
// first use, then reused for all subsequent conversions. At 4K (8.3M pixels ×
// 3 channels × 3 buffers), this turns ~75M Math.pow calls per transition frame
// into 75M array lookups — ~100× faster.
function buildLut(fn: (v: number) => number): Uint16Array {
const lut = new Uint16Array(65536);
for (let i = 0; i < 65536; i++) {
lut[i] = Math.round(fn(i / 65535) * 65535);
}
return lut;
}
let pqToLinearLut: Uint16Array | null = null;
let linearToPqLut: Uint16Array | null = null;
let hlgToLinearLut: Uint16Array | null = null;
let linearToHlgLut: Uint16Array | null = null;
function getPqToLinearLut(): Uint16Array {
if (!pqToLinearLut) pqToLinearLut = buildLut(pqEotf);
return pqToLinearLut;
}
function getLinearToPqLut(): Uint16Array {
if (!linearToPqLut) linearToPqLut = buildLut(pqOetf);
return linearToPqLut;
}
function getHlgToLinearLut(): Uint16Array {
if (!hlgToLinearLut) hlgToLinearLut = buildLut(hlgEotf);
return hlgToLinearLut;
}
function getLinearToHlgLut(): Uint16Array {
if (!linearToHlgLut) linearToHlgLut = buildLut(hlgOetf);
return linearToHlgLut;
}
/**
* Convert an rgb48le buffer from HDR signal space to linear light, in-place.
* Uses precomputed 65536-entry LUT for O(1) per-sample conversion.
* @param transfer "pq" or "hlg"
*/
export function hdrToLinear(buf: Buffer, transfer: "pq" | "hlg"): void {
const lut = transfer === "pq" ? getPqToLinearLut() : getHlgToLinearLut();
const len = buf.length / 2;
for (let i = 0; i < len; i++) {
const off = i * 2;
buf.writeUInt16LE(lut[buf.readUInt16LE(off)] ?? 0, off);
}
}
/**
* Convert an rgb48le buffer from linear light back to HDR signal space, in-place.
* Uses precomputed 65536-entry LUT for O(1) per-sample conversion.
* @param transfer "pq" or "hlg"
*/
export function linearToHdr(buf: Buffer, transfer: "pq" | "hlg"): void {
const lut = transfer === "pq" ? getLinearToPqLut() : getLinearToHlgLut();
const len = buf.length / 2;
for (let i = 0; i < len; i++) {
const off = i * 2;
buf.writeUInt16LE(lut[buf.readUInt16LE(off)] ?? 0, off);
}
}
// ── Cross-transfer conversion (HLG↔PQ) ──────────────────────────────────────
// HLG is scene-referred, PQ is display-referred. Converting between them
// requires the OOTF (Optical-Optical Transfer Function) which maps scene
// light to display light. Per BT.2100, the HLG OOTF for a reference
// display at Lw nits is: Y_display = Lw * Y_scene^gamma, where
// gamma = 1.2 * 1.111^(log2(Lw/1000)). At 1000 nits: gamma = 1.2.
//
// The per-channel approximation (applying gamma per-channel rather than
// on luminance Y) introduces slight color shifts but avoids a full
// colorimetric conversion with BT.2020 luma coefficients.
const HLG_OOTF_LW = 1000; // reference display peak luminance (nits)
const HLG_OOTF_GAMMA = 1.2 * Math.pow(1.111, Math.log2(HLG_OOTF_LW / 1000));
/** HLG scene light → PQ display light (per-channel, normalized to 10000 nits) */
function hlgSceneToPqDisplay(sceneLinear: number): number {
const displayNits = HLG_OOTF_LW * Math.pow(Math.max(0, sceneLinear), HLG_OOTF_GAMMA);
return displayNits / 10000; // PQ is normalized to 10000 nits
}
/** PQ display light → HLG scene light (inverse OOTF) */
function pqDisplayToHlgScene(displayNormalized: number): number {
const displayNits = displayNormalized * 10000;
return Math.pow(Math.max(0, displayNits / HLG_OOTF_LW), 1 / HLG_OOTF_GAMMA);
}
let hlgToPqLut: Uint16Array | null = null;
let pqToHlgLut: Uint16Array | null = null;
function getHlgToPqLut(): Uint16Array {
// HLG signal → scene linear (EOTF) → display linear (OOTF) → PQ signal (OETF)
if (!hlgToPqLut) hlgToPqLut = buildLut((v) => pqOetf(hlgSceneToPqDisplay(hlgEotf(v))));
return hlgToPqLut;
}
function getPqToHlgLut(): Uint16Array {
// PQ signal → display linear (EOTF) → scene linear (inverse OOTF) → HLG signal (OETF)
if (!pqToHlgLut) pqToHlgLut = buildLut((v) => hlgOetf(pqDisplayToHlgScene(pqEotf(v))));
return pqToHlgLut;
}
/**
* Convert an rgb48le buffer between HDR transfer functions, in-place.
* Uses a composite 65536-entry LUT (source EOTF → linear → target OETF)
* for O(1) per-sample conversion. No-op if from === to.
*/
export function convertTransfer(buf: Buffer, from: "pq" | "hlg", to: "pq" | "hlg"): void {
if (from === to) return;
const lut = from === "hlg" ? getHlgToPqLut() : getPqToHlgLut();
const len = buf.length / 2;
for (let i = 0; i < len; i++) {
const off = i * 2;
buf.writeUInt16LE(lut[buf.readUInt16LE(off)] ?? 0, off);
}
}
// ── Buffer sampling ───────────────────────────────────────────────────────────
/**
* Sample an rgb48le buffer at floating-point UV coordinates (01 range, clamped).
* Uses bilinear interpolation between the 4 nearest pixels, equivalent to
* GLSL `texture2D` with clamp-to-edge wrapping.
*
* @param buf rgb48le buffer — w * h * 6 bytes
* @param u Horizontal coordinate in [0, 1]
* @param v Vertical coordinate in [0, 1]
* @param w Image width in pixels
* @param h Image height in pixels
* @returns [r, g, b] as 16-bit values (065535)
*/
export function sampleRgb48le(
buf: Buffer,
u: number,
v: number,
w: number,
h: number,
): [number, number, number] {
// Clamp UV to [0, 1] then map to pixel coordinates
const uc = Math.max(0, Math.min(1, u));
const vc = Math.max(0, Math.min(1, v));
const sx = uc * (w - 1);
const sy = vc * (h - 1);
const x0 = Math.floor(sx);
const y0 = Math.floor(sy);
const x1 = Math.min(x0 + 1, w - 1);
const y1 = Math.min(y0 + 1, h - 1);
const fx = sx - x0;
const fy = sy - y0;
const w00 = (1 - fx) * (1 - fy);
const w10 = fx * (1 - fy);
const w01 = (1 - fx) * fy;
const w11 = fx * fy;
const off00 = (y0 * w + x0) * 6;
const off10 = (y0 * w + x1) * 6;
const off01 = (y1 * w + x0) * 6;
const off11 = (y1 * w + x1) * 6;
const r = Math.round(
buf.readUInt16LE(off00) * w00 +
buf.readUInt16LE(off10) * w10 +
buf.readUInt16LE(off01) * w01 +
buf.readUInt16LE(off11) * w11,
);
const g = Math.round(
buf.readUInt16LE(off00 + 2) * w00 +
buf.readUInt16LE(off10 + 2) * w10 +
buf.readUInt16LE(off01 + 2) * w01 +
buf.readUInt16LE(off11 + 2) * w11,
);
const b = Math.round(
buf.readUInt16LE(off00 + 4) * w00 +
buf.readUInt16LE(off10 + 4) * w10 +
buf.readUInt16LE(off01 + 4) * w01 +
buf.readUInt16LE(off11 + 4) * w11,
);
return [r, g, b];
}
// ── 16-bit math primitives ────────────────────────────────────────────────────
/**
* Linear interpolate two 16-bit values. Equivalent to GLSL `mix(a, b, t)`.
*/
export function mix16(a: number, b: number, t: number): number {
return Math.round(a * (1 - t) + b * t);
}
/**
* Clamp a value to the 16-bit unsigned range [0, 65535].
*/
export function clamp16(v: number): number {
return Math.max(0, Math.min(65535, v));
}
// ── GLSL math ports ───────────────────────────────────────────────────────────
/**
* Hermite interpolation from GLSL `smoothstep(edge0, edge1, x)`.
* Returns 0 for x ≤ edge0, 1 for x ≥ edge1, and a smooth S-curve between.
*/
export function smoothstep(edge0: number, edge1: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
/**
* Deterministic pseudo-random value in [0, 1).
* Port of the GLSL idiom: `fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453)`.
*/
export function hash(x: number, y: number): number {
return (((Math.sin(x * 127.1 + y * 311.7) * 43758.5453) % 1) + 1) % 1;
}
/**
* Value noise with C2-continuous quintic interpolation.
* Samples `hash()` at the 4 surrounding integer grid corners and blends
* using the quintic fade f = f³(f(6f 15) + 10).
*
* Returns a value in [0, 1].
*/
export function vnoise(px: number, py: number): number {
const ix = Math.floor(px);
const iy = Math.floor(py);
// Fractional part
let fx = px - ix;
let fy = py - iy;
// Quintic C2 interpolation weights
fx = fx * fx * fx * (fx * (fx * 6 - 15) + 10);
fy = fy * fy * fy * (fy * (fy * 6 - 15) + 10);
const h00 = hash(ix, iy);
const h10 = hash(ix + 1, iy);
const h01 = hash(ix, iy + 1);
const h11 = hash(ix + 1, iy + 1);
// Bilinear blend
return h00 * (1 - fx) * (1 - fy) + h10 * fx * (1 - fy) + h01 * (1 - fx) * fy + h11 * fx * fy;
}
// Rotation matrix constants from GLSL: mat2(0.8, 0.6, -0.6, 0.8)
// Applies to [px, py]: px' = 0.8*px - 0.6*py, py' = 0.6*px + 0.8*py
const ROT_A = 0.8;
const ROT_B = 0.6;
/**
* Fractal Brownian motion — 5-octave accumulation of value noise.
*
* Each octave: accumulate `amplitude * vnoise(p)`, rotate p by 36.87°,
* scale by 2.02, halve the amplitude. Matching the GLSL convention of
* `mat2(0.8, 0.6, -0.6, 0.8)` for the rotation.
*/
export function fbm(px: number, py: number): number {
let value = 0;
let amplitude = 0.5;
let x = px;
let y = py;
for (let i = 0; i < 5; i++) {
value += amplitude * vnoise(x, y);
// Rotate by mat2(0.8, 0.6, -0.6, 0.8)
const nx = ROT_A * x - ROT_B * y;
const ny = ROT_B * x + ROT_A * y;
x = nx * 2.02;
y = ny * 2.02;
amplitude *= 0.5;
}
return value;
}
// ── Transition types and registry ─────────────────────────────────────────────
/** A transition function that blends two rgb48le buffers into an output buffer. */
export type TransitionFn = (
from: Buffer,
to: Buffer,
output: Buffer,
width: number,
height: number,
progress: number,
) => void;
/** Registry of all available transitions by name. */
export const TRANSITIONS: Record<string, TransitionFn> = {};
// ── crossfade ─────────────────────────────────────────────────────────────────
/**
* Simple linear blend between two frames. Equivalent to GLSL `mix(from, to, progress)`.
*/
export const crossfade: TransitionFn = (from, to, out, w, h, p) => {
const inv = 1 - p;
for (let i = 0; i < w * h; i++) {
const o = i * 6;
out.writeUInt16LE(Math.round(from.readUInt16LE(o) * inv + to.readUInt16LE(o) * p), o);
out.writeUInt16LE(
Math.round(from.readUInt16LE(o + 2) * inv + to.readUInt16LE(o + 2) * p),
o + 2,
);
out.writeUInt16LE(
Math.round(from.readUInt16LE(o + 4) * inv + to.readUInt16LE(o + 4) * p),
o + 4,
);
}
};
TRANSITIONS["crossfade"] = crossfade;
// ── flashThroughWhite ─────────────────────────────────────────────────────────
/**
* Flash-through-white transition: the outgoing scene brightens to white while
* the incoming scene emerges from white, creating a bright flash at the midpoint.
*
* Port of the GLSL flash-through-white shader.
*/
export const flashThroughWhite: TransitionFn = (from, to, out, w, h, p) => {
const toWhite = smoothstep(0, 0.45, p); // outgoing brightens toward white
const fromWhite = 1 - smoothstep(0.5, 1, p); // incoming starts from white
const blend = smoothstep(0.35, 0.65, p); // crossfade between the two
for (let i = 0; i < w * h; i++) {
const o = i * 6;
const fromR = mix16(from.readUInt16LE(o), 65535, toWhite);
const fromG = mix16(from.readUInt16LE(o + 2), 65535, toWhite);
const fromB = mix16(from.readUInt16LE(o + 4), 65535, toWhite);
const toR = mix16(to.readUInt16LE(o), 65535, fromWhite);
const toG = mix16(to.readUInt16LE(o + 2), 65535, fromWhite);
const toB = mix16(to.readUInt16LE(o + 4), 65535, fromWhite);
out.writeUInt16LE(mix16(fromR, toR, blend), o);
out.writeUInt16LE(mix16(fromG, toG, blend), o + 2);
out.writeUInt16LE(mix16(fromB, toB, blend), o + 4);
}
};
TRANSITIONS["flash-through-white"] = flashThroughWhite;
// ── chromatic-split ───────────────────────────────────────────────────────────
/**
* RGB channel offset transition. Each channel is sampled at a different UV
* offset, spreading apart as progress increases (outgoing) and converging
* as progress approaches 1 (incoming). Port of the GLSL chromatic-split shader.
*/
export const chromaticSplit: TransitionFn = (from, to, out, w, h, p) => {
for (let i = 0; i < w * h; i++) {
const ux = (i % w) / w;
const uy = Math.floor(i / w) / h;
const o = i * 6;
// Center-relative UV for offset direction
const cx = ux - 0.5;
const cy = uy - 0.5;
const fromShift = p * 0.06;
const fr = sampleRgb48le(from, ux + cx * fromShift, uy + cy * fromShift, w, h)[0];
const fg = sampleRgb48le(from, ux, uy, w, h)[1];
const fb = sampleRgb48le(from, ux - cx * fromShift, uy - cy * fromShift, w, h)[2];
const toShift = (1 - p) * 0.06;
const tr = sampleRgb48le(to, ux - cx * toShift, uy - cy * toShift, w, h)[0];
const tg = sampleRgb48le(to, ux, uy, w, h)[1];
const tb = sampleRgb48le(to, ux + cx * toShift, uy + cy * toShift, w, h)[2];
out.writeUInt16LE(clamp16(mix16(fr, tr, p)), o);
out.writeUInt16LE(clamp16(mix16(fg, tg, p)), o + 2);
out.writeUInt16LE(clamp16(mix16(fb, tb, p)), o + 4);
}
};
TRANSITIONS["chromatic-split"] = chromaticSplit;
// ── sdf-iris ──────────────────────────────────────────────────────────────────
/**
* Circular iris reveal. A sharp edge expands from the center while golden
* glow rings ripple outward at the boundary. Port of the GLSL sdf-iris shader.
*/
export const sdfIris: TransitionFn = (from, to, out, w, h, p) => {
// Accent colors for glow rings (16-bit scale)
const accentBright = [65535, 55000, 35000] as const;
for (let i = 0; i < w * h; i++) {
const ux = (i % w) / w;
const uy = Math.floor(i / w) / h;
const o = i * 6;
// Aspect-corrected distance from center
const ax = (ux - 0.5) * (w / h);
const ay = uy - 0.5;
const d = Math.sqrt(ax * ax + ay * ay);
const radius = p * 1.2;
const fw = 0.003;
const edge = smoothstep(radius + fw, radius - fw, d);
// Three glow rings at different radii and falloff speeds
const ring1 = Math.exp(-Math.abs(d - radius) * 25);
const ring2 = Math.exp(-Math.abs(d - radius + 0.04) * 20) * 0.5;
const ring3 = Math.exp(-Math.abs(d - radius + 0.08) * 15) * 0.25;
const glow = (ring1 + ring2 + ring3) * p * (1 - p) * 4;
const [fromR, fromG, fromB] = sampleRgb48le(from, ux, uy, w, h);
const [toR, toG, toB] = sampleRgb48le(to, ux, uy, w, h);
out.writeUInt16LE(clamp16(mix16(fromR, toR, edge) + accentBright[0] * glow * 0.6), o);
out.writeUInt16LE(clamp16(mix16(fromG, toG, edge) + accentBright[1] * glow * 0.6), o + 2);
out.writeUInt16LE(clamp16(mix16(fromB, toB, edge) + accentBright[2] * glow * 0.6), o + 4);
}
};
TRANSITIONS["sdf-iris"] = sdfIris;
// ── glitch ────────────────────────────────────────────────────────────────────
/**
* Deterministic PRNG matching the GLSL `rand` in the glitch shader.
* Uses different constants than `hash` — do NOT substitute.
*/
function glitchRand(x: number, y: number): number {
return (((Math.sin(x * 12.9898 + y * 78.233) * 43758.5453) % 1) + 1) % 1;
}
/**
* Block displacement + scanlines + RGB channel split. Intensity peaks at the
* midpoint (p=0.5) and decays at both ends. Port of the GLSL glitch shader.
*/
export const glitch: TransitionFn = (from, to, out, w, h, p) => {
const intensity = p * (1 - p) * 4;
for (let i = 0; i < w * h; i++) {
const ux = (i % w) / w;
const uy = Math.floor(i / w) / h;
const o = i * 6;
// Horizontal line displacement
const lineY = Math.floor(uy * 60) / 60;
const lineDisp = (glitchRand(lineY, Math.floor(p * 17)) - 0.5) * 0.18 * intensity;
// Block displacement
const blockX = Math.floor(ux * 12);
const blockY = Math.floor(uy * 8);
const progressStep = Math.floor(p * 11);
const br = glitchRand(blockX + progressStep, blockY + progressStep);
const ba = (br >= 0.83 ? 1 : 0) * intensity;
const bdx = (glitchRand(blockX * 2.1, blockY * 2.1) - 0.5) * 0.35 * ba;
const bdy = (glitchRand(blockX * 3.7, blockY * 3.7) - 0.5) * 0.35 * ba;
const uvx = Math.max(0, Math.min(1, ux + lineDisp + bdx));
const uvy = Math.max(0, Math.min(1, uy + bdy));
// RGB channel split on displaced UV
const shift = intensity * 0.035;
const r = sampleRgb48le(from, uvx + shift, uvy, w, h)[0];
const g = sampleRgb48le(from, uvx, uvy, w, h)[1];
const b = sampleRgb48le(from, uvx - shift, uvy, w, h)[2];
// Normalize to 0-1 for scanline, flicker, and crush operations
let cr = r / 65535;
let cg = g / 65535;
let cb = b / 65535;
// Scanline darkening: darken rows where fract(uy * h * 0.5) > 0.5
const scanline = (((uy * h * 0.5) % 1) + 1) % 1 >= 0.5 ? 0.05 * intensity : 0;
cr -= scanline;
cg -= scanline;
cb -= scanline;
// Brightness flicker
const flicker = 1 + (glitchRand(Math.floor(p * 23), 0) - 0.5) * 0.3 * intensity;
cr *= flicker;
cg *= flicker;
cb *= flicker;
// Color crush (posterize)
const levels = 256 - (256 - 8) * (intensity * 0.5);
cr = Math.floor(cr * levels) / levels;
cg = Math.floor(cg * levels) / levels;
cb = Math.floor(cb * levels) / levels;
// Scale back to 16-bit and mix with `to` by progress
const [toR, toG, toB] = sampleRgb48le(to, ux, uy, w, h);
out.writeUInt16LE(clamp16(mix16(Math.round(cr * 65535), toR, p)), o);
out.writeUInt16LE(clamp16(mix16(Math.round(cg * 65535), toG, p)), o + 2);
out.writeUInt16LE(clamp16(mix16(Math.round(cb * 65535), toB, p)), o + 4);
}
};
TRANSITIONS["glitch"] = glitch;
// ── light-leak ────────────────────────────────────────────────────────────────
/**
* ACES filmic tonemap. Input and output in 0-1 normalized range.
* Formula: (x * (2.51x + 0.03)) / (x * (2.43x + 0.59) + 0.14)
*/
function aces(x: number): number {
return Math.max(0, Math.min(1, (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14)));
}
/**
* Warm lens-flare from the upper-right corner. The incoming scene burns through
* an overexposed flash, tonemapped with ACES and crossfaded with the outgoing
* scene. Port of the GLSL light-leak shader.
*/
export const lightLeak: TransitionFn = (from, to, out, w, h, p) => {
// Normalized accent colors (0-1 range for ACES pipeline)
const accent = [50000 / 65535, 25000 / 65535, 5000 / 65535] as const;
const accentBright = [65535 / 65535, 55000 / 65535, 35000 / 65535] as const;
// Light source position
const lpx = 1.3;
const lpy = -0.2;
for (let i = 0; i < w * h; i++) {
const ux = (i % w) / w;
const uy = Math.floor(i / w) / h;
const o = i * 6;
const dx = ux - lpx;
const dy = uy - lpy;
const dist = Math.sqrt(dx * dx + dy * dy);
const leak = Math.max(0, Math.min(1, Math.exp(-dist * 1.8) * p * 4));
// Warm color: mix accent with accent_bright based on distance
const warmR = accent[0] + (accentBright[0] - accent[0]) * dist * 0.7;
const warmG = accent[1] + (accentBright[1] - accent[1]) * dist * 0.7;
const warmB = accent[2] + (accentBright[2] - accent[2]) * dist * 0.7;
// Lens flare streak
const flare = Math.exp(-Math.abs(uy - (-0.2 + ux * 0.3)) * 15) * leak * 0.3;
const [fr, fg, fb] = sampleRgb48le(from, ux, uy, w, h);
const fromR = fr / 65535;
const fromG = fg / 65535;
const fromB = fb / 65535;
// Overexpose and tonemap
const overR = aces(fromR + warmR * leak * 3 + accentBright[0] * flare);
const overG = aces(fromG + warmG * leak * 3 + accentBright[1] * flare);
const overB = aces(fromB + warmB * leak * 3 + accentBright[2] * flare);
// Mix overexposed → to by smoothstepped progress
const [toR, toG, toB] = sampleRgb48le(to, ux, uy, w, h);
const blend = smoothstep(0.15, 0.85, p);
out.writeUInt16LE(clamp16(mix16(Math.round(overR * 65535), toR, blend)), o);
out.writeUInt16LE(clamp16(mix16(Math.round(overG * 65535), toG, blend)), o + 2);
out.writeUInt16LE(clamp16(mix16(Math.round(overB * 65535), toB, blend)), o + 4);
}
};
TRANSITIONS["light-leak"] = lightLeak;
// ── cross-warp-morph ──────────────────────────────────────────────────────────
/**
* FBM displacement warp. Both frames are warped in opposite directions by a
* fractal noise field, then blended by a noise-threshold mask that sweeps
* across the screen as progress advances. Port of the GLSL cross-warp-morph shader.
*/
export const crossWarpMorph: TransitionFn = (from, to, out, w, h, p) => {
for (let i = 0; i < w * h; i++) {
const ux = (i % w) / w;
const uy = Math.floor(i / w) / h;
const o = i * 6;
const dispX = fbm(ux * 3, uy * 3) - 0.5;
const dispY = fbm(ux * 3 + 7.3, uy * 3 + 3.7) - 0.5;
const fromUx = Math.max(0, Math.min(1, ux + dispX * p * 0.5));
const fromUy = Math.max(0, Math.min(1, uy + dispY * p * 0.5));
const toUx = Math.max(0, Math.min(1, ux - dispX * (1 - p) * 0.5));
const toUy = Math.max(0, Math.min(1, uy - dispY * (1 - p) * 0.5));
const [fromR, fromG, fromB] = sampleRgb48le(from, fromUx, fromUy, w, h);
const [toR, toG, toB] = sampleRgb48le(to, toUx, toUy, w, h);
const n = fbm(ux * 4 + 3.1, uy * 4 + 1.7);
const blend = smoothstep(0.4, 0.6, n + p * 1.2 - 0.6);
out.writeUInt16LE(clamp16(mix16(fromR, toR, blend)), o);
out.writeUInt16LE(clamp16(mix16(fromG, toG, blend)), o + 2);
out.writeUInt16LE(clamp16(mix16(fromB, toB, blend)), o + 4);
}
};
TRANSITIONS["cross-warp-morph"] = crossWarpMorph;
// ── whip-pan ──────────────────────────────────────────────────────────────────
/**
* Horizontal motion blur. The outgoing frame is sampled with offsets shifted
* right (by progress*1.5) and the incoming frame is sampled with offsets shifted
* left (by (1-progress)*1.5). Each direction uses 10 samples averaged together.
* Port of the GLSL whip-pan shader.
*/
export const whipPan: TransitionFn = (from, to, out, w, h, p) => {
const fromOff = p * 1.5;
const toOff = (1 - p) * 1.5;
for (let i = 0; i < w * h; i++) {
const ux = (i % w) / w;
const uy = Math.floor(i / w) / h;
const o = i * 6;
let fromR = 0,
fromG = 0,
fromB = 0;
for (let s = 0; s < 10; s++) {
const f = s / 10;
const fuv = Math.max(0, Math.min(1, ux + fromOff + p * 0.08 * f));
const [r, g, b] = sampleRgb48le(from, fuv, uy, w, h);
fromR += r;
fromG += g;
fromB += b;
}
fromR /= 10;
fromG /= 10;
fromB /= 10;
let toR = 0,
toG = 0,
toB = 0;
for (let s = 0; s < 10; s++) {
const f = s / 10;
const tuv = Math.max(0, Math.min(1, ux - toOff - (1 - p) * 0.08 * f));
const [r, g, b] = sampleRgb48le(to, tuv, uy, w, h);
toR += r;
toG += g;
toB += b;
}
toR /= 10;
toG /= 10;
toB /= 10;
out.writeUInt16LE(clamp16(mix16(Math.round(fromR), Math.round(toR), p)), o);
out.writeUInt16LE(clamp16(mix16(Math.round(fromG), Math.round(toG), p)), o + 2);
out.writeUInt16LE(clamp16(mix16(Math.round(fromB), Math.round(toB), p)), o + 4);
}
};
TRANSITIONS["whip-pan"] = whipPan;
// ── cinematic-zoom ────────────────────────────────────────────────────────────
/**
* Radial zoom blur with chromatic aberration. Both frames are blurred along a
* radial direction from center using 12 samples. R/G/B channels use slightly
* different zoom factors (1.06, 1.0, 0.94) for chromatic aberration. The
* outgoing frame zooms inward while the incoming zooms outward.
* Port of the GLSL cinematic-zoom shader.
*/
export const cinematicZoom: TransitionFn = (from, to, out, w, h, p) => {
const fromS = p * 0.08;
const toS = (1 - p) * 0.06;
for (let i = 0; i < w * h; i++) {
const ux = (i % w) / w;
const uy = Math.floor(i / w) / h;
const o = i * 6;
const dx = ux - 0.5;
const dy = uy - 0.5;
let fr = 0,
fg = 0,
fb = 0;
for (let s = 0; s < 12; s++) {
const f = s / 12;
const rr = sampleRgb48le(
from,
ux - dx * fromS * 1.06 * f,
uy - dy * fromS * 1.06 * f,
w,
h,
)[0];
const gg = sampleRgb48le(from, ux - dx * fromS * f, uy - dy * fromS * f, w, h)[1];
const bb = sampleRgb48le(
from,
ux - dx * fromS * 0.94 * f,
uy - dy * fromS * 0.94 * f,
w,
h,
)[2];
fr += rr;
fg += gg;
fb += bb;
}
fr /= 12;
fg /= 12;
fb /= 12;
let tr = 0,
tg = 0,
tb = 0;
for (let s = 0; s < 12; s++) {
const f = s / 12;
const rr = sampleRgb48le(to, ux + dx * toS * 1.06 * f, uy + dy * toS * 1.06 * f, w, h)[0];
const gg = sampleRgb48le(to, ux + dx * toS * f, uy + dy * toS * f, w, h)[1];
const bb = sampleRgb48le(to, ux + dx * toS * 0.94 * f, uy + dy * toS * 0.94 * f, w, h)[2];
tr += rr;
tg += gg;
tb += bb;
}
tr /= 12;
tg /= 12;
tb /= 12;
out.writeUInt16LE(clamp16(mix16(Math.round(fr), Math.round(tr), p)), o);
out.writeUInt16LE(clamp16(mix16(Math.round(fg), Math.round(tg), p)), o + 2);
out.writeUInt16LE(clamp16(mix16(Math.round(fb), Math.round(tb), p)), o + 4);
}
};
TRANSITIONS["cinematic-zoom"] = cinematicZoom;
// ── gravitational-lens ────────────────────────────────────────────────────────
/**
* Radial warp toward center simulating a gravitational lens effect. The
* outgoing frame is warped with chromatic separation, masked by a horizon
* that depends on distance from center. Mixed to the incoming frame using
* smoothstep(0.3, 0.9, progress). Port of the GLSL gravitational-lens shader.
*/
export const gravitationalLens: TransitionFn = (from, to, out, w, h, p) => {
for (let i = 0; i < w * h; i++) {
const ux = (i % w) / w;
const uy = Math.floor(i / w) / h;
const o = i * 6;
const uvx = ux - 0.5;
const uvy = uy - 0.5;
const dist = Math.sqrt(uvx * uvx + uvy * uvy);
const pull = p * 2;
const warpStr = (pull * 0.3) / (dist + 0.1);
const warpedX = Math.max(0, Math.min(1, ux - uvx * warpStr));
const warpedY = Math.max(0, Math.min(1, uy - uvy * warpStr));
const [, ag] = sampleRgb48le(from, warpedX, warpedY, w, h);
const horizon = smoothstep(0, 0.3, dist / (1 - p * 0.85 + 0.001));
const shift = (pull * 0.02) / (dist + 0.2);
const rSampX = Math.max(0, Math.min(1, ux - uvx * (warpStr + shift)));
const rSampY = Math.max(0, Math.min(1, uy - uvy * (warpStr + shift)));
const bSampX = Math.max(0, Math.min(1, ux - uvx * (warpStr - shift)));
const bSampY = Math.max(0, Math.min(1, uy - uvy * (warpStr - shift)));
const ar = sampleRgb48le(from, rSampX, rSampY, w, h)[0];
const ab = sampleRgb48le(from, bSampX, bSampY, w, h)[2];
const lensedR = Math.round(ar * horizon);
const lensedG = Math.round(ag * horizon);
const lensedB = Math.round(ab * horizon);
const [toR, toG, toB] = sampleRgb48le(to, ux, uy, w, h);
const blend = smoothstep(0.3, 0.9, p);
out.writeUInt16LE(clamp16(mix16(lensedR, toR, blend)), o);
out.writeUInt16LE(clamp16(mix16(lensedG, toG, blend)), o + 2);
out.writeUInt16LE(clamp16(mix16(lensedB, toB, blend)), o + 4);
}
};
TRANSITIONS["gravitational-lens"] = gravitationalLens;
// ── ripple-waves ──────────────────────────────────────────────────────────────
/**
* Concentric wave distortion. Exponential wave functions create rings radiating
* outward from center. Both frames are distorted — the outgoing with progress-
* scaled amplitude, the incoming with (1-progress)-scaled amplitude. A warm
* accent tint highlights wave peaks. Port of the GLSL ripple-waves shader.
*/
export const rippleWaves: TransitionFn = (from, to, out, w, h, p) => {
// Accent bright color (16-bit)
const accentBright = [65535, 55000, 35000] as const;
for (let i = 0; i < w * h; i++) {
const ux = (i % w) / w;
const uy = Math.floor(i / w) / h;
const o = i * 6;
const uvx = ux - 0.5;
const uvy = uy - 0.5;
const dist = Math.sqrt(uvx * uvx + uvy * uvy);
// Normalize direction from center, offset by small amount to avoid div-by-zero
const nux = uvx + 0.001;
const nuy = uvy + 0.001;
const nlen = Math.sqrt(nux * nux + nuy * nuy);
const dirx = nux / nlen;
const diry = nuy / nlen;
// From frame: waves moving outward (positive phase)
const fromAmp = p * 0.04;
const fw1 = Math.exp(Math.sin(dist * 25 - p * 12) - 1);
const fw2 = Math.exp(Math.sin(dist * 50 - p * 18) - 1) * 0.5;
const fromUx = Math.max(0, Math.min(1, ux + dirx * (fw1 + fw2) * fromAmp));
const fromUy = Math.max(0, Math.min(1, uy + diry * (fw1 + fw2) * fromAmp));
// To frame: waves moving inward (reversed phase)
const toAmp = (1 - p) * 0.04;
const tw1 = Math.exp(Math.sin(dist * 25 + p * 12) - 1);
const tw2 = Math.exp(Math.sin(dist * 50 + p * 18) - 1) * 0.5;
const toUx = Math.max(0, Math.min(1, ux - dirx * (tw1 + tw2) * toAmp));
const toUy = Math.max(0, Math.min(1, uy - diry * (tw1 + tw2) * toAmp));
const [fromR, fromG, fromB] = sampleRgb48le(from, fromUx, fromUy, w, h);
const [toR, toG, toB] = sampleRgb48le(to, toUx, toUy, w, h);
const peak = fw1 * p;
const tintR = accentBright[0] * peak * 0.1;
const tintG = accentBright[1] * peak * 0.1;
const tintB = accentBright[2] * peak * 0.1;
out.writeUInt16LE(clamp16(mix16(Math.round(fromR + tintR), toR, p)), o);
out.writeUInt16LE(clamp16(mix16(Math.round(fromG + tintG), toG, p)), o + 2);
out.writeUInt16LE(clamp16(mix16(Math.round(fromB + tintB), toB, p)), o + 4);
}
};
TRANSITIONS["ripple-waves"] = rippleWaves;
// ── swirl-vortex ──────────────────────────────────────────────────────────────
/**
* Rotational UV warp. The outgoing frame is rotated clockwise and the incoming
* counter-clockwise. Rotation angle depends on distance from center and FBM
* warp noise. Port of the GLSL swirl-vortex shader.
*/
export const swirlVortex: TransitionFn = (from, to, out, w, h, p) => {
for (let i = 0; i < w * h; i++) {
const ux = (i % w) / w;
const uy = Math.floor(i / w) / h;
const o = i * 6;
const uvx = ux - 0.5;
const uvy = uy - 0.5;
const dist = Math.sqrt(uvx * uvx + uvy * uvy);
const warp = fbm(ux * 4, uy * 4) * 0.5;
const fromAng = p * (1 - dist) * 10 + warp * p * 3;
const fs = Math.sin(fromAng);
const fc = Math.cos(fromAng);
const fromUx = Math.max(0, Math.min(1, uvx * fc - uvy * fs + 0.5));
const fromUy = Math.max(0, Math.min(1, uvx * fs + uvy * fc + 0.5));
const toAng = -(1 - p) * (1 - dist) * 10 - warp * (1 - p) * 3;
const ts = Math.sin(toAng);
const tc = Math.cos(toAng);
const toUx = Math.max(0, Math.min(1, uvx * tc - uvy * ts + 0.5));
const toUy = Math.max(0, Math.min(1, uvx * ts + uvy * tc + 0.5));
const [fromR, fromG, fromB] = sampleRgb48le(from, fromUx, fromUy, w, h);
const [toR, toG, toB] = sampleRgb48le(to, toUx, toUy, w, h);
out.writeUInt16LE(clamp16(mix16(fromR, toR, p)), o);
out.writeUInt16LE(clamp16(mix16(fromG, toG, p)), o + 2);
out.writeUInt16LE(clamp16(mix16(fromB, toB, p)), o + 4);
}
};
TRANSITIONS["swirl-vortex"] = swirlVortex;
// ── thermal-distortion ────────────────────────────────────────────────────────
/**
* Heat shimmer effect. Horizontal displacement based on a sin wave modulated by
* FBM noise, fading toward the top of the screen. Both frames are displaced
* independently, and a warm haze overlay fades as progress advances.
* Port of the GLSL thermal-distortion shader.
*/
export const thermalDistortion: TransitionFn = (from, to, out, w, h, p) => {
// Accent bright color (16-bit)
const accentBright = [65535, 55000, 35000] as const;
for (let i = 0; i < w * h; i++) {
const ux = (i % w) / w;
const uy = Math.floor(i / w) / h;
const o = i * 6;
const heat = p * 1.5;
const yFade = smoothstep(1, 0, uy);
// From frame shimmer: fbm(uv*6) modulates sin wave
const shimmer = Math.sin(uy * 40 + fbm(ux * 6, uy * 6) * 8) * fbm(ux * 3 + 0, uy * 3 + p * 2);
const dispX = shimmer * heat * 0.03 * yFade;
const fromUx = Math.max(0, Math.min(1, ux + dispX));
const [fromR, fromG, fromB] = sampleRgb48le(from, fromUx, uy, w, h);
// To frame shimmer: different FBM seed (offset by 3)
const invShimmer =
Math.sin(uy * 40 + fbm(ux * 6 + 3, uy * 6 + 3) * 8) * fbm(ux * 3 + 3, uy * 3 + p * 2);
const dispX2 = invShimmer * (1 - p) * 0.03 * yFade;
const toUx = Math.max(0, Math.min(1, ux + dispX2));
const [toR, toG, toB] = sampleRgb48le(to, toUx, uy, w, h);
const haze = heat * yFade * 0.15 * (1 - p);
out.writeUInt16LE(clamp16(mix16(fromR, toR, p) + Math.round(accentBright[0] * haze)), o);
out.writeUInt16LE(clamp16(mix16(fromG, toG, p) + Math.round(accentBright[1] * haze)), o + 2);
out.writeUInt16LE(clamp16(mix16(fromB, toB, p) + Math.round(accentBright[2] * haze)), o + 4);
}
};
TRANSITIONS["thermal-distortion"] = thermalDistortion;
// ── domain-warp ───────────────────────────────────────────────────────────────
/**
* FBM-driven UV warp with edge glow. Computes two layers of FBM (q and r) to
* derive a warp direction. Both frames are displaced in opposite directions.
* An edge-detection glow appears at the transition boundary.
* Port of the GLSL domain-warp shader. Note: mix(B, A, e) ordering — e=1 shows A (from).
*/
export const domainWarp: TransitionFn = (from, to, out, w, h, p) => {
// Accent colors (16-bit)
const accentDark = [25000, 8000, 2000] as const;
const accentBright = [65535, 55000, 35000] as const;
for (let i = 0; i < w * h; i++) {
const ux = (i % w) / w;
const uy = Math.floor(i / w) / h;
const o = i * 6;
// Two-layer domain warp: q, then r
const qx = fbm(ux * 3, uy * 3);
const qy = fbm(ux * 3 + 5.2, uy * 3 + 1.3);
const rx = fbm(ux * 3 + qx * 4 + 1.7, uy * 3 + qy * 4 + 9.2);
const ry = fbm(ux * 3 + qx * 4 + 8.3, uy * 3 + qy * 4 + 2.8);
const n = fbm(ux * 3 + rx * 2, uy * 3 + ry * 2);
const warpDirX = (qx - 0.5) * 0.4;
const warpDirY = (qy - 0.5) * 0.4;
const aUx = Math.max(0, Math.min(1, ux + warpDirX * p));
const aUy = Math.max(0, Math.min(1, uy + warpDirY * p));
const bUx = Math.max(0, Math.min(1, ux - warpDirX * (1 - p)));
const bUy = Math.max(0, Math.min(1, uy - warpDirY * (1 - p)));
const [aR, aG, aB] = sampleRgb48le(from, aUx, aUy, w, h);
const [bR, bG, bB] = sampleRgb48le(to, bUx, bUy, w, h);
// e=1 → show A (from), e=0 → show B (to): mix(B, A, e)
const e = smoothstep(p - 0.08, p + 0.08, n);
const ed = Math.abs(n - p);
// step(1, p) = p >= 1 ? 1 : 0 → suppress glow at p=1
const pStep = p >= 1 ? 1 : 0;
const em = smoothstep(0.1, 0, ed) * (1 - pStep);
// Edge color: mix accent_dark → accent_bright based on edge proximity
const ecBlend = smoothstep(0, 0.1, ed);
const ecR = accentDark[0] + (accentBright[0] - accentDark[0]) * (1 - ecBlend);
const ecG = accentDark[1] + (accentBright[1] - accentDark[1]) * (1 - ecBlend);
const ecB = accentDark[2] + (accentBright[2] - accentDark[2]) * (1 - ecBlend);
// mix(B, A, e) + edge glow
out.writeUInt16LE(clamp16(mix16(bR, aR, e) + Math.round(ecR * em * 2)), o);
out.writeUInt16LE(clamp16(mix16(bG, aG, e) + Math.round(ecG * em * 2)), o + 2);
out.writeUInt16LE(clamp16(mix16(bB, aB, e) + Math.round(ecB * em * 2)), o + 4);
}
};
TRANSITIONS["domain-warp"] = domainWarp;
// ── ridged-burn ───────────────────────────────────────────────────────────────
/**
* Ridged noise threshold transition with heat glow and sparks. Uses a custom
* ridged noise function (5 octaves of abs(vnoise*2 - 1)) to create a
* burning-paper effect. Accent colors glow at the burn boundary. Sparks
* appear from high-frequency noise.
* Port of the GLSL ridged-burn shader. Note: mix(B, A, e) ordering — e=1 shows A (from).
*/
/**
* Ridged noise: 5 octaves of abs(vnoise*2 - 1) with the same rotation and
* scaling as fbm. Returns values in [0, ~0.97].
*/
function ridged(px: number, py: number): number {
let value = 0;
let amplitude = 0.5;
let x = px;
let y = py;
for (let i = 0; i < 5; i++) {
value += amplitude * Math.abs(vnoise(x, y) * 2 - 1);
const nx = ROT_A * x - ROT_B * y;
const ny = ROT_B * x + ROT_A * y;
x = nx * 2.02;
y = ny * 2.02;
amplitude *= 0.5;
}
return value;
}
export const ridgedBurn: TransitionFn = (from, to, out, w, h, p) => {
// Accent colors (16-bit)
const accent = [50000, 25000, 5000] as const;
const accentDark = [25000, 8000, 2000] as const;
const accentBright = [65535, 55000, 35000] as const;
for (let i = 0; i < w * h; i++) {
const ux = (i % w) / w;
const uy = Math.floor(i / w) / h;
const o = i * 6;
const [aR, aG, aB] = sampleRgb48le(from, ux, uy, w, h);
const [bR, bG, bB] = sampleRgb48le(to, ux, uy, w, h);
const n = ridged(ux * 4, uy * 4);
// e=1 → show A (from), e=0 → show B (to): mix(B, A, e)
const e = smoothstep(p - 0.04, p + 0.04, n);
const heat = smoothstep(0.12, 0, Math.abs(n - p));
// step(1, p) = p >= 1 ? 1 : 0 → suppress glow at p=1
const pStep = p >= 1 ? 1 : 0;
const heatMasked = heat * (1 - pStep);
// Burn color gradient: dark → accent → accent_bright → white
let burnR = accentDark[0] + (accent[0] - accentDark[0]) * smoothstep(0, 0.25, heatMasked);
let burnG = accentDark[1] + (accent[1] - accentDark[1]) * smoothstep(0, 0.25, heatMasked);
let burnB = accentDark[2] + (accent[2] - accentDark[2]) * smoothstep(0, 0.25, heatMasked);
const blend2 = smoothstep(0.25, 0.5, heatMasked);
burnR = burnR + (accentBright[0] - burnR) * blend2;
burnG = burnG + (accentBright[1] - burnG) * blend2;
burnB = burnB + (accentBright[2] - burnB) * blend2;
const blend3 = smoothstep(0.5, 1, heatMasked);
burnR = burnR + (65535 - burnR) * blend3;
burnG = burnG + (65535 - burnG) * blend3;
burnB = burnB + (65535 - burnB) * blend3;
// Sparks: high-frequency noise above threshold
const sparks = (vnoise(ux * 80, uy * 80) >= 0.92 ? 1 : 0) * heatMasked * 3;
out.writeUInt16LE(
clamp16(
mix16(bR, aR, e) +
Math.round(burnR * heatMasked * 3.5) +
Math.round(accentBright[0] * sparks),
),
o,
);
out.writeUInt16LE(
clamp16(
mix16(bG, aG, e) +
Math.round(burnG * heatMasked * 3.5) +
Math.round(accentBright[1] * sparks),
),
o + 2,
);
out.writeUInt16LE(
clamp16(
mix16(bB, aB, e) +
Math.round(burnB * heatMasked * 3.5) +
Math.round(accentBright[2] * sparks),
),
o + 4,
);
}
};
TRANSITIONS["ridged-burn"] = ridgedBurn;