feat: add @hyperframes/shader-transitions package (#251)

## Summary

New `@hyperframes/shader-transitions` package that encapsulates WebGL shader transitions into a single `HyperShader.init()` call. Replaces ~200 lines of per-composition boilerplate that LLMs failed to wire correctly 60% of the time.

### API

```js
var tl = HyperShader.init({
  bgColor: "#0a0a1a",
  accentColor: "#6366f1",
  scenes: ["scene1", "scene2", "scene3", "scene4", "scene5"],
  transitions: [
    { time: 7.2, shader: "cross-warp-morph", duration: 0.7 },
    { time: 15.2, shader: "domain-warp", duration: 0.7 },
  ]
});
tl.from("#s1-title", { y: 50, opacity: 0, duration: 0.7 }, 0.3);
```

### What the library handles

- **13 shader programs**: domain-warp, ridged-burn, whip-pan, sdf-iris, ripple-waves, gravitational-lens, cinematic-zoom, chromatic-split, glitch, swirl-vortex, thermal-distortion, cross-warp-morph, light-leak
- **html2canvas** bundled as dependency (not CDN) — single script tag for CLI users
- **DOM-during-holds**: canvas hidden between transitions, GSAP animations play on live DOM
- **Async capture with pause/resume**: timeline pauses during capture, resumes after textures uploaded — prevents progress tween from running ahead
- **Accent color theming**: `accentColor` derives dark/mid/bright uniforms. Burns, glows, leaks match the composition palette
- **Graceful degradation**: falls back silently when WebGL unavailable

### Code quality (from 3 review agents)

- No `!` non-null assertions — all WebGL creation calls throw on failure
- Vertex shader compiled once, cached across all programs
- Uniform/attribute locations cached per program via WeakMap (not looked up every frame)
- Captured canvases freed after texture upload (8MB each)
- Single timeline creation (was creating two, discarding one)
- Shared `tickShader()` render callback (was copy-pasted)
- `.finally()` for DOM restore in capture (was duplicated in `.then`/`.catch`)
- `parseHex` validates input (was silently producing NaN on invalid hex)
- Dead `ND`/`CP` shader library exports removed

### Shader-compatible CSS rules (transitions.md)

6 rules for compositions using shader transitions:
1. No `transparent` in gradients (canvas interpolates through black)
2. No gradient backgrounds on elements < 4px
3. No CSS variables on captured elements
4. `data-no-capture` for uncapturable decoratives
5. No gradient opacity < 0.15
6. Every `.scene` must have explicit `background-color` matching `bgColor`

### Build output

- IIFE (~214KB with html2canvas bundled, ~65KB gzipped) — `window.HyperShader`
- ESM + CJS + TypeScript declarations
- tsup build following `@hyperframes/player` conventions

## Test plan
- [ ] `bun run build` succeeds (includes shader-transitions)
- [ ] `bunx oxlint packages/shader-transitions/src/` — 0 errors
- [ ] Create a composition using `HyperShader.init()` — verify transitions fire, DOM animations play, accent colors match
- [ ] Test graceful degradation: composition works without WebGL (no transitions, no crash)
- [ ] Verify pause/resume: scrub to transition boundary — no jump in progress

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Vance Ingalls
2026-04-13 18:44:00 -07:00
committed by GitHub
parent 5de2af5bde
commit cb3d94c2a5
13 changed files with 1037 additions and 366 deletions
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@hyperframes/shader-transitions",
"version": "0.2.4",
"description": "WebGL shader transitions for HyperFrames compositions",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
"directory": "packages/shader-transitions"
},
"files": [
"dist"
],
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"script": "./dist/index.global.js",
"import": "./src/index.ts",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"html2canvas": "^1.4.1"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,70 @@
import html2canvas from "html2canvas";
let patched = false;
function patchCreatePattern(): void {
if (patched) return;
patched = true;
const orig = CanvasRenderingContext2D.prototype.createPattern;
CanvasRenderingContext2D.prototype.createPattern = function (
image: CanvasImageSource,
repetition: string | null,
): CanvasPattern | null {
if (
image &&
"width" in image &&
"height" in image &&
((image as HTMLCanvasElement).width === 0 || (image as HTMLCanvasElement).height === 0)
) {
return null;
}
return orig.call(this, image, repetition);
};
}
export function initCapture(): void {
patchCreatePattern();
}
export function captureScene(sceneEl: HTMLElement, bgColor: string): Promise<HTMLCanvasElement> {
return html2canvas(sceneEl, {
width: 1920,
height: 1080,
scale: 1,
backgroundColor: bgColor,
logging: false,
ignoreElements: (el: Element) => el.tagName === "CANVAS" || el.hasAttribute("data-no-capture"),
});
}
/**
* Capture the incoming scene with .scene-content hidden (background + decoratives only).
* Shows the scene behind the outgoing scene via z-index, waits 2 rAFs for font rendering,
* captures, then restores.
*/
export function captureIncomingScene(
toScene: HTMLElement,
bgColor: string,
): Promise<HTMLCanvasElement> {
return new Promise<HTMLCanvasElement>((resolve, reject) => {
const origZ = toScene.style.zIndex;
const origOpacity = toScene.style.opacity;
toScene.style.zIndex = "-1";
toScene.style.opacity = "1";
const contentEl = toScene.querySelector<HTMLElement>(".scene-content");
if (contentEl) contentEl.style.visibility = "hidden";
const restore = () => {
if (contentEl) contentEl.style.visibility = "";
toScene.style.opacity = origOpacity;
toScene.style.zIndex = origZ;
};
requestAnimationFrame(() => {
requestAnimationFrame(() => {
captureScene(toScene, bgColor).then(resolve, reject).finally(restore);
});
});
});
}
@@ -0,0 +1,261 @@
import {
createContext,
setupQuad,
createProgram,
createTexture,
uploadTexture,
renderShader,
WIDTH,
HEIGHT,
type AccentColors,
} from "./webgl.js";
import { getFragSource, type ShaderName } from "./shaders/registry.js";
import { initCapture, captureScene, captureIncomingScene } from "./capture.js";
declare const gsap: {
timeline: (opts: Record<string, unknown>) => GsapTimeline;
};
interface GsapTimeline {
paused: () => boolean;
play: () => GsapTimeline;
pause: () => GsapTimeline;
call: (fn: () => void, args: null, position: number) => GsapTimeline;
to: (
target: Record<string, unknown>,
vars: Record<string, unknown>,
position: number,
) => GsapTimeline;
set: (target: string, vars: Record<string, unknown>, position?: number) => GsapTimeline;
from: (target: string, vars: Record<string, unknown>, position?: number) => GsapTimeline;
fromTo: (
target: string,
from: Record<string, unknown>,
to: Record<string, unknown>,
position?: number,
) => GsapTimeline;
[key: string]: unknown;
}
export interface TransitionConfig {
time: number;
shader: ShaderName;
duration?: number;
ease?: string;
}
export interface HyperShaderConfig {
bgColor: string;
accentColor?: string;
scenes: string[];
transitions: TransitionConfig[];
timeline?: GsapTimeline;
compositionId?: string;
}
interface TransState {
active: boolean;
prog: WebGLProgram | null;
fromId: string;
toId: string;
progress: number;
}
function parseHex(hex: string): [number, number, number] {
const h = hex.replace("#", "");
if (h.length < 6) return [0.5, 0.5, 0.5];
const r = parseInt(h.slice(0, 2), 16) / 255;
const g = parseInt(h.slice(2, 4), 16) / 255;
const b = parseInt(h.slice(4, 6), 16) / 255;
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return [0.5, 0.5, 0.5];
return [r, g, b];
}
function deriveAccentColors(hex: string): AccentColors {
const [r, g, b] = parseHex(hex);
return {
accent: [r, g, b],
dark: [r * 0.35, g * 0.35, b * 0.35],
bright: [Math.min(1, r * 1.5 + 0.2), Math.min(1, g * 1.5 + 0.2), Math.min(1, b * 1.5 + 0.2)],
};
}
export function init(config: HyperShaderConfig): GsapTimeline {
const { bgColor, scenes, transitions } = config;
const accentColors: AccentColors = config.accentColor
? deriveAccentColors(config.accentColor)
: { accent: [1, 0.6, 0.2], dark: [0.4, 0.15, 0], bright: [1, 0.85, 0.5] };
const root = document.querySelector<HTMLElement>("[data-composition-id]");
const compId = config.compositionId || root?.getAttribute("data-composition-id") || "main";
const state: TransState = {
active: false,
prog: null,
fromId: "",
toId: "",
progress: 0,
};
let glCanvas = document.getElementById("gl-canvas") as HTMLCanvasElement | null;
if (!glCanvas) {
glCanvas = document.createElement("canvas");
glCanvas.id = "gl-canvas";
glCanvas.width = WIDTH;
glCanvas.height = HEIGHT;
glCanvas.style.cssText = `position:absolute;top:0;left:0;width:${WIDTH}px;height:${HEIGHT}px;z-index:100;pointer-events:none;display:none;`;
(root || document.body).appendChild(glCanvas);
}
const gl = createContext(glCanvas);
if (!gl) {
console.warn("[HyperShader] WebGL unavailable — shader transitions disabled.");
const fallback = config.timeline || gsap.timeline({ paused: true });
registerTimeline(compId, fallback, config.timeline);
return fallback;
}
const quadBuf = setupQuad(gl);
const programs = new Map<string, WebGLProgram>();
for (const t of transitions) {
if (!programs.has(t.shader)) {
try {
programs.set(t.shader, createProgram(gl, getFragSource(t.shader)));
} catch (e) {
console.error(`[HyperShader] Failed to compile "${t.shader}":`, e);
}
}
}
const textures = new Map<string, WebGLTexture>();
for (const id of scenes) {
textures.set(id, createTexture(gl));
}
const tickShader = () => {
if (state.active && state.prog) {
const fromTex = textures.get(state.fromId);
const toTex = textures.get(state.toId);
if (fromTex && toTex) {
renderShader(gl, quadBuf, state.prog, fromTex, toTex, state.progress, accentColors);
}
}
};
let tl: GsapTimeline;
if (config.timeline) {
tl = config.timeline;
const duration = Number(root?.getAttribute("data-duration") || "40");
tl.to({ t: 0 }, { t: 1, duration, ease: "none", onUpdate: tickShader }, 0);
} else {
tl = gsap.timeline({ paused: true, onUpdate: tickShader });
}
initCapture();
glCanvas.style.display = "none";
const canvasEl = glCanvas;
for (let i = 0; i < transitions.length; i++) {
const t = transitions[i];
const fromId = scenes[i];
const toId = scenes[i + 1];
if (!fromId || !toId) continue;
const prog = programs.get(t.shader);
if (!prog) continue;
const dur = t.duration ?? 0.7;
const ease = t.ease ?? "power2.inOut";
const T = t.time;
// Pause timeline during async capture to prevent the progress tween
// from running ahead. Resume once textures are uploaded.
tl.call(
() => {
const fromScene = document.getElementById(fromId);
const toScene = document.getElementById(toId);
if (!fromScene || !toScene) return;
const wasPlaying = !tl.paused();
if (wasPlaying) tl.pause();
captureScene(fromScene, bgColor)
.then((fromCanvas) => {
const fromTex = textures.get(fromId);
if (fromTex) uploadTexture(gl, fromTex, fromCanvas);
return captureIncomingScene(toScene, bgColor);
})
.then((toCanvas) => {
const toTex = textures.get(toId);
if (toTex) uploadTexture(gl, toTex, toCanvas);
document.querySelectorAll<HTMLElement>(".scene").forEach((s) => {
s.style.opacity = "0";
});
canvasEl.style.display = "block";
state.prog = prog;
state.fromId = fromId;
state.toId = toId;
state.progress = 0;
state.active = true;
if (wasPlaying) tl.play();
})
.catch((e) => {
console.warn("[HyperShader] Capture failed, falling back to hard cut:", e);
document.querySelectorAll<HTMLElement>(".scene").forEach((s) => {
s.style.opacity = "0";
});
const scene = document.getElementById(toId);
if (scene) scene.style.opacity = "1";
if (wasPlaying) tl.play();
});
},
null,
T,
);
const proxy = { p: 0 };
tl.to(
proxy,
{
p: 1,
duration: dur,
ease,
onUpdate: () => {
state.progress = proxy.p;
},
},
T,
);
tl.call(
() => {
state.active = false;
canvasEl.style.display = "none";
const scene = document.getElementById(toId);
if (scene) scene.style.opacity = "1";
},
null,
T + dur,
);
}
registerTimeline(compId, tl, config.timeline);
return tl;
}
function registerTimeline(
compId: string,
tl: GsapTimeline,
provided: GsapTimeline | undefined,
): void {
if (!provided) {
const w = window as unknown as { __timelines: Record<string, unknown> };
w.__timelines = w.__timelines || {};
w.__timelines[compId] = tl;
}
}
+2
View File
@@ -0,0 +1,2 @@
export { init, type HyperShaderConfig, type TransitionConfig } from "./hyper-shader.js";
export { SHADER_NAMES, type ShaderName } from "./shaders/registry.js";
@@ -0,0 +1,26 @@
/** Vertex shader — flips Y for WebGL coordinate system */
export const vertSrc =
"attribute vec2 a_pos; varying vec2 v_uv; void main(){" +
"v_uv=a_pos*0.5+0.5; v_uv.y=1.0-v_uv.y; gl_Position=vec4(a_pos,0,1);}";
/** Shared uniform header — every fragment shader starts with this */
export const H =
"precision mediump float;" +
"varying vec2 v_uv;" +
"uniform sampler2D u_from, u_to;" +
"uniform float u_progress;" +
"uniform vec2 u_resolution;" +
"uniform vec3 u_accent;" +
"uniform vec3 u_accent_dark;" +
"uniform vec3 u_accent_bright;\n";
/** Quintic C2 noise + inter-octave rotation FBM */
export const NQ =
"float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}" +
"float vnoise(vec2 p){vec2 i=floor(p),f=fract(p);" +
"f=f*f*f*(f*(f*6.-15.)+10.);" +
"return mix(mix(hash(i),hash(i+vec2(1,0)),f.x)," +
"mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);}" +
"float fbm(vec2 p){float v=0.,a=.5;" +
"mat2 R=mat2(.8,.6,-.6,.8);" +
"for(int i=0;i<5;i++){v+=a*vnoise(p);p=R*p*2.02;a*=.5;}return v;}";
@@ -0,0 +1,249 @@
import { H, NQ } from "./common.js";
interface ShaderDef {
frag: string;
}
const shaders: Record<string, ShaderDef> = {
"domain-warp": {
frag:
H +
NQ +
"void main(){" +
"vec2 q=vec2(fbm(v_uv*3.),fbm(v_uv*3.+vec2(5.2,1.3)));" +
"vec2 r=vec2(fbm(v_uv*3.+q*4.+vec2(1.7,9.2)),fbm(v_uv*3.+q*4.+vec2(8.3,2.8)));" +
"float n=fbm(v_uv*3.+r*2.);" +
"vec2 warpDir=(q-.5)*.4;" +
"vec4 A=texture2D(u_from,clamp(v_uv+warpDir*u_progress,0.,1.));" +
"vec4 B=texture2D(u_to,clamp(v_uv-warpDir*(1.-u_progress),0.,1.));" +
"float e=smoothstep(u_progress-.08,u_progress+.08,n);" +
"float ed=abs(n-u_progress);" +
"float em=smoothstep(.1,0.,ed)*(1.-step(1.,u_progress));" +
"vec3 ec=mix(u_accent_dark,u_accent_bright,smoothstep(0.,.1,ed));" +
"gl_FragColor=vec4(mix(B,A,e).rgb+ec*em*2.,1.);}",
},
"ridged-burn": {
frag:
H +
NQ +
"float ridged(vec2 p){float v=0.,a=.5;mat2 R=mat2(.8,.6,-.6,.8);" +
"for(int i=0;i<5;i++){v+=a*abs(vnoise(p)*2.-1.);p=R*p*2.02;a*=.5;}return v;}" +
"void main(){vec4 A=texture2D(u_from,v_uv),B=texture2D(u_to,v_uv);" +
"float n=ridged(v_uv*4.);" +
"float e=smoothstep(u_progress-.04,u_progress+.04,n);" +
"float heat=smoothstep(.12,0.,abs(n-u_progress))*(1.-step(1.,u_progress));" +
"vec3 burn=mix(u_accent_dark,u_accent,smoothstep(0.,.25,heat));" +
"burn=mix(burn,u_accent_bright,smoothstep(.25,.5,heat));" +
"burn=mix(burn,vec3(1),smoothstep(.5,1.,heat));" +
"float sparks=step(.92,vnoise(v_uv*80.))*heat*3.;" +
"gl_FragColor=vec4(mix(B,A,e).rgb+burn*heat*3.5+u_accent_bright*sparks,1.);}",
},
"whip-pan": {
frag:
H +
"void main(){" +
"float fromOff=u_progress*1.5;vec3 fromC=vec3(0.);" +
"for(int i=0;i<10;i++){float f=float(i)/10.;" +
"vec2 fuv=vec2(v_uv.x+fromOff+u_progress*.08*f,v_uv.y);" +
"fromC+=texture2D(u_from,clamp(fuv,0.,1.)).rgb;}fromC/=10.;" +
"float toOff=(1.-u_progress)*1.5;vec3 toC=vec3(0.);" +
"for(int i=0;i<10;i++){float f=float(i)/10.;" +
"vec2 tuv=vec2(v_uv.x-toOff-(1.-u_progress)*.08*f,v_uv.y);" +
"toC+=texture2D(u_to,clamp(tuv,0.,1.)).rgb;}toC/=10.;" +
"gl_FragColor=vec4(mix(fromC,toC,u_progress),1.);}",
},
"sdf-iris": {
frag:
H +
"void main(){vec4 A=texture2D(u_from,v_uv),B=texture2D(u_to,v_uv);" +
"vec2 uv=(v_uv-.5)*vec2(u_resolution.x/u_resolution.y,1.);" +
"float d=length(uv);float radius=u_progress*1.2;float fw=.003;" +
"float edge=smoothstep(radius+fw,radius-fw,d);" +
"float ring1=exp(-abs(d-radius)*25.);" +
"float ring2=exp(-abs(d-radius+.04)*20.)*.5;" +
"float ring3=exp(-abs(d-radius+.08)*15.)*.25;" +
"float glow=(ring1+ring2+ring3)*u_progress*(1.-u_progress)*4.;" +
"gl_FragColor=vec4(mix(A,B,edge).rgb+u_accent_bright*glow*.6,1.);}",
},
"ripple-waves": {
frag:
H +
"void main(){vec2 uv=v_uv-.5;float dist=length(uv);vec2 dir=normalize(uv+.001);" +
"float fromAmp=u_progress*.04;" +
"float fw1=exp(sin(dist*25.-u_progress*12.)-1.);" +
"float fw2=exp(sin(dist*50.-u_progress*18.)-1.)*.5;" +
"vec2 fromUv=clamp(v_uv+dir*(fw1+fw2)*fromAmp,0.,1.);" +
"float toAmp=(1.-u_progress)*.04;" +
"float tw1=exp(sin(dist*25.+u_progress*12.)-1.);" +
"float tw2=exp(sin(dist*50.+u_progress*18.)-1.)*.5;" +
"vec2 toUv=clamp(v_uv-dir*(tw1+tw2)*toAmp,0.,1.);" +
"vec4 A=texture2D(u_from,fromUv);vec4 B=texture2D(u_to,toUv);" +
"float peak=fw1*u_progress;vec3 tint=u_accent_bright*peak*.1;" +
"gl_FragColor=vec4(mix(A.rgb+tint,B.rgb,u_progress),1.);}",
},
"gravitational-lens": {
frag:
H +
"void main(){vec4 B=texture2D(u_to,v_uv);" +
"vec2 uv=v_uv-.5;float dist=length(uv);float pull=u_progress*2.;" +
"float warpStr=pull*.3/(dist+.1);" +
"vec2 warped=clamp(v_uv-uv*warpStr,0.,1.);" +
"vec4 A=texture2D(u_from,warped);" +
"float horizon=smoothstep(0.,.3,dist/(1.-u_progress*.85+.001));" +
"float shift=pull*.02/(dist+.2);" +
"float r=texture2D(u_from,clamp(v_uv-uv*(warpStr+shift),0.,1.)).r;" +
"float b=texture2D(u_from,clamp(v_uv-uv*(warpStr-shift),0.,1.)).b;" +
"vec3 lensed=vec3(r,A.g,b)*horizon;" +
"gl_FragColor=vec4(mix(lensed,B.rgb,smoothstep(.3,.9,u_progress)),1.);}",
},
"cinematic-zoom": {
frag:
H +
"void main(){vec2 d=v_uv-vec2(.5);" +
"float fromS=u_progress*.08;float toS=(1.-u_progress)*.06;" +
"float fr=0.,fg=0.,fb=0.;" +
"for(int i=0;i<12;i++){float f=float(i)/12.;" +
"fr+=texture2D(u_from,v_uv-d*(fromS*1.06)*f).r;" +
"fg+=texture2D(u_from,v_uv-d*fromS*f).g;" +
"fb+=texture2D(u_from,v_uv-d*(fromS*.94)*f).b;}" +
"vec3 fromBl=vec3(fr,fg,fb)/12.;" +
"float tr=0.,tg=0.,tb=0.;" +
"for(int i=0;i<12;i++){float f=float(i)/12.;" +
"tr+=texture2D(u_to,v_uv+d*(toS*1.06)*f).r;" +
"tg+=texture2D(u_to,v_uv+d*toS*f).g;" +
"tb+=texture2D(u_to,v_uv+d*(toS*.94)*f).b;}" +
"vec3 toBl=vec3(tr,tg,tb)/12.;" +
"gl_FragColor=vec4(mix(fromBl,toBl,u_progress),1.);}",
},
"chromatic-split": {
frag:
H +
"void main(){vec2 c=v_uv-.5;" +
"float fromShift=u_progress*.06;" +
"float fr=texture2D(u_from,clamp(v_uv+c*fromShift,0.,1.)).r;" +
"float fg=texture2D(u_from,v_uv).g;" +
"float fb=texture2D(u_from,clamp(v_uv-c*fromShift,0.,1.)).b;" +
"vec3 fromSplit=vec3(fr,fg,fb);" +
"float toShift=(1.-u_progress)*.06;" +
"float tr=texture2D(u_to,clamp(v_uv-c*toShift,0.,1.)).r;" +
"float tg=texture2D(u_to,v_uv).g;" +
"float tb=texture2D(u_to,clamp(v_uv+c*toShift,0.,1.)).b;" +
"vec3 toSplit=vec3(tr,tg,tb);" +
"gl_FragColor=vec4(mix(fromSplit,toSplit,u_progress),1.);}",
},
glitch: {
frag:
H +
"float rand(vec2 co){return fract(sin(dot(co,vec2(12.9898,78.233)))*43758.5453);}" +
"void main(){float inten=u_progress*(1.-u_progress)*4.;" +
"float lineY=floor(v_uv.y*60.)/60.;" +
"float lineDisp=(rand(vec2(lineY,floor(u_progress*17.)))-.5)*.18*inten;" +
"vec2 block=floor(v_uv*vec2(12.,8.));" +
"float br=rand(block+vec2(floor(u_progress*11.)));" +
"float ba=step(.83,br)*inten;" +
"vec2 bd=(vec2(rand(block*2.1),rand(block*3.7))-.5)*.35*ba;" +
"vec2 uv=clamp(v_uv+vec2(lineDisp,0.)+bd,0.,1.);" +
"float shift=inten*.035;" +
"float r=texture2D(u_from,uv+vec2(shift,0.)).r;" +
"float g=texture2D(u_from,uv).g;" +
"float b=texture2D(u_from,uv-vec2(shift,0.)).b;" +
"vec3 col=vec3(r,g,b);" +
"col-=step(.5,fract(v_uv.y*u_resolution.y*.5))*.05*inten;" +
"col*=1.+(rand(vec2(floor(u_progress*23.)))-.5)*.3*inten;" +
"float levels=mix(256.,8.,inten*.5);" +
"col=floor(col*levels)/levels;" +
"gl_FragColor=mix(vec4(col,1.),texture2D(u_to,v_uv),u_progress);}",
},
"swirl-vortex": {
frag:
H +
NQ +
"void main(){vec2 uv=v_uv-.5;float dist=length(uv);" +
"float warp=fbm(v_uv*4.)*.5;" +
"float fromAng=u_progress*(1.-dist)*10.+warp*u_progress*3.;" +
"float fs=sin(fromAng),fc=cos(fromAng);" +
"vec2 fromUv=clamp(vec2(uv.x*fc-uv.y*fs,uv.x*fs+uv.y*fc)+.5,0.,1.);" +
"float toAng=-(1.-u_progress)*(1.-dist)*10.-warp*(1.-u_progress)*3.;" +
"float ts=sin(toAng),tc=cos(toAng);" +
"vec2 toUv=clamp(vec2(uv.x*tc-uv.y*ts,uv.x*ts+uv.y*tc)+.5,0.,1.);" +
"vec4 A=texture2D(u_from,fromUv);vec4 B=texture2D(u_to,toUv);" +
"gl_FragColor=mix(A,B,u_progress);}",
},
"thermal-distortion": {
frag:
H +
NQ +
"void main(){float heat=u_progress*1.5;" +
"float yFade=smoothstep(1.,0.,v_uv.y);" +
"float shimmer=sin(v_uv.y*40.+fbm(v_uv*6.)*8.)*fbm(v_uv*3.+vec2(0.,u_progress*2.));" +
"float dispX=shimmer*heat*.03*yFade;" +
"vec2 fromUv=clamp(v_uv+vec2(dispX,0.),0.,1.);" +
"vec4 A=texture2D(u_from,fromUv);" +
"float invShimmer=sin(v_uv.y*40.+fbm(v_uv*6.+3.)*8.)*fbm(v_uv*3.+vec2(3.,u_progress*2.));" +
"float dispX2=invShimmer*(1.-u_progress)*.03*yFade;" +
"vec2 toUv=clamp(v_uv+vec2(dispX2,0.),0.,1.);" +
"vec4 B=texture2D(u_to,toUv);" +
"float haze=heat*yFade*.15*(1.-u_progress);" +
"gl_FragColor=vec4(mix(A.rgb,B.rgb,u_progress)+u_accent_bright*haze,1.);}",
},
"flash-through-white": {
frag:
H +
"void main(){vec4 A=texture2D(u_from,v_uv),B=texture2D(u_to,v_uv);" +
"float toWhite=smoothstep(0.,.45,u_progress);" +
"vec3 fromC=mix(A.rgb,vec3(1.),toWhite);" +
"float fromWhite=1.-smoothstep(.5,1.,u_progress);" +
"vec3 toC=mix(B.rgb,vec3(1.),fromWhite);" +
"gl_FragColor=vec4(mix(fromC,toC,smoothstep(.35,.65,u_progress)),1.);}",
},
"cross-warp-morph": {
frag:
H +
NQ +
"void main(){vec2 disp=vec2(fbm(v_uv*3.),fbm(v_uv*3.+vec2(7.3,3.7)))-.5;" +
"vec2 fromUv=clamp(v_uv+disp*u_progress*.5,0.,1.);" +
"vec2 toUv=clamp(v_uv-disp*(1.-u_progress)*.5,0.,1.);" +
"vec4 A=texture2D(u_from,fromUv);vec4 B=texture2D(u_to,toUv);" +
"float n=fbm(v_uv*4.+vec2(3.1,1.7));" +
"float blend=smoothstep(.4,.6,n+u_progress*1.2-.6);" +
"gl_FragColor=mix(A,B,blend);}",
},
"light-leak": {
frag:
H +
"vec3 aces(vec3 x){return clamp((x*(2.51*x+.03))/(x*(2.43*x+.59)+.14),0.,1.);}" +
"void main(){vec4 A=texture2D(u_from,v_uv),B=texture2D(u_to,v_uv);" +
"vec2 lp=vec2(1.3,-.2);float dist=length(v_uv-lp);" +
"float leak=clamp(exp(-dist*1.8)*u_progress*4.,0.,1.);" +
"vec3 warmColor=mix(u_accent,u_accent_bright,dist*.7);" +
"float flare=exp(-abs(v_uv.y-(-.2+v_uv.x*.3))*15.)*leak*.3;" +
"vec3 overexposed=A.rgb+warmColor*leak*3.+u_accent_bright*flare;" +
"overexposed=aces(overexposed);" +
"gl_FragColor=vec4(mix(overexposed,B.rgb,smoothstep(.15,.85,u_progress)),1.);}",
},
};
export type ShaderName = keyof typeof shaders;
export const SHADER_NAMES = Object.keys(shaders) as ShaderName[];
export function getFragSource(name: string): string {
const def = shaders[name];
if (!def)
throw new Error(
`[HyperShader] Unknown shader: "${name}". Available: ${SHADER_NAMES.join(", ")}`,
);
return def.frag;
}
+137
View File
@@ -0,0 +1,137 @@
import { vertSrc } from "./shaders/common.js";
export const WIDTH = 1920;
export const HEIGHT = 1080;
export function createContext(canvas: HTMLCanvasElement): WebGLRenderingContext | null {
const gl = canvas.getContext("webgl", { preserveDrawingBuffer: true });
if (!gl) return null;
gl.viewport(0, 0, WIDTH, HEIGHT);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
return gl as WebGLRenderingContext;
}
export function setupQuad(gl: WebGLRenderingContext): WebGLBuffer {
const buf = gl.createBuffer();
if (!buf) throw new Error("[HyperShader] Failed to create quad buffer");
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
return buf;
}
let cachedVertexShader: WebGLShader | null = null;
function compileShader(gl: WebGLRenderingContext, src: string, type: number): WebGLShader {
const s = gl.createShader(type);
if (!s) throw new Error("[HyperShader] Failed to create shader");
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
throw new Error(`[HyperShader] Shader compile: ${gl.getShaderInfoLog(s) || "unknown"}`);
}
return s;
}
export function createProgram(gl: WebGLRenderingContext, fragSrc: string): WebGLProgram {
if (!cachedVertexShader) {
cachedVertexShader = compileShader(gl, vertSrc, gl.VERTEX_SHADER);
}
const p = gl.createProgram();
if (!p) throw new Error("[HyperShader] Failed to create program");
gl.attachShader(p, cachedVertexShader);
gl.attachShader(p, compileShader(gl, fragSrc, gl.FRAGMENT_SHADER));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
throw new Error(`[HyperShader] Program link: ${gl.getProgramInfoLog(p) || "unknown"}`);
}
return p;
}
export interface AccentColors {
accent: [number, number, number];
dark: [number, number, number];
bright: [number, number, number];
}
interface ProgramLocations {
from: WebGLUniformLocation | null;
to: WebGLUniformLocation | null;
progress: WebGLUniformLocation | null;
resolution: WebGLUniformLocation | null;
accent: WebGLUniformLocation | null;
accentDark: WebGLUniformLocation | null;
accentBright: WebGLUniformLocation | null;
aPos: number;
}
const locationsCache = new WeakMap<WebGLProgram, ProgramLocations>();
function getLocations(gl: WebGLRenderingContext, prog: WebGLProgram): ProgramLocations {
let loc = locationsCache.get(prog);
if (loc) return loc;
loc = {
from: gl.getUniformLocation(prog, "u_from"),
to: gl.getUniformLocation(prog, "u_to"),
progress: gl.getUniformLocation(prog, "u_progress"),
resolution: gl.getUniformLocation(prog, "u_resolution"),
accent: gl.getUniformLocation(prog, "u_accent"),
accentDark: gl.getUniformLocation(prog, "u_accent_dark"),
accentBright: gl.getUniformLocation(prog, "u_accent_bright"),
aPos: gl.getAttribLocation(prog, "a_pos"),
};
locationsCache.set(prog, loc);
return loc;
}
export function renderShader(
gl: WebGLRenderingContext,
quadBuf: WebGLBuffer,
prog: WebGLProgram,
texFrom: WebGLTexture,
texTo: WebGLTexture,
progress: number,
colors?: AccentColors,
): void {
const loc = getLocations(gl, prog);
gl.useProgram(prog);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texFrom);
gl.uniform1i(loc.from, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, texTo);
gl.uniform1i(loc.to, 1);
gl.uniform1f(loc.progress, progress);
gl.uniform2f(loc.resolution, WIDTH, HEIGHT);
if (colors) {
gl.uniform3f(loc.accent, ...colors.accent);
gl.uniform3f(loc.accentDark, ...colors.dark);
gl.uniform3f(loc.accentBright, ...colors.bright);
}
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.enableVertexAttribArray(loc.aPos);
gl.vertexAttribPointer(loc.aPos, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
export function createTexture(gl: WebGLRenderingContext): WebGLTexture {
const tex = gl.createTexture();
if (!tex) throw new Error("[HyperShader] Failed to create texture");
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
return tex;
}
export function uploadTexture(
gl: WebGLRenderingContext,
tex: WebGLTexture,
canvas: HTMLCanvasElement,
): void {
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
canvas.width = 0;
canvas.height = 0;
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
@@ -0,0 +1,12 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs", "iife"],
globalName: "HyperShader",
noExternal: ["html2canvas"],
dts: true,
clean: true,
minify: true,
sourcemap: true,
});