From cb3d94c2a50b921bcca71445d3e3bc9dbfc35beb Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 13 Apr 2026 18:44:00 -0700 Subject: [PATCH] feat: add @hyperframes/shader-transitions package (#251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- bun.lock | 27 +- packages/shader-transitions/package.json | 37 ++ packages/shader-transitions/src/capture.ts | 70 +++ .../shader-transitions/src/hyper-shader.ts | 261 ++++++++++ packages/shader-transitions/src/index.ts | 2 + .../shader-transitions/src/shaders/common.ts | 26 + .../src/shaders/registry.ts | 249 ++++++++++ packages/shader-transitions/src/webgl.ts | 137 ++++++ packages/shader-transitions/tsconfig.json | 17 + packages/shader-transitions/tsup.config.ts | 12 + skills/hyperframes/references/transitions.md | 90 ++-- .../references/transitions/catalog.md | 12 +- .../references/transitions/shader-setup.md | 463 ++++++------------ 13 files changed, 1037 insertions(+), 366 deletions(-) create mode 100644 packages/shader-transitions/package.json create mode 100644 packages/shader-transitions/src/capture.ts create mode 100644 packages/shader-transitions/src/hyper-shader.ts create mode 100644 packages/shader-transitions/src/index.ts create mode 100644 packages/shader-transitions/src/shaders/common.ts create mode 100644 packages/shader-transitions/src/shaders/registry.ts create mode 100644 packages/shader-transitions/src/webgl.ts create mode 100644 packages/shader-transitions/tsconfig.json create mode 100644 packages/shader-transitions/tsup.config.ts diff --git a/bun.lock b/bun.lock index b14329f75..2ea128e62 100644 --- a/bun.lock +++ b/bun.lock @@ -102,7 +102,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.2.2", + "version": "0.2.4", "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.0.0", @@ -147,6 +147,18 @@ "typescript": "^5.7.2", }, }, + "packages/shader-transitions": { + "name": "@hyperframes/shader-transitions", + "version": "0.2.4", + "dependencies": { + "html2canvas": "^1.4.1", + }, + "devDependencies": { + "tsup": "^8.0.0", + "typescript": "^5.0.0", + "vitest": "^3.2.4", + }, + }, "packages/studio": { "name": "@hyperframes/studio", "version": "0.2.4", @@ -162,6 +174,7 @@ "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.40.0", "@hyperframes/core": "workspace:*", + "@hyperframes/player": "workspace:*", "@phosphor-icons/react": "^2.1.10", "codemirror": "^6.0.1", "motion": "^12.38.0", @@ -426,6 +439,8 @@ "@hyperframes/producer": ["@hyperframes/producer@workspace:packages/producer"], + "@hyperframes/shader-transitions": ["@hyperframes/shader-transitions@workspace:packages/shader-transitions"], + "@hyperframes/studio": ["@hyperframes/studio@workspace:packages/studio"], "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "5.1.2", "string-width-cjs": "npm:string-width@4.2.3", "strip-ansi": "7.2.0", "strip-ansi-cjs": "npm:strip-ansi@6.0.1", "wrap-ansi": "8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], @@ -782,6 +797,8 @@ "bare-url": ["bare-url@2.4.0", "", { "dependencies": { "bare-path": "3.0.0" } }, "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA=="], + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg=="], "basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="], @@ -860,6 +877,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "3.1.1", "shebang-command": "2.0.0", "which": "2.0.2" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "1.0.0", "css-what": "6.2.2", "domhandler": "5.0.3", "domutils": "3.2.2", "nth-check": "2.1.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], @@ -1006,6 +1025,8 @@ "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], + "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "2.3.0", "domhandler": "5.0.3", "domutils": "3.2.2", "entities": "7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "7.1.4", "debug": "4.4.3" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], @@ -1358,6 +1379,8 @@ "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "1.8.0" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "1.3.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": "3.3.1" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], @@ -1412,6 +1435,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.3", "es-module-lexer": "1.7.0", "pathe": "2.0.3", "vite": "5.4.21" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], diff --git a/packages/shader-transitions/package.json b/packages/shader-transitions/package.json new file mode 100644 index 000000000..6ee527ad8 --- /dev/null +++ b/packages/shader-transitions/package.json @@ -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" + } +} diff --git a/packages/shader-transitions/src/capture.ts b/packages/shader-transitions/src/capture.ts new file mode 100644 index 000000000..1ec563e2d --- /dev/null +++ b/packages/shader-transitions/src/capture.ts @@ -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 { + 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 { + return new Promise((resolve, reject) => { + const origZ = toScene.style.zIndex; + const origOpacity = toScene.style.opacity; + toScene.style.zIndex = "-1"; + toScene.style.opacity = "1"; + + const contentEl = toScene.querySelector(".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); + }); + }); + }); +} diff --git a/packages/shader-transitions/src/hyper-shader.ts b/packages/shader-transitions/src/hyper-shader.ts new file mode 100644 index 000000000..0d077ef9e --- /dev/null +++ b/packages/shader-transitions/src/hyper-shader.ts @@ -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) => GsapTimeline; +}; + +interface GsapTimeline { + paused: () => boolean; + play: () => GsapTimeline; + pause: () => GsapTimeline; + call: (fn: () => void, args: null, position: number) => GsapTimeline; + to: ( + target: Record, + vars: Record, + position: number, + ) => GsapTimeline; + set: (target: string, vars: Record, position?: number) => GsapTimeline; + from: (target: string, vars: Record, position?: number) => GsapTimeline; + fromTo: ( + target: string, + from: Record, + to: Record, + 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("[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(); + 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(); + 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(".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(".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 }; + w.__timelines = w.__timelines || {}; + w.__timelines[compId] = tl; + } +} diff --git a/packages/shader-transitions/src/index.ts b/packages/shader-transitions/src/index.ts new file mode 100644 index 000000000..184c26c7e --- /dev/null +++ b/packages/shader-transitions/src/index.ts @@ -0,0 +1,2 @@ +export { init, type HyperShaderConfig, type TransitionConfig } from "./hyper-shader.js"; +export { SHADER_NAMES, type ShaderName } from "./shaders/registry.js"; diff --git a/packages/shader-transitions/src/shaders/common.ts b/packages/shader-transitions/src/shaders/common.ts new file mode 100644 index 000000000..37df537c2 --- /dev/null +++ b/packages/shader-transitions/src/shaders/common.ts @@ -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;}"; diff --git a/packages/shader-transitions/src/shaders/registry.ts b/packages/shader-transitions/src/shaders/registry.ts new file mode 100644 index 000000000..26bfac777 --- /dev/null +++ b/packages/shader-transitions/src/shaders/registry.ts @@ -0,0 +1,249 @@ +import { H, NQ } from "./common.js"; + +interface ShaderDef { + frag: string; +} + +const shaders: Record = { + "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; +} diff --git a/packages/shader-transitions/src/webgl.ts b/packages/shader-transitions/src/webgl.ts new file mode 100644 index 000000000..1ed23b4ca --- /dev/null +++ b/packages/shader-transitions/src/webgl.ts @@ -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(); + +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; +} diff --git a/packages/shader-transitions/tsconfig.json b/packages/shader-transitions/tsconfig.json new file mode 100644 index 000000000..d1972c9fd --- /dev/null +++ b/packages/shader-transitions/tsconfig.json @@ -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"] +} diff --git a/packages/shader-transitions/tsup.config.ts b/packages/shader-transitions/tsup.config.ts new file mode 100644 index 000000000..c4c7c3b6e --- /dev/null +++ b/packages/shader-transitions/tsup.config.ts @@ -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, +}); diff --git a/skills/hyperframes/references/transitions.md b/skills/hyperframes/references/transitions.md index 624e6603f..415b24c41 100644 --- a/skills/hyperframes/references/transitions.md +++ b/skills/hyperframes/references/transitions.md @@ -1,14 +1,23 @@ # Scene Transitions -A transition tells the viewer how two scenes relate. A crossfade says "this continues." A push slide says "next point." A flash cut says "wake up." A blur crossfade says "drift with me." Choose transitions that match what the content is doing emotionally, not just technically. +A transition tells the viewer how two scenes relate. A crossfade says "this continues." A push slide says "next point." A blur crossfade says "drift with me." Choose transitions that match what the content is doing emotionally, not just technically. + +## Animation Rules for Multi-Scene Compositions + +These are non-negotiable for every multi-scene composition: + +1. **Every composition uses transitions.** No exceptions. Scenes without transitions feel like jump cuts. +2. **Every scene uses entrance animations.** Elements animate IN via `gsap.from()` — opacity, position, scale, etc. No scene should pop fully-formed onto screen. +3. **Exit animations are BANNED** except on the final scene. Do NOT use `gsap.to()` to animate elements out before a transition fires. The transition IS the exit. Outgoing scene content must be fully visible when the transition starts — the transition handles the visual handoff. +4. **Final scene exception:** The last scene MAY fade elements out (e.g., fade to black at the end of the composition). This is the only scene where exit animations are allowed. ## Energy → Primary Transition -| Energy | Primary | Accent for key moments | Duration | Easing | -| ---------------------------------------- | ---------------------------- | ------------------------------ | --------- | ---------------------- | -| **Calm** (wellness, brand story, luxury) | Blur crossfade, focus pull | Light leak, circle iris | 0.5-0.8s | `sine.inOut`, `power1` | -| **Medium** (corporate, SaaS, explainer) | Push slide, staggered blocks | Squeeze, vertical push | 0.3-0.5s | `power2`, `power3` | -| **High** (promos, sports, music, launch) | Flash cut, zoom through | Staggered blocks, gravity drop | 0.15-0.3s | `power4`, `expo` | +| Energy | CSS Primary | Shader Primary | Accent | Duration | Easing | +| ---------------------------------------- | ---------------------------- | ------------------------------------ | ------------------------------ | --------- | ---------------------- | +| **Calm** (wellness, brand story, luxury) | Blur crossfade, focus pull | Cross-warp morph, thermal distortion | Light leak, circle iris | 0.5-0.8s | `sine.inOut`, `power1` | +| **Medium** (corporate, SaaS, explainer) | Push slide, staggered blocks | Whip pan, cinematic zoom | Squeeze, vertical push | 0.3-0.5s | `power2`, `power3` | +| **High** (promos, sports, music, launch) | Zoom through, overexposure | Ridged burn, glitch, chromatic split | Staggered blocks, gravity drop | 0.15-0.3s | `power4`, `expo` | Pick ONE primary (60-70% of scene changes) + 1-2 accents. Never use a different transition for every scene. @@ -16,17 +25,17 @@ Pick ONE primary (60-70% of scene changes) + 1-2 accents. Never use a different Think about what the transition _communicates_, not just what it looks like. -| Mood | Transitions | Why it works | -| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| **Warm / inviting** | Light leak, blur crossfade, focus pull, film burn, light leak (shader), thermal distortion | Soft edges, warm color washes. Nothing sharp or mechanical. The transition feels like sunlight. | -| **Cold / clinical** | Squeeze, zoom out, blinds, shutter, grid dissolve, gravitational lens | Content transforms mechanically — compressed, shrunk, sliced, gridded. Zoom out creates clinical distance. No softness. | -| **Editorial / magazine** | Push slide, vertical push, diagonal split, shutter, whip pan | Like turning a page or slicing a layout. Clean directional movement. Whip pan is a fast editorial camera move. | -| **Tech / futuristic** | Grid dissolve, staggered blocks, blinds, chromatic aberration, glitch (shader), chromatic split (shader) | Grid dissolve is the core "data" transition. Shader glitch adds posterization + scan lines. | -| **Tense / edgy** | Glitch, VHS, chromatic aberration, flash cut, ripple, ridged burn, glitch (shader) | Instability, distortion, digital breakdown. Ridged burn adds sharp lightning-crack edges. | -| **Playful / fun** | Elastic push, 3D flip, circle iris, morph circle, clock wipe, ripple waves, swirl vortex | Overshoot, bounce, rotation, expansion. Swirl vortex adds organic spiral distortion. | -| **Dramatic / cinematic** | Zoom through, zoom out, gravity drop, overexposure, diagonal split, color dip to black, cinematic zoom (shader), gravitational lens, domain warp, flash through white | Scale, weight, light extremes. Shader transitions add per-pixel depth. | -| **Premium / luxury** | Focus pull, blur crossfade, color dip to black, slow crossfade, cross-warp morph, thermal distortion | Restraint. Cross-warp morph flows both scenes into each other organically. | -| **Retro / analog** | Film burn, light leak, VHS, clock wipe | Organic imperfection. Warm color bleeds, scan line displacement. Clock wipe evokes broadcast TV. | +| Mood | Transitions | Why it works | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | +| **Warm / inviting** | Light leak, blur crossfade, focus pull, film burn · **Shader:** thermal distortion, light leak, cross-warp morph | Soft edges, warm color washes. Nothing sharp or mechanical. | +| **Cold / clinical** | Squeeze, zoom out, blinds, shutter, grid dissolve · **Shader:** gravitational lens | Content transforms mechanically — compressed, shrunk, sliced, gridded. | +| **Editorial / magazine** | Push slide, vertical push, diagonal split, shutter · **Shader:** whip pan | Like turning a page or slicing a layout. Clean directional movement. | +| **Tech / futuristic** | Grid dissolve, staggered blocks, blinds, chromatic aberration · **Shader:** glitch, chromatic split | Grid dissolve is the core "data" transition. Shader glitch adds posterization + scan lines. | +| **Tense / edgy** | Glitch, VHS, chromatic aberration, ripple · **Shader:** ridged burn, glitch, domain warp | Instability, distortion, digital breakdown. Ridged burn adds sharp lightning-crack edges. | +| **Playful / fun** | Elastic push, 3D flip, circle iris, morph circle, clock wipe · **Shader:** ripple waves, swirl vortex | Overshoot, bounce, rotation, expansion. Swirl vortex adds organic spiral distortion. | +| **Dramatic / cinematic** | Zoom through, zoom out, gravity drop, overexposure, color dip to black · **Shader:** cinematic zoom, gravitational lens, domain warp | Scale, weight, light extremes. Shader transitions add per-pixel depth. | +| **Premium / luxury** | Focus pull, blur crossfade, color dip to black · **Shader:** cross-warp morph, thermal distortion | Restraint. Cross-warp morph flows both scenes into each other organically. | +| **Retro / analog** | Film burn, light leak, VHS, clock wipe · **Shader:** light leak | Organic imperfection. Warm color bleeds, scan line displacement. | ## Narrative Position @@ -62,34 +71,41 @@ Think about what the transition _communicates_, not just what it looks like. Read [transitions/catalog.md](transitions/catalog.md) for GSAP code and hard rules for every transition type. -| Category | Transitions | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Content-transforming | Push slide, vertical push, elastic push, squeeze, zoom through, zoom out, gravity drop, 3D flip | -| Reveal/mask | Circle iris, diamond iris, diagonal split, clock wipe, shutter | -| Dissolve | Crossfade, blur crossfade, focus pull, color dip | -| Cover | Staggered blocks, horizontal blinds, vertical blinds | -| Light | Light leak, overexposure burn, film burn | -| Distortion | Glitch, chromatic aberration, ripple, VHS tape | -| Pattern | Grid dissolve | -| Instant | Flash cut, morph circle | -| Shader (WebGL) | Domain warp, ridged burn, whip pan, SDF iris, ripple waves, gravitational lens, cinematic zoom, chromatic split, glitch, swirl vortex, thermal distortion, flash through white, cross-warp morph, light leak (shader) | +| Category | CSS | Shader (WebGL) | +| ----------- | -------------------------------------------------------------- | ------------------------------------------------------------------------- | +| Push/slide | Push slide, vertical push, elastic push, squeeze | Whip pan | +| Scale/zoom | Zoom through, zoom out, gravity drop, 3D flip | Cinematic zoom, gravitational lens | +| Reveal/mask | Circle iris, diamond iris, diagonal split, clock wipe, shutter | SDF iris | +| Dissolve | Crossfade, blur crossfade, focus pull, color dip | Cross-warp morph, domain warp | +| Cover | Staggered blocks, horizontal blinds, vertical blinds | — | +| Light | Light leak, overexposure burn, film burn | Light leak (shader), thermal distortion | +| Distortion | Glitch, chromatic aberration, ripple, VHS tape | Glitch (shader), chromatic split, ridged burn, ripple waves, swirl vortex | +| Pattern | Grid dissolve, morph circle | — | ## Transitions That Don't Work in CSS Avoid: star iris, tilt-shift, lens flare, hinge/door. See catalog.md for why. -## CSS vs Shader: When to Use Which +## CSS vs Shader -Most compositions should use **CSS/GSAP transitions** (the other categories above). They're simpler, lighter, and handle most needs. Use **shader transitions** only when you need an effect that CSS can't achieve: +CSS transitions animate scene containers with opacity, transforms, clip-path, and filters. Shader transitions composite both scene textures per-pixel on a WebGL canvas — they can warp, dissolve, and morph in ways CSS cannot. -| Use CSS/GSAP when | Use Shader when | -| ---------------------------------------------- | ----------------------------------------------- | -| Opacity, transform, clip-path, filter effects | Per-pixel noise dissolves, domain warping | -| Simple crossfades, wipes, slides | Both scenes actively morph into each other | -| No images/video in scenes (text + shapes only) | Live video needs to play through the transition | -| Quick to set up, no boilerplate | Willing to add WebGL setup layer (~200 lines) | +**Both are first-class options.** Shaders require setup boilerplate (~200 lines, copied from [transitions/shader-setup.md](transitions/shader-setup.md)) but produce richer, more cinematic effects. CSS transitions are simpler to set up. Choose based on the effect you want, not based on which is easier. -Shader transitions require setup boilerplate (canvas, scene capture, WebGL init). Read [transitions/shader-setup.md](transitions/shader-setup.md) for the complete code. The fragment shaders themselves are in the Shader section of [transitions/catalog.md](transitions/catalog.md). +When a composition uses shader transitions, ALL transitions in that composition should be shader-based (the WebGL canvas replaces DOM-based scene switching). Don't mix CSS and shader transitions in the same composition. + +## Shader-Compatible CSS Rules + +Shader transitions capture DOM scenes to WebGL textures via html2canvas. The canvas 2D rendering pipeline doesn't match CSS exactly. Follow these rules to avoid visible artifacts at transition boundaries: + +1. **No `transparent` keyword in gradients.** Canvas interpolates `transparent` as `rgba(0,0,0,0)` (black at zero alpha), creating dark fringes. Always use the target color at zero alpha: `rgba(200,117,51,0)` not `transparent`. +2. **No gradient backgrounds on elements thinner than 4px.** Canvas can't match CSS gradient rendering on 1-2px elements. Use solid `background-color` on thin accent lines. +3. **No CSS variables (`var()`) on elements visible during capture.** html2canvas doesn't reliably resolve custom properties. Use literal color values in inline styles. +4. **Mark uncapturable decorative elements with `data-no-capture`.** The capture function skips these. They're present on the live DOM but absent from the shader texture. Use for elements that can't follow the rules above. +5. **No gradient opacity below 0.15.** Gradient elements below 10% opacity render differently in canvas vs CSS. Increase to 0.15+ or use a solid color at equivalent brightness. +6. **Every `.scene` div must have explicit `background-color`, AND set `BG_COLOR` in the shader setup to the same value.** html2canvas captures the scene element, not the body. Both the CSS `background-color` on `.scene` and the `backgroundColor` option in `html2canvas()` must be set to the composition's background color. Without either, the texture renders as black. + +These rules only apply to shader transition compositions. CSS-only compositions have no restrictions. ## Visual Pattern Warning diff --git a/skills/hyperframes/references/transitions/catalog.md b/skills/hyperframes/references/transitions/catalog.md index 2b462cde1..1ea3ded62 100644 --- a/skills/hyperframes/references/transitions/catalog.md +++ b/skills/hyperframes/references/transitions/catalog.md @@ -34,7 +34,7 @@ Read [shader-setup.md](./shader-setup.md) for the full setup code these rules ap **WebGL setup:** `gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false)` — NOT true. Vertex shader flips Y: `v_uv.y = 1.0 - v_uv.y`. `preserveDrawingBuffer: true` required for HyperFrames capture. No `fwidth()` without extension — use constant `0.003`. -**Rendering model:** GL canvas stays visible entire composition — DOM scenes remain `opacity: 0`. Passthrough shader for holds. Scene capture = static snapshots at load; animated GSAP tweens on elements won't appear. +**Rendering model:** DOM scenes play normally with GSAP animations during holds — canvas is hidden (`display:none`). When a transition starts: capture outgoing scene with full content, capture incoming scene with `.scene-content` hidden (background + decoratives only), show canvas, run shader. When transition ends: hide canvas, show next DOM scene. GSAP entrance animations play on the live DOM. The incoming scene's content is never visible in the shader — it only shows the background layer, preventing un-animated elements from flashing. **Scene capture:** Canvas `fillText` doesn't match CSS fonts exactly (known, not a bug). No CSS gradients or SVGs. Images and videos ARE supported via `ctx.drawImage()`. Video scenes re-capture every frame during transitions via `recaptureVideoScene()`. @@ -118,7 +118,7 @@ All code examples use `old` for the outgoing scene-inner selector and `new` for | Distortion | Glitch, chromatic aberration, ripple, VHS tape | [css-distortion.md](./css-distortion.md) | | Mechanical | Shutter, clock wipe | [css-mechanical.md](./css-mechanical.md) | | Grid | Grid dissolve | [css-grid.md](./css-grid.md) | -| Other | Flash cut, gravity drop, morph circle | [css-other.md](./css-other.md) | +| Other | Gravity drop, morph circle | [css-other.md](./css-other.md) | | Blur | Blur through, directional blur | [css-blur.md](./css-blur.md) | | Destruction | Page burn | [css-destruction.md](./css-destruction.md) | @@ -126,7 +126,7 @@ All code examples use `old` for the outgoing scene-inner selector and `new` for WebGL fragment shaders that composite between scene textures per-pixel. Require setup boilerplate. -| What | Reference | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -| Setup (canvas, capture, WebGL init, render loop, GSAP integration) | [shader-setup.md](./shader-setup.md) | -| Fragment shaders (14 transitions: domain warp, ridged burn, whip pan, SDF iris, ripple waves, gravitational lens, cinematic zoom, chromatic split, glitch, swirl vortex, thermal distortion, flash through white, cross-warp morph, light leak) | [shader-transitions.md](./shader-transitions.md) | +| What | Reference | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| Setup (canvas, capture, WebGL init, render loop, GSAP integration) | [shader-setup.md](./shader-setup.md) | +| Fragment shaders (13 transitions: 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) | [shader-transitions.md](./shader-transitions.md) | diff --git a/skills/hyperframes/references/transitions/shader-setup.md b/skills/hyperframes/references/transitions/shader-setup.md index edc553cfa..e0dd8ccb3 100644 --- a/skills/hyperframes/references/transitions/shader-setup.md +++ b/skills/hyperframes/references/transitions/shader-setup.md @@ -1,6 +1,10 @@ # Shader Transition Setup -Complete boilerplate for WebGL shader transitions in HyperFrames. Read this when implementing a shader transition — copy the setup code, then plug in the fragment shader from the catalog. +Complete boilerplate for WebGL shader transitions in HyperFrames. Copy the setup code, then plug in the fragment shader from the catalog. + +**Rendering model:** DOM scenes play normally with GSAP animations. The WebGL canvas is hidden (`display:none`) between transitions. When a transition starts, `beginTrans` uses html2canvas to capture the outgoing scene with full content, and the incoming scene with `.scene-content` hidden (background + decorative elements only). This prevents un-animated content from flashing during the transition. When the transition ends, `endTrans` hides the canvas and reveals the incoming DOM scene — GSAP entrance animations then play on live elements. + +**Shader-compatible CSS:** Compositions using shader transitions must follow the rules in transitions.md § "Shader-Compatible CSS Rules" — no `transparent` in gradients, no gradient backgrounds on sub-4px elements, no `var()` on captured elements, `data-no-capture` on uncapturable decoratives. ## HTML @@ -14,270 +18,14 @@ Complete boilerplate for WebGL shader transitions in HyperFrames. Read this when ``` -## WebGL Init + Scene Capture - -Handles images, video, shapes, and text. Supports `object-fit: cover` on images and live video re-upload during transitions. +## WebGL Init ```js var sceneTextures = {}; -var sceneHasVideo = {}; // tracks which scenes have live video var glCanvas = document.getElementById("gl-canvas"); var gl = glCanvas.getContext("webgl", { preserveDrawingBuffer: true }); gl.viewport(0, 0, 1920, 1080); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); - -// Wait for all media to load before capturing -function waitForMedia() { - return new Promise(function (resolve) { - var promises = []; - document.querySelectorAll("img").forEach(function (img) { - if (!img.complete) - promises.push( - new Promise(function (r) { - img.onload = r; - img.onerror = r; - }), - ); - }); - document.querySelectorAll("video").forEach(function (vid) { - if (vid.readyState < 2) - promises.push( - new Promise(function (r) { - vid.addEventListener("loadeddata", r, { once: true }); - }), - ); - }); - Promise.all(promises).then(resolve); - }); -} - -function captureScene(sceneId) { - return new Promise(function (resolve) { - var scene = document.getElementById(sceneId); - var origOpacity = scene.style.opacity; - var origZ = scene.style.zIndex; - scene.style.opacity = "1"; - scene.style.zIndex = "999"; - - if (scene.querySelector("video")) sceneHasVideo[sceneId] = scene.querySelector("video"); - - requestAnimationFrame(function () { - requestAnimationFrame(function () { - var c = document.createElement("canvas"); - c.width = 1920; - c.height = 1080; - var ctx = c.getContext("2d"); - - ctx.fillStyle = window.getComputedStyle(scene).backgroundColor; - ctx.fillRect(0, 0, 1920, 1080); - - var sr = scene.getBoundingClientRect(); - var els = scene.querySelectorAll("*"); - for (var i = 0; i < els.length; i++) { - var el = els[i]; - var cs = window.getComputedStyle(el); - if (cs.display === "none" || cs.visibility === "hidden") continue; - var r = el.getBoundingClientRect(); - if (r.width < 1 || r.height < 1) continue; - var x = r.left - sr.left, - y = r.top - sr.top, - w = r.width, - h = r.height; - - ctx.save(); - ctx.globalAlpha = parseFloat(cs.opacity) || 1; - - // elements (with object-fit: cover support) - if (el.tagName === "IMG" && el.complete && el.naturalWidth > 0) { - try { - if (cs.objectFit === "cover") { - var iR = el.naturalWidth / el.naturalHeight, - bR = w / h; - var sx = 0, - sy = 0, - sw = el.naturalWidth, - sh = el.naturalHeight; - if (iR > bR) { - sw = sh * bR; - sx = (el.naturalWidth - sw) / 2; - } else { - sh = sw / bR; - sy = (el.naturalHeight - sh) / 2; - } - ctx.drawImage(el, sx, sy, sw, sh, x, y, w, h); - } else { - ctx.drawImage(el, x, y, w, h); - } - } catch (e) {} - ctx.restore(); - continue; - } - - //