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
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user