chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
# @hyperframes/shader-transitions
WebGL shader transitions for HyperFrames compositions. Renders GPU-accelerated scene-to-scene transitions using fragment shaders, driven by GSAP timelines.
## Install
```bash
npm install @hyperframes/shader-transitions
```
Or load directly via CDN:
```html
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/shader-transitions/dist/index.global.js"></script>
```
## Usage
```typescript
import { init } from "@hyperframes/shader-transitions";
const tl = init({
bgColor: "#0a0a0a",
accentColor: "#ff6b2b",
scenes: ["scene-1", "scene-2", "scene-3"],
transitions: [
{ time: 3, shader: "domain-warp", duration: 0.8 },
{ time: 8, shader: "light-leak", duration: 0.7 },
],
});
```
The `init()` function pre-captures animated scene samples for every transition, composites cached samples with the selected shader during playback, and returns a GSAP timeline. Scene animations keep advancing through shader transitions without running DOM captures in the playback loop. If WebGL is unavailable, it falls back to normal timeline playback without shader compositing.
When the browser exposes Chrome's experimental CanvasDrawElement API, scene
capture uses native HTML-in-canvas via `drawElementImage()`. Other browsers keep
using the existing `html2canvas` fallback. You can feature-detect the native path
with `isHtmlInCanvasCaptureSupported()`.
### With an existing timeline
Pass your own GSAP timeline to layer transitions onto it:
```typescript
const tl = gsap.timeline({ paused: true });
// ... add your scene animations ...
init({
bgColor: "#000",
scenes: ["intro", "demo", "outro"],
transitions: [
{ time: 5, shader: "cinematic-zoom" },
{ time: 12, shader: "glitch", duration: 0.5 },
],
timeline: tl,
});
```
## Available shaders
| Shader | Description |
| --------------------- | ---------------------------------------------------- |
| `domain-warp` | Organic noise-based warp with glowing edge |
| `ridged-burn` | Ridged noise burn with sparks and heat glow |
| `whip-pan` | Horizontal motion blur simulating a fast camera pan |
| `sdf-iris` | Circular iris wipe with glowing ring edge |
| `ripple-waves` | Concentric ripple distortion radiating from center |
| `gravitational-lens` | Warping gravity well with chromatic aberration |
| `cinematic-zoom` | Radial zoom blur with chromatic fringing |
| `chromatic-split` | RGB channel separation expanding from center |
| `glitch` | Digital glitch with block displacement and scanlines |
| `swirl-vortex` | Spiral rotation with noise-based warping |
| `thermal-distortion` | Heat shimmer rising from the bottom |
| `flash-through-white` | Flash to white then reveal the next scene |
| `cross-warp-morph` | Noise-driven morph blending both scenes |
| `light-leak` | Warm cinematic light leak with lens flare |
## API
### `init(config): GsapTimeline`
| Option | Type | Required | Description |
| ------------------- | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bgColor` | `string` | yes | Fallback background color (hex) for scene capture. Use the composition's body/canvas background — individual scenes set their own `background-color` via CSS. |
| `accentColor` | `string` | no | Accent color (hex) for shader glow effects |
| `scenes` | `string[]` | yes | Element IDs of each scene, in order |
| `transitions` | `TransitionConfig[]` | yes | Transition definitions (see below) |
| `timeline` | `GsapTimeline` | no | Existing timeline to attach transitions to |
| `compositionId` | `string` | no | Override the `data-composition-id` for timeline registration |
| `previewCaptureFps` | `number` | no | Browser preview pre-capture samples per transition second. Defaults to `30`; rendering uses deterministic per-frame compositing instead. |
Browser preview capture scale and transition-prep loading UI ownership are controlled by `<hyperframes-player>` (`shader-capture-scale`, `shader-loading`) instead of composition code. Direct non-player previews keep the built-in full-fidelity loading fallback.
Browser previews store captured transition snapshots in IndexedDB using a key derived from composition ID, scene DOM/style signatures, transition timing, capture FPS, scale, and dimensions. On refresh, matching snapshots are reloaded into WebGL textures instead of being captured again. Runtime scene or stylesheet edits mark only adjacent transition caches dirty; recapture is deferred until playback so editing stays responsive.
### `TransitionConfig`
| Option | Type | Default | Description |
| ---------- | ------------ | ---------------- | -------------------------------- |
| `time` | `number` | — | Start time in seconds |
| `shader` | `ShaderName` | — | Shader name from the table above |
| `duration` | `number` | `0.7` | Transition duration in seconds |
| `ease` | `string` | `"power2.inOut"` | GSAP easing function |
### `SHADER_NAMES`
Array of all available shader name strings, useful for validation or building UIs.
```typescript
import { SHADER_NAMES } from "@hyperframes/shader-transitions";
// ["domain-warp", "ridged-burn", "whip-pan", ...]
```
## Distribution
| Format | File | Use case |
| ------ | ---------------------- | ------------------------------------------- |
| ESM | `dist/index.js` | Bundlers (Vite, webpack, etc.) |
| CJS | `dist/index.cjs` | Node.js / require() |
| IIFE | `dist/index.global.js` | `<script>` tag, CDN (global: `HyperShader`) |
All formats include source maps. TypeScript definitions included.
## Related packages
- [`@hyperframes/core`](../core) -- types, parsers, runtime
- [`@hyperframes/engine`](../engine) -- rendering engine
- [`hyperframes`](../cli) -- CLI
## License
MIT
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@hyperframes/shader-transitions",
"version": "0.7.55",
"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"
}
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"script": "./dist/index.global.js",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"html2canvas": "^1.4.1"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { isHtmlInCanvasCaptureSupported } from "./capture.js";
describe("isHtmlInCanvasCaptureSupported", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns false outside the browser", () => {
vi.stubGlobal("document", undefined);
expect(isHtmlInCanvasCaptureSupported()).toBe(false);
});
it("returns true when layoutSubtree and drawElementImage are available", () => {
vi.stubGlobal("document", {
createElement: () => ({
setAttribute: () => undefined,
layoutSubtree: true,
getContext: () => ({
drawElementImage: () => undefined,
}),
}),
});
expect(isHtmlInCanvasCaptureSupported()).toBe(true);
});
it("returns false when drawElementImage is missing", () => {
vi.stubGlobal("document", {
createElement: () => ({
setAttribute: () => undefined,
layoutSubtree: true,
getContext: () => ({}),
}),
});
expect(isHtmlInCanvasCaptureSupported()).toBe(false);
});
});
+225
View File
@@ -0,0 +1,225 @@
import html2canvas from "html2canvas";
import { DEFAULT_WIDTH, DEFAULT_HEIGHT } from "./webgl.js";
let patched = false;
const VOID_ELEMENT_TAGS = new Set([
"AREA",
"BASE",
"BR",
"COL",
"EMBED",
"HR",
"IMG",
"INPUT",
"LINK",
"META",
"PARAM",
"SOURCE",
"TRACK",
"WBR",
]);
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 interface CaptureSceneOptions {
forceVisible?: boolean;
preferBrowserPaint?: boolean;
scale?: number;
}
function forceSceneVisibleInClone(source: HTMLElement, cloneDoc: Document): void {
if (!source.id) return;
const clone = cloneDoc.getElementById(source.id);
if (!(clone instanceof HTMLElement)) return;
clone.style.opacity = "1";
clone.style.visibility = "visible";
clone.querySelectorAll<HTMLElement>("[data-start]").forEach((el) => {
el.style.visibility = "visible";
});
}
function stabilizeTransformedBoxShadows(root: HTMLElement): void {
const view = root.ownerDocument.defaultView;
if (!view) return;
[root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))].forEach((el) => {
if (VOID_ELEMENT_TAGS.has(el.tagName)) return;
const styles = view.getComputedStyle(el);
if (styles.boxShadow === "none" || styles.transform === "none") return;
const shadow = root.ownerDocument.createElement("div");
shadow.setAttribute("data-hyper-shader-shadow-shim", "");
shadow.style.cssText = [
"position:absolute",
"inset:0",
"border-radius:inherit",
`box-shadow:${styles.boxShadow}`,
"background:transparent",
"pointer-events:none",
"z-index:0",
].join(";");
if (styles.position === "static") {
el.style.position = "relative";
}
el.style.boxShadow = "none";
el.insertBefore(shadow, el.firstChild);
});
}
// ── HTML-in-Canvas (drawElementImage) native capture ──────────────────────
interface CanvasWithLayoutSubtree extends HTMLCanvasElement {
layoutSubtree: boolean;
requestPaint: () => void;
}
interface CanvasRenderingContext2DWithDrawElement extends CanvasRenderingContext2D {
drawElementImage: (element: Element, x: number, y: number, w: number, h: number) => void;
}
export function isHtmlInCanvasCaptureSupported(): boolean {
if (typeof document === "undefined") return false;
const probe = document.createElement("canvas") as HTMLCanvasElement & {
layoutSubtree?: boolean;
};
probe.setAttribute("layoutsubtree", "");
if (!("layoutSubtree" in probe)) return false;
const ctx = probe.getContext("2d") as CanvasRenderingContext2DWithDrawElement | null;
return ctx != null && typeof ctx.drawElementImage === "function";
}
async function captureSceneWithHtmlInCanvas(
sceneEl: HTMLElement,
bgColor: string,
width: number,
height: number,
): Promise<HTMLCanvasElement> {
const canvas = document.createElement("canvas") as CanvasWithLayoutSubtree;
canvas.width = width;
canvas.height = height;
canvas.setAttribute("layoutsubtree", "");
canvas.style.cssText = `position:fixed;top:0;left:0;width:${width}px;height:${height}px;z-index:-9999;pointer-events:none;opacity:0`;
canvas.appendChild(sceneEl.cloneNode(true));
document.body.appendChild(canvas);
try {
await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
const ctx = canvas.getContext("2d") as CanvasRenderingContext2DWithDrawElement;
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, height);
const child = canvas.firstElementChild;
if (child) ctx.drawElementImage(child, 0, 0, width, height);
const result = document.createElement("canvas");
result.width = width;
result.height = height;
result.getContext("2d")!.drawImage(canvas, 0, 0);
canvas.remove();
return result;
} catch (err) {
canvas.remove();
throw err;
}
}
export function captureScene(
sceneEl: HTMLElement,
bgColor: string,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT,
options: CaptureSceneOptions = {},
): Promise<HTMLCanvasElement> {
if (isHtmlInCanvasCaptureSupported() && !options.preferBrowserPaint) {
return captureSceneWithHtmlInCanvas(sceneEl, bgColor, width, height).catch(() =>
captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height, options),
);
}
return captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height, options);
}
function captureSceneWithHtml2Canvas(
sceneEl: HTMLElement,
bgColor: string,
width: number,
height: number,
options: CaptureSceneOptions = {},
): Promise<HTMLCanvasElement> {
const captureWithRenderer = (foreignObjectRendering: boolean): Promise<HTMLCanvasElement> => {
return html2canvas(sceneEl, {
width,
height,
scale: options.scale ?? 1,
backgroundColor: bgColor,
logging: false,
foreignObjectRendering,
// Safari applies stricter canvas-taint rules than Chrome. SVG data URLs
// with <filter> elements (e.g. feTurbulence grain backgrounds), certain
// cross-origin images, and mask/clip-path url() refs can taint the
// output canvas on WebKit. Without these flags, html2canvas throws
// `SecurityError: The operation is insecure` during its own read-back
// path and every shader transition falls through to the catch handler
// — observed in Safari + Claude Design's cross-origin iframe sandbox.
//
// useCORS: send CORS headers on image fetches so cross-origin images
// with proper `Access-Control-Allow-Origin` don't taint the
// canvas in the first place. Strict improvement.
// allowTaint: let html2canvas complete and return a canvas even when it
// becomes tainted (instead of throwing). Important caveat:
// a tainted canvas CANNOT be uploaded to WebGL via
// `gl.texImage2D` — WebGL spec requires SecurityError on
// non-origin-clean sources, with no opt-out. So this flag
// only moves the failure point from html2canvas to the
// texImage2D call in webgl.ts. The caller catches the
// rejected promise and keeps the DOM fallback visible. Net
// effect: the end-user UX avoids blank frames either way,
// but we get a cleaner, more predictable error site and the
// flag is defensively correct for the non-taint branches
// where it genuinely helps (e.g.,
// `crossOrigin="anonymous"` image fetches that already had
// CORS headers).
useCORS: true,
allowTaint: true,
onclone: (cloneDoc) => {
if (!sceneEl.id) return;
const clone = cloneDoc.getElementById(sceneEl.id);
if (clone instanceof HTMLElement) {
stabilizeTransformedBoxShadows(clone);
}
if (options.forceVisible) {
forceSceneVisibleInClone(sceneEl, cloneDoc);
}
},
ignoreElements: (el: Element) =>
el.tagName === "CANVAS" || el.hasAttribute("data-no-capture"),
});
};
if (options.preferBrowserPaint === true) {
return captureWithRenderer(true).catch(() => captureWithRenderer(false));
}
return captureWithRenderer(false);
}
@@ -0,0 +1,101 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clonePinStyleFor,
isPageSideCompositingSupported,
PAGE_COMPOSITOR_BUILD_CANARY,
PAGE_COMPOSITOR_CANVAS_ID,
} from "./engineModePageComposite.js";
describe("isPageSideCompositingSupported", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns false outside the browser (no window)", () => {
vi.stubGlobal("window", undefined);
expect(isPageSideCompositingSupported()).toBe(false);
});
it("returns false outside the browser (no document)", () => {
vi.stubGlobal("window", {});
vi.stubGlobal("document", undefined);
expect(isPageSideCompositingSupported()).toBe(false);
});
it("returns true when drawElementImage and WebGL are both available", () => {
vi.stubGlobal("window", {});
vi.stubGlobal("document", {
createElement: (tag: string) => {
if (tag === "canvas") {
return {
setAttribute: () => undefined,
layoutSubtree: true,
getContext: (type: string) => {
if (type === "2d") return { drawElementImage: () => undefined };
if (type === "webgl")
return { getExtension: () => ({ loseContext: () => undefined }) };
return null;
},
};
}
return {};
},
});
expect(isPageSideCompositingSupported()).toBe(true);
});
it("returns false when drawElementImage is missing", () => {
vi.stubGlobal("window", {});
vi.stubGlobal("document", {
createElement: () => ({
setAttribute: () => undefined,
getContext: (type: string) =>
type === "webgl" ? { getExtension: () => ({ loseContext: () => undefined }) } : {},
}),
});
expect(isPageSideCompositingSupported()).toBe(false);
});
it("returns false when WebGL is unavailable", () => {
vi.stubGlobal("window", {});
vi.stubGlobal("document", {
createElement: () => ({
setAttribute: () => undefined,
layoutSubtree: true,
getContext: (type: string) =>
type === "2d" ? { drawElementImage: () => undefined } : null,
}),
});
expect(isPageSideCompositingSupported()).toBe(false);
});
});
describe("clonePinStyleFor", () => {
it("fixes a 0x0 inset:0 scene root to its live-measured box (the collapse this exists to prevent)", () => {
// A scene root sized only by `position:absolute; inset:0` measures as
// the full composition frame in the live document (its containing block
// there is the real ancestor chain) — collapses to 0x0 only once cloned
// into the staging canvas's own layout subtree.
const pin = clonePinStyleFor({ left: 0, top: 0, width: 1080, height: 1920 });
expect(pin).toEqual({ left: "0px", top: "0px", width: "1080px", height: "1920px" });
});
it("preserves an authored explicit width/height and offset instead of overriding it", () => {
// A scene root with its own explicit size/position (e.g. a picture-in-
// picture panel) measures as that exact box in the live document —
// clonePinStyleFor must reproduce it verbatim, not the full composition
// frame, or the clone would silently grow to fill the canvas.
const pin = clonePinStyleFor({ left: 120, top: 240, width: 400, height: 300 });
expect(pin).toEqual({ left: "120px", top: "240px", width: "400px", height: "300px" });
});
});
describe("page-side compositor exported constants", () => {
it("exports a stable canary string used by the bundled-CLI smoke", () => {
expect(PAGE_COMPOSITOR_BUILD_CANARY).toBe("__hf_page_compositor_v1__");
});
it("exports a stable canvas id", () => {
expect(PAGE_COMPOSITOR_CANVAS_ID).toBe("__hf-page-side-compositor");
});
});
@@ -0,0 +1,443 @@
/**
* engineModePageComposite — page-side WebGL compositor for engine render mode.
*
* Opt-in via `window.__HF_PAGE_SIDE_COMPOSITING__ = true` (set by the producer
* when `EngineConfig.enablePageSideCompositing` is true). When the flag is
* off, hyper-shader's engine-mode path stays on the opacity-flip-only timeline
* and the producer's hf#677 Node-side layered pipeline runs the shader blend.
*
* Two-phase capture protocol:
*
* Phase 1 (seek wrapper, runs inside page.evaluate):
* - Runs original GSAP seek to position the timeline
* - If inside a transition window, clones FROM/TO scene elements into
* layoutsubtree staging canvases
* - Sets window.__hf_page_composite_pending with transition metadata
* - Returns immediately (seek resolves)
*
* Paint force (engine-side, frameCapture.ts):
* - Engine detects the pending flag and fires a micro Page.captureScreenshot
* to force the browser compositor to paint the staging canvas clones
*
* Phase 2 (engine calls window.__hf_page_composite_resolve):
* - drawElementImage reads the now-valid paint records from the clones
* - Uploads textures to WebGL, runs the shader, shows the GL overlay
* - Cleans up staging canvases
*
* This gives native-fidelity capture (identical to preview-path
* drawElementImage) without depending on requestAnimationFrame for paint.
*/
import {
createContext,
setupQuad,
createProgram,
createTexture,
uploadTextureSource,
renderShader,
type AccentColors,
} from "./webgl.js";
import { getFragSource, type ShaderName } from "./shaders/registry.js";
import { isHtmlInCanvasCaptureSupported } from "./capture.js";
interface PageCompositeTransitionConfig {
time: number;
/**
* Shader id. Undefined entries are CSS crossfades — the page-side
* compositor skips them, and the GSAP timeline in `initEngineMode`
* schedules an actual opacity-crossfade tween for those entries so the
* single page screenshot contains a correct blended frame. The entry
* stays in the array to preserve `transitions[i]` ↔ `scenes[i]`/
* `scenes[i+1]` index alignment for the surrounding shader entries.
*/
shader?: ShaderName;
duration?: number;
}
export interface PageCompositorInstallOptions {
scenes: string[];
transitions: PageCompositeTransitionConfig[];
bgColor: string;
accentColors: AccentColors;
width: number;
height: number;
defaultDuration: number;
}
interface ResolvedTransition {
time: number;
duration: number;
shader: string;
fromSceneId: string;
toSceneId: string;
prog: WebGLProgram;
}
export const PAGE_COMPOSITOR_CANVAS_ID = "__hf-page-side-compositor";
export const PAGE_COMPOSITOR_BUILD_CANARY = "__hf_page_compositor_v1__";
export interface ClonePinStyle {
left: string;
top: string;
width: string;
height: string;
}
/**
* Style values to pin a cloned scene root to the box its source measured
* WHILE STILL LIVE in the document — never the composition's full pixel size.
* A live-document `getBoundingClientRect()` already resolves `inset:0` (and
* any authored explicit width/height) correctly against the real ancestor
* chain; reapplying that exact box to the clone fixes the 0x0 collapse a
* detached `inset:0` clone would otherwise have inside the staging canvas's
* layout subtree, without ever overriding an author's own sizing.
*/
export function clonePinStyleFor(rect: {
left: number;
top: number;
width: number;
height: number;
}): ClonePinStyle {
return {
left: `${rect.left}px`,
top: `${rect.top}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
};
}
export function isPageSideCompositingSupported(): boolean {
if (typeof window === "undefined" || typeof document === "undefined") return false;
if (!isHtmlInCanvasCaptureSupported()) return false;
const probe = document.createElement("canvas");
const gl = probe.getContext("webgl") || probe.getContext("experimental-webgl");
if (!gl) return false;
(gl as WebGLRenderingContext).getExtension("WEBGL_lose_context")?.loseContext();
return true;
}
export function installPageSideCompositor(options: PageCompositorInstallOptions): boolean {
if (typeof window === "undefined") return false;
(window as unknown as { __HF_PAGE_COMPOSITOR_CANARY__?: string }).__HF_PAGE_COMPOSITOR_CANARY__ =
PAGE_COMPOSITOR_BUILD_CANARY;
if (!isPageSideCompositingSupported()) {
// eslint-disable-next-line no-console
console.warn(
"[HyperShader] page-side compositing requested but drawElementImage/WebGL is not " +
"available; falling back to opacity-flip mode " +
"(Node-side layered pipeline will handle the blend).",
);
return false;
}
if (document.getElementById(PAGE_COMPOSITOR_CANVAS_ID)) return true;
const { scenes, transitions, accentColors, width, height, defaultDuration } = options;
const glCanvas = document.createElement("canvas");
glCanvas.id = PAGE_COMPOSITOR_CANVAS_ID;
glCanvas.width = width;
glCanvas.height = height;
glCanvas.style.cssText =
"position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;display:none;";
document.body.appendChild(glCanvas);
const gl = createContext(glCanvas, width, height);
if (!gl) {
// eslint-disable-next-line no-console
console.warn("[HyperShader] page-side compositor: WebGL context unavailable.");
glCanvas.remove();
return false;
}
const quadBuf = setupQuad(gl);
const programs = new Map<string, WebGLProgram>();
for (const t of transitions) {
// CSS crossfade entries (shader undefined) carry no program. Use a
// strict undefined check so a misconfigured empty string still fails
// loudly through the createProgram path below.
if (t.shader === undefined) continue;
if (programs.has(t.shader)) continue;
try {
programs.set(t.shader, createProgram(gl, getFragSource(t.shader)));
} catch (err) {
// eslint-disable-next-line no-console
console.warn(`[HyperShader] page-side compositor: failed to compile "${t.shader}":`, err);
}
}
const resolved: ResolvedTransition[] = [];
for (let i = 0; i < transitions.length; i++) {
const t = transitions[i];
if (!t) continue;
// CSS-only transitions stay on the GSAP opacity timeline; the page-
// side compositor only handles shader entries. Index i is preserved
// so subsequent shader transitions still pair with the right scenes.
if (t.shader === undefined) continue;
const fromSceneId = scenes[i];
const toSceneId = scenes[i + 1];
const prog = programs.get(t.shader);
if (!fromSceneId || !toSceneId || !prog) continue;
resolved.push({
time: t.time,
duration: t.duration ?? defaultDuration,
shader: t.shader,
fromSceneId,
toSceneId,
prog,
});
}
if (resolved.length === 0) {
glCanvas.remove();
return false;
}
const fromTex = createTexture(gl);
const toTex = createTexture(gl);
type DrawElementImageCtx = CanvasRenderingContext2D & {
drawElementImage: (el: Element, x: number, y: number, w: number, h: number) => void;
};
interface StagingCanvas extends HTMLCanvasElement {
layoutSubtree?: boolean;
}
// Persistent staging canvases — children are swapped per transition frame.
// Kept in the DOM so the compositor paints them on the next frame.
const fromStaging = document.createElement("canvas") as StagingCanvas;
const toStaging = document.createElement("canvas") as StagingCanvas;
for (const s of [fromStaging, toStaging]) {
s.width = width;
s.height = height;
s.setAttribute("layoutsubtree", "");
s.style.cssText =
"position:fixed;top:0;left:0;width:" +
width +
"px;height:" +
height +
"px;z-index:-9998;pointer-events:none;";
document.body.appendChild(s);
}
function findActive(time: number): ResolvedTransition | null {
for (const t of resolved) {
if (time >= t.time && time <= t.time + t.duration) return t;
}
return null;
}
// Scene on screen at a non-transition time: after the last transition whose
// window has passed. Full transitions list so the index matches scene order.
function settledSceneIdAt(time: number): string | undefined {
let idx = 0;
for (const t of transitions) {
if (time >= t.time + (t.duration ?? defaultDuration)) idx += 1;
}
return scenes[Math.min(idx, scenes.length - 1)];
}
let currentActive: ResolvedTransition | null = null;
let currentProgress = 0;
type PendingWindow = Window & {
__hf_page_composite_pending?: boolean;
__hf_page_composite_resolve?: () => boolean;
};
const pWin = window as PendingWindow;
// Phase 2a: clone scenes into staging canvases. Called after video frame
// injection so cloneNode picks up <img> replacements for <video> elements.
// Awaits decode on cloned data-URI images so drawElementImage reads
// the current frame, not a stale paint cache entry.
async function prepareComposite(): Promise<boolean> {
const active = currentActive;
if (!active) {
pWin.__hf_page_composite_pending = false;
return false;
}
const fromEl = document.getElementById(active.fromSceneId);
const toEl = document.getElementById(active.toSceneId);
if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) {
pWin.__hf_page_composite_pending = false;
return false;
}
// Measure each scene's rendered box WHILE STILL LIVE — a scene root sized
// only by `position:absolute; inset:0` resolves to 0x0 once cloned into
// the staging canvas's layout subtree (no containing-block dimensions
// there), and the transition textures blank out (wild report: explicit
// 1080x1920 anchors fixed both transitions). The live document already
// resolves inset:0 (and any authored explicit width/height) correctly
// against the real ancestor chain, so pinning the clone to THIS measured
// box fixes the collapse without ever overriding an author's own sizing.
const fromPin = clonePinStyleFor(fromEl.getBoundingClientRect());
const toPin = clonePinStyleFor(toEl.getBoundingClientRect());
while (fromStaging.firstChild) fromStaging.removeChild(fromStaging.firstChild);
while (toStaging.firstChild) toStaging.removeChild(toStaging.firstChild);
const fromClone = fromEl.cloneNode(true) as HTMLElement;
const toClone = toEl.cloneNode(true) as HTMLElement;
fromStaging.appendChild(fromClone);
toStaging.appendChild(toClone);
// cloneNode copies the GSAP opacity-fade (opacity:0 / hidden data-start), and
// Chrome won't paint hidden elements — drawElementImage then throws "No cached
// paint record" and the shader degrades to a hard cut. The shader blends from
// full-opacity textures via u_progress, so force the clones visible. Cf.
// forceSceneVisibleInClone (html2canvas path).
for (const [clone, pin] of [
[fromClone, fromPin],
[toClone, toPin],
] as const) {
clone.style.opacity = "1";
clone.style.visibility = "visible";
clone.style.position = "absolute";
clone.style.left = pin.left;
clone.style.top = pin.top;
clone.style.width = pin.width;
clone.style.height = pin.height;
clone.querySelectorAll<HTMLElement>("[data-start]").forEach((el) => {
el.style.opacity = "1";
el.style.visibility = "visible";
});
}
// Decode any data-URI images in clones so the browser has current
// bitmaps before the micro-screenshot forces a paint pass.
const decodes: Promise<void>[] = [];
for (const staging of [fromStaging, toStaging]) {
for (const img of staging.querySelectorAll("img")) {
if (img.src && img.src.startsWith("data:") && typeof img.decode === "function") {
decodes.push(img.decode().catch(() => {}));
}
}
}
if (decodes.length > 0) await Promise.all(decodes);
return true;
}
// Phase 2b: drawElementImage from painted clones + shader composite.
// Called after micro-screenshot forces the browser to paint the clones.
function resolveComposite(): boolean {
const active = currentActive;
if (!active) {
pWin.__hf_page_composite_pending = false;
return false;
}
const fromChild = fromStaging.firstElementChild;
const toChild = toStaging.firstElementChild;
if (!fromChild || !toChild) {
pWin.__hf_page_composite_pending = false;
return false;
}
const fromCtx = fromStaging.getContext("2d") as DrawElementImageCtx | null;
const toCtx = toStaging.getContext("2d") as DrawElementImageCtx | null;
if (!fromCtx?.drawElementImage || !toCtx?.drawElementImage) {
pWin.__hf_page_composite_pending = false;
return false;
}
try {
fromCtx.fillStyle = options.bgColor;
fromCtx.fillRect(0, 0, width, height);
fromCtx.drawElementImage(fromChild, 0, 0, width, height);
toCtx.fillStyle = options.bgColor;
toCtx.fillRect(0, 0, width, height);
toCtx.drawElementImage(toChild, 0, 0, width, height);
} catch (err) {
// eslint-disable-next-line no-console
console.warn("[HyperShader] page-side compositor: drawElementImage failed:", err);
pWin.__hf_page_composite_pending = false;
return false;
}
uploadTextureSource(gl as WebGLRenderingContext, fromTex, fromStaging);
uploadTextureSource(gl as WebGLRenderingContext, toTex, toStaging);
try {
renderShader(
gl as WebGLRenderingContext,
quadBuf,
active.prog,
fromTex,
toTex,
currentProgress,
accentColors,
width,
height,
);
glCanvas.style.display = "block";
} catch (err) {
// eslint-disable-next-line no-console
console.warn("[HyperShader] page-side compositor: renderShader failed:", err);
glCanvas.style.display = "none";
}
pWin.__hf_page_composite_pending = false;
return true;
}
pWin.__hf_page_composite_resolve = resolveComposite;
(
pWin as unknown as { __hf_page_composite_prepare?: () => Promise<boolean> }
).__hf_page_composite_prepare = prepareComposite;
type HfWindow = Window & {
__hf?: { seek?: (t: number) => unknown };
};
const hfWin = window as HfWindow;
const wrapSeek = (): void => {
if (!hfWin.__hf) return;
const originalSeek = hfWin.__hf.seek;
if (typeof originalSeek !== "function") return;
const wrapped = (time: number): unknown => {
const result = originalSeek.call(hfWin.__hf, time);
const active = findActive(time);
if (!active) {
glCanvas.style.display = "none";
pWin.__hf_page_composite_pending = false;
while (fromStaging.firstChild) fromStaging.removeChild(fromStaging.firstChild);
while (toStaging.firstChild) toStaging.removeChild(toStaging.firstChild);
// Live-page screenshot parity with the layered path's forceVisible: the
// core clip runtime hides the final scene a beat before the comp ends, so
// un-hide the settled scene (others stay at opacity 0).
const settledId = settledSceneIdAt(time);
const settled = settledId ? document.getElementById(settledId) : null;
if (settled instanceof HTMLElement && settled.style.visibility === "hidden") {
settled.style.visibility = "visible";
}
return result;
}
currentActive = active;
currentProgress =
active.duration === 0
? 1
: Math.min(1, Math.max(0, (time - active.time) / active.duration));
pWin.__hf_page_composite_pending = true;
return result;
};
hfWin.__hf.seek = wrapped;
};
let attempts = 0;
const ivHandle = window.setInterval(() => {
attempts += 1;
if (hfWin.__hf?.seek) {
wrapSeek();
window.clearInterval(ivHandle);
} else if (attempts > 200) {
window.clearInterval(ivHandle);
// eslint-disable-next-line no-console
console.warn(
"[HyperShader] page-side compositor: window.__hf.seek never appeared after 10s; " +
"the engine bridge did not initialize. Falling back to opacity-flip mode.",
);
}
}, 50);
return true;
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
export { init, type HyperShaderConfig, type TransitionConfig } from "./hyper-shader.js";
export { isHtmlInCanvasCaptureSupported } from "./capture.js";
export { SHADER_NAMES, type ShaderName } from "./shaders/registry.js";
export {
installPageSideCompositor,
isPageSideCompositingSupported,
PAGE_COMPOSITOR_BUILD_CANARY,
PAGE_COMPOSITOR_CANVAS_ID,
} from "./engineModePageComposite.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;
}
+157
View File
@@ -0,0 +1,157 @@
import { vertSrc } from "./shaders/common.js";
export const DEFAULT_WIDTH = 1920;
export const DEFAULT_HEIGHT = 1080;
export function createContext(
canvas: HTMLCanvasElement,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT,
): 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;
}
function linkProgram(
gl: WebGLRenderingContext,
vertexShader: WebGLShader,
fragSrc: string,
): WebGLProgram {
const p = gl.createProgram();
if (!p) throw new Error("[HyperShader] Failed to create program");
gl.attachShader(p, vertexShader);
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 function createProgram(gl: WebGLRenderingContext, fragSrc: string): WebGLProgram {
if (!cachedVertexShader) {
cachedVertexShader = compileShader(gl, vertSrc, gl.VERTEX_SHADER);
}
return linkProgram(gl, cachedVertexShader, fragSrc);
}
export function createProgramWithVertex(
gl: WebGLRenderingContext,
vertexSrc: string,
fragSrc: string,
): WebGLProgram {
return linkProgram(gl, compileShader(gl, vertexSrc, gl.VERTEX_SHADER), fragSrc);
}
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,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT,
): 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 uploadTextureSource(
gl: WebGLRenderingContext,
tex: WebGLTexture,
source: TexImageSource,
): void {
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
}
+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,
});