mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
* 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>
1131 lines
42 KiB
TypeScript
1131 lines
42 KiB
TypeScript
/**
|
||
* 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 (0–1 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 (0–65535)
|
||
*/
|
||
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;
|