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
@@ -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);
}