mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(runtime): render media color grading shaders
This commit is contained in:
@@ -4,6 +4,8 @@ import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorG
|
||||
|
||||
let lastUniform1f: ReturnType<typeof vi.fn> | null = null;
|
||||
let lastUniform3f: ReturnType<typeof vi.fn> | null = null;
|
||||
let lastShaderSources: string[] = [];
|
||||
let texImage2DCalls: unknown[][] = [];
|
||||
|
||||
const IDENTITY_2 = `
|
||||
LUT_3D_SIZE 2
|
||||
@@ -17,7 +19,7 @@ LUT_3D_SIZE 2
|
||||
1 1 1
|
||||
`;
|
||||
|
||||
function createMockWebGl(): WebGLRenderingContext {
|
||||
function createMockWebGl(options: { failMediaUpload?: boolean } = {}): WebGLRenderingContext {
|
||||
const shader = {};
|
||||
const program = {};
|
||||
const texture = {};
|
||||
@@ -45,11 +47,18 @@ function createMockWebGl(): WebGLRenderingContext {
|
||||
STATIC_DRAW: 0x88e4,
|
||||
TEXTURE0: 0x84c0,
|
||||
TEXTURE1: 0x84c1,
|
||||
TEXTURE2: 0x84c2,
|
||||
TEXTURE3: 0x84c3,
|
||||
FLOAT: 0x1406,
|
||||
TRIANGLE_STRIP: 0x0005,
|
||||
UNPACK_FLIP_Y_WEBGL: 0x9240,
|
||||
FRAMEBUFFER: 0x8d40,
|
||||
COLOR_ATTACHMENT0: 0x8ce0,
|
||||
FRAMEBUFFER_COMPLETE: 0x8cd5,
|
||||
createShader: vi.fn(() => shader),
|
||||
shaderSource: vi.fn(),
|
||||
shaderSource: vi.fn((_shader, source: string) => {
|
||||
lastShaderSources.push(source);
|
||||
}),
|
||||
compileShader: vi.fn(),
|
||||
getShaderParameter: vi.fn(() => true),
|
||||
getShaderInfoLog: vi.fn(() => ""),
|
||||
@@ -63,7 +72,17 @@ function createMockWebGl(): WebGLRenderingContext {
|
||||
createTexture: vi.fn(() => texture),
|
||||
bindTexture: vi.fn(),
|
||||
texParameteri: vi.fn(),
|
||||
texImage2D: vi.fn(),
|
||||
texImage2D: vi.fn((...args: unknown[]) => {
|
||||
texImage2DCalls.push(args);
|
||||
if (options.failMediaUpload && args.length === 6) {
|
||||
throw new Error("Media cannot be sampled by WebGL");
|
||||
}
|
||||
}),
|
||||
createFramebuffer: vi.fn(() => ({})),
|
||||
bindFramebuffer: vi.fn(),
|
||||
framebufferTexture2D: vi.fn(),
|
||||
checkFramebufferStatus: vi.fn(() => 0x8cd5),
|
||||
deleteFramebuffer: vi.fn(),
|
||||
createBuffer: vi.fn(() => buffer),
|
||||
bindBuffer: vi.fn(),
|
||||
bufferData: vi.fn(),
|
||||
@@ -81,6 +100,7 @@ function createMockWebGl(): WebGLRenderingContext {
|
||||
vertexAttribPointer: vi.fn(),
|
||||
drawArrays: vi.fn(),
|
||||
deleteTexture: vi.fn(),
|
||||
deleteBuffer: vi.fn(),
|
||||
} as unknown as WebGLRenderingContext;
|
||||
}
|
||||
|
||||
@@ -113,12 +133,12 @@ function makeDrawableVideo(): HTMLVideoElement {
|
||||
return video;
|
||||
}
|
||||
|
||||
function stubCubeLutFetch(): ReturnType<typeof vi.fn> {
|
||||
function stubCubeLutFetch(text = IDENTITY_2): ReturnType<typeof vi.fn> {
|
||||
const fetchMock = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(IDENTITY_2),
|
||||
text: () => Promise.resolve(text),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
@@ -133,6 +153,8 @@ describe("createColorGradingRuntime", () => {
|
||||
document.body.innerHTML = "";
|
||||
lastUniform1f = null;
|
||||
lastUniform3f = null;
|
||||
lastShaderSources = [];
|
||||
texImage2DCalls = [];
|
||||
getContextSpy = vi
|
||||
.spyOn(HTMLCanvasElement.prototype, "getContext")
|
||||
.mockImplementation((type: string) =>
|
||||
@@ -172,6 +194,7 @@ describe("createColorGradingRuntime", () => {
|
||||
it("re-hides source media after timeline visibility sync", () => {
|
||||
const { video, canvas } = startRuntimeWithVideo();
|
||||
|
||||
expect(canvas.id).toBe("__hf_color_grading_hero-video");
|
||||
expect(video.style.getPropertyValue("visibility")).toBe("");
|
||||
expect(video.style.getPropertyValue("opacity")).toBe("0");
|
||||
expect(video.style.getPropertyPriority("opacity")).toBe("important");
|
||||
@@ -258,6 +281,70 @@ describe("createColorGradingRuntime", () => {
|
||||
expect(video.style.getPropertyPriority("opacity")).toBe("important");
|
||||
});
|
||||
|
||||
it("keeps the canvas visible when producer render-frame injection hides the source video", () => {
|
||||
const video = makeDrawableVideo();
|
||||
Object.defineProperty(video, "readyState", {
|
||||
value: HTMLMediaElement.HAVE_METADATA,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(video, "videoWidth", { value: 0, configurable: true });
|
||||
Object.defineProperty(video, "videoHeight", { value: 0, configurable: true });
|
||||
document.body.appendChild(video);
|
||||
|
||||
runtime = createColorGradingRuntime();
|
||||
const canvas = document.querySelector<HTMLCanvasElement>("[data-hf-color-grading-canvas]");
|
||||
if (!canvas) throw new Error("Expected color grading canvas");
|
||||
expect(canvas.style.display).toBe("none");
|
||||
|
||||
const frame = document.createElement("img");
|
||||
frame.id = "__render_frame_hero-video__";
|
||||
frame.className = "__render_frame__";
|
||||
frame.style.visibility = "visible";
|
||||
frame.style.opacity = "0.75";
|
||||
Object.defineProperty(frame, "complete", { value: true, configurable: true });
|
||||
Object.defineProperty(frame, "naturalWidth", { value: 640, configurable: true });
|
||||
Object.defineProperty(frame, "naturalHeight", { value: 360, configurable: true });
|
||||
video.parentNode?.insertBefore(frame, canvas);
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
|
||||
runtime.redraw();
|
||||
|
||||
expect(canvas.style.display).toBe("block");
|
||||
expect(canvas.style.visibility).toBe("visible");
|
||||
expect(canvas.style.opacity).toBe("0.75");
|
||||
});
|
||||
|
||||
it("moves the canvas above producer render-frame images before capture", () => {
|
||||
const video = makeDrawableVideo();
|
||||
Object.defineProperty(video, "readyState", {
|
||||
value: HTMLMediaElement.HAVE_METADATA,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(video, "videoWidth", { value: 0, configurable: true });
|
||||
Object.defineProperty(video, "videoHeight", { value: 0, configurable: true });
|
||||
document.body.appendChild(video);
|
||||
|
||||
runtime = createColorGradingRuntime();
|
||||
const canvas = document.querySelector<HTMLCanvasElement>("[data-hf-color-grading-canvas]");
|
||||
if (!canvas) throw new Error("Expected color grading canvas");
|
||||
|
||||
const frame = document.createElement("img");
|
||||
frame.id = "__render_frame_hero-video__";
|
||||
frame.className = "__render_frame__";
|
||||
Object.defineProperty(frame, "complete", { value: true, configurable: true });
|
||||
Object.defineProperty(frame, "naturalWidth", { value: 640, configurable: true });
|
||||
Object.defineProperty(frame, "naturalHeight", { value: 360, configurable: true });
|
||||
video.parentNode?.insertBefore(frame, canvas.nextSibling);
|
||||
|
||||
expect(video.nextSibling).toBe(canvas);
|
||||
expect(canvas.nextSibling).toBe(frame);
|
||||
|
||||
runtime.redraw();
|
||||
|
||||
expect(video.nextSibling).toBe(frame);
|
||||
expect(frame.nextSibling).toBe(canvas);
|
||||
});
|
||||
|
||||
it("updates before-after compare uniforms without changing the source grading", () => {
|
||||
const video = makeDrawableVideo();
|
||||
document.body.appendChild(video);
|
||||
@@ -279,6 +366,86 @@ describe("createColorGradingRuntime", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes finishing detail uniforms into the shader", () => {
|
||||
const video = makeDrawableVideo();
|
||||
video.setAttribute(
|
||||
HF_COLOR_GRADING_ATTR,
|
||||
serializeHfColorGrading({
|
||||
adjust: { vibrance: 0.35 },
|
||||
details: {
|
||||
vignette: 0.4,
|
||||
vignetteMidpoint: 0.35,
|
||||
vignetteRoundness: -0.25,
|
||||
vignetteFeather: 0.8,
|
||||
grain: 0.2,
|
||||
grainSize: 0.7,
|
||||
grainRoughness: 0.3,
|
||||
},
|
||||
effects: { blur: 0.3, pixelate: 0.1 },
|
||||
}),
|
||||
);
|
||||
document.body.appendChild(video);
|
||||
|
||||
runtime = createColorGradingRuntime();
|
||||
|
||||
if (!lastUniform1f) throw new Error("Expected WebGL uniform calls");
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_vibrance", 0.35);
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_vignette", 0.4);
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_vignetteMidpoint", 0.35);
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_vignetteRoundness", -0.25);
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_vignetteFeather", 0.8);
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_grain", 0.2);
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_grainSize", 0.7);
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_grainRoughness", 0.3);
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_grainSeed", expect.any(Number));
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_blur", 0.3);
|
||||
expect(lastUniform1f).toHaveBeenCalledWith("u_pixelate", 0.1);
|
||||
});
|
||||
|
||||
it("uses the effected media sample as the graded shader input", () => {
|
||||
const video = makeDrawableVideo();
|
||||
video.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ effects: { blur: 0.25 } }));
|
||||
document.body.appendChild(video);
|
||||
|
||||
runtime = createColorGradingRuntime();
|
||||
|
||||
const fragment = lastShaderSources.find((source) => source.includes("sampleMedia"));
|
||||
expect(fragment).toContain("vec4 originalSample = sampleSource(uv);");
|
||||
expect(fragment).toContain("vec4 sampleColor = sampleMedia(uv);");
|
||||
expect(fragment).toContain("uniform sampler2D u_blurSource;");
|
||||
expect(fragment).toContain("floor(clamp(uv, vec2(0.0), vec2(0.999999)) * cells)");
|
||||
expect(fragment).toContain("vec2 vignetteAspect");
|
||||
expect(fragment).toContain("float vibranceWeight");
|
||||
expect(fragment).toContain("float vignettePower");
|
||||
expect(fragment).toContain("float grainMask");
|
||||
expect(fragment).toContain("float grainPixelSize");
|
||||
expect(fragment).toContain("float blackPoint = clamp(u_blacks * 0.18");
|
||||
expect(fragment).toContain("float whitePoint = clamp(1.0 - u_whites * 0.18");
|
||||
expect(fragment).toContain("vec3 color = sampleColor.rgb * pow(2.0, u_exposure);");
|
||||
expect(fragment).not.toContain("sampleSoft");
|
||||
const blurFragment = lastShaderSources.find((source) =>
|
||||
source.includes("uniform vec2 u_direction;"),
|
||||
);
|
||||
expect(blurFragment).toContain("stepUv * 12.0");
|
||||
expect(blurFragment).toContain("color.rgb *= color.a;");
|
||||
expect(blurFragment).toContain("color.rgb /= color.a;");
|
||||
});
|
||||
|
||||
it("renders blur passes at media resolution to avoid blocky high-strength blur", () => {
|
||||
const video = makeDrawableVideo();
|
||||
video.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ effects: { blur: 1 } }));
|
||||
document.body.appendChild(video);
|
||||
|
||||
runtime = createColorGradingRuntime();
|
||||
|
||||
expect(texImage2DCalls.some((args) => args[3] === 640 && args[4] === 360)).toBe(true);
|
||||
expect(
|
||||
lastUniform1f?.mock.calls.some(
|
||||
([name, value]) => name === "u_radius" && typeof value === "number" && value > 30,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("loads cube LUTs and enables LUT uniforms", async () => {
|
||||
const fetchMock = stubCubeLutFetch();
|
||||
const origin = window.location.origin;
|
||||
@@ -305,4 +472,85 @@ describe("createColorGradingRuntime", () => {
|
||||
expect(lastUniform3f).toHaveBeenCalledWith("u_lutDomainMax", 1, 1, 1);
|
||||
expect(runtime.getStatus("#hero-video").message).toBe("Shader + LUT active");
|
||||
});
|
||||
|
||||
it("bounds the runtime LUT cache", async () => {
|
||||
const fetchMock = stubCubeLutFetch();
|
||||
const origin = window.location.origin;
|
||||
document.head.innerHTML = `<base href="${origin}/api/projects/demo/preview/">`;
|
||||
const { video } = startRuntimeWithVideo();
|
||||
|
||||
for (let index = 0; index < 17; index += 1) {
|
||||
runtime?.setGrading(`#${video.id}`, {
|
||||
lut: { src: `assets/luts/${index}.cube`, intensity: 1 },
|
||||
});
|
||||
await flushLutLoad();
|
||||
}
|
||||
runtime?.setGrading(`#${video.id}`, {
|
||||
lut: { src: "assets/luts/0.cube", intensity: 1 },
|
||||
});
|
||||
await flushLutLoad();
|
||||
|
||||
const firstUrl = `${origin}/api/projects/demo/preview/assets/luts/0.cube`;
|
||||
expect(fetchMock.mock.calls.filter(([url]) => url === firstUrl)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reports unsupported LUT files in runtime status", async () => {
|
||||
stubCubeLutFetch(`
|
||||
LUT_1D_SIZE 2
|
||||
0 0 0
|
||||
1 1 1
|
||||
`);
|
||||
const origin = window.location.origin;
|
||||
document.head.innerHTML = `<base href="${origin}/api/projects/demo/preview/">`;
|
||||
const video = makeDrawableVideo();
|
||||
video.setAttribute(
|
||||
HF_COLOR_GRADING_ATTR,
|
||||
serializeHfColorGrading({ lut: { src: "assets/luts/oned.cube", intensity: 1 } }),
|
||||
);
|
||||
document.body.appendChild(video);
|
||||
|
||||
runtime = createColorGradingRuntime();
|
||||
await flushLutLoad();
|
||||
|
||||
expect(runtime.getStatus("#hero-video").message).toContain(
|
||||
"LUT error: 1D cube LUTs are not supported yet",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports media texture upload failures in runtime status", () => {
|
||||
getContextSpy.mockImplementation((type: string) =>
|
||||
type === "webgl" ? createMockWebGl({ failMediaUpload: true }) : null,
|
||||
);
|
||||
const video = makeDrawableVideo();
|
||||
document.body.appendChild(video);
|
||||
|
||||
runtime = createColorGradingRuntime();
|
||||
|
||||
expect(runtime.getStatus("#hero-video")).toEqual({
|
||||
state: "unavailable",
|
||||
message: "Media cannot be sampled by WebGL",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the source media on WebGL context loss and redraws after restore", () => {
|
||||
const { video, canvas } = startRuntimeWithVideo();
|
||||
|
||||
const lost = new Event("webglcontextlost", { cancelable: true });
|
||||
canvas.dispatchEvent(lost);
|
||||
|
||||
expect(lost.defaultPrevented).toBe(true);
|
||||
expect(canvas.style.display).toBe("none");
|
||||
expect(video.hasAttribute("data-hf-color-grading-source-hidden")).toBe(false);
|
||||
expect(video.style.getPropertyValue("opacity")).toBe("");
|
||||
expect(runtime?.getStatus("#hero-video")).toEqual({
|
||||
state: "unavailable",
|
||||
message: "WebGL context lost",
|
||||
});
|
||||
|
||||
canvas.dispatchEvent(new Event("webglcontextrestored"));
|
||||
|
||||
expect(runtime?.getStatus("#hero-video").state).toBe("active");
|
||||
expect(video.hasAttribute("data-hf-color-grading-source-hidden")).toBe(true);
|
||||
expect(video.style.getPropertyValue("opacity")).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
HF_COLOR_GRADING_ATTR,
|
||||
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
|
||||
isHfColorGradingActive,
|
||||
normalizeHfColorGrading,
|
||||
normalizeHfColorGradingWithVariables,
|
||||
@@ -7,7 +8,13 @@ import {
|
||||
type HfColorGradingTarget,
|
||||
type NormalizedHfColorGrading,
|
||||
} from "../colorGrading";
|
||||
import { packCubeLutToRgba8, parseCubeLut, type CubeLut3D, type CubeLutVec3 } from "../colorLuts";
|
||||
import {
|
||||
DEFAULT_MAX_CUBE_LUT_SIZE,
|
||||
packCubeLutToRgba8,
|
||||
parseCubeLut,
|
||||
type CubeLut3D,
|
||||
type CubeLutVec3,
|
||||
} from "../colorLuts";
|
||||
import { copyMediaVisualStyles } from "../inline-scripts/parityContract";
|
||||
import { swallow } from "./diagnostics";
|
||||
|
||||
@@ -34,12 +41,15 @@ interface ProgramInfo {
|
||||
program: WebGLProgram;
|
||||
texture: WebGLTexture;
|
||||
lutTexture: WebGLTexture;
|
||||
quad: WebGLBuffer;
|
||||
position: number;
|
||||
source: WebGLUniformLocation | null;
|
||||
blurSource: WebGLUniformLocation | null;
|
||||
lut: WebGLUniformLocation | null;
|
||||
resolution: WebGLUniformLocation | null;
|
||||
uvScale: WebGLUniformLocation | null;
|
||||
uvOffset: WebGLUniformLocation | null;
|
||||
blurReady: WebGLUniformLocation | null;
|
||||
lutEnabled: WebGLUniformLocation | null;
|
||||
lutSize: WebGLUniformLocation | null;
|
||||
lutTextureSize: WebGLUniformLocation | null;
|
||||
@@ -54,7 +64,18 @@ interface ProgramInfo {
|
||||
blacks: WebGLUniformLocation | null;
|
||||
temperature: WebGLUniformLocation | null;
|
||||
tint: WebGLUniformLocation | null;
|
||||
vibrance: WebGLUniformLocation | null;
|
||||
saturation: WebGLUniformLocation | null;
|
||||
vignette: WebGLUniformLocation | null;
|
||||
vignetteMidpoint: WebGLUniformLocation | null;
|
||||
vignetteRoundness: WebGLUniformLocation | null;
|
||||
vignetteFeather: WebGLUniformLocation | null;
|
||||
grain: WebGLUniformLocation | null;
|
||||
grainSize: WebGLUniformLocation | null;
|
||||
grainRoughness: WebGLUniformLocation | null;
|
||||
grainSeed: WebGLUniformLocation | null;
|
||||
blur: WebGLUniformLocation | null;
|
||||
pixelate: WebGLUniformLocation | null;
|
||||
intensity: WebGLUniformLocation | null;
|
||||
compareEnabled: WebGLUniformLocation | null;
|
||||
comparePosition: WebGLUniformLocation | null;
|
||||
@@ -62,6 +83,29 @@ interface ProgramInfo {
|
||||
compareLineWidth: WebGLUniformLocation | null;
|
||||
}
|
||||
|
||||
interface BlurProgramInfo {
|
||||
program: WebGLProgram;
|
||||
quad: WebGLBuffer;
|
||||
position: number;
|
||||
source: WebGLUniformLocation | null;
|
||||
resolution: WebGLUniformLocation | null;
|
||||
direction: WebGLUniformLocation | null;
|
||||
radius: WebGLUniformLocation | null;
|
||||
}
|
||||
|
||||
interface RenderTarget {
|
||||
texture: WebGLTexture;
|
||||
framebuffer: WebGLFramebuffer;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface EffectTargets {
|
||||
blurProgram: BlurProgramInfo;
|
||||
scratch: RenderTarget;
|
||||
blur: RenderTarget;
|
||||
}
|
||||
|
||||
interface RuntimeColorGradingCompareState {
|
||||
enabled: boolean;
|
||||
position: number;
|
||||
@@ -79,6 +123,9 @@ interface ColorGradingEntry {
|
||||
lut: RuntimeLutTexture | null;
|
||||
lutLoadingSrc: string | null;
|
||||
lutError: string | null;
|
||||
drawError: string | null;
|
||||
effectTargets: EffectTargets | null;
|
||||
effectError: string | null;
|
||||
source: EntrySource;
|
||||
animationFrame: number | null;
|
||||
videoFrameHandle: number | null;
|
||||
@@ -92,6 +139,8 @@ interface ColorGradingEntry {
|
||||
sourceOpacityForCanvas: string;
|
||||
sourceVisibleForCanvas: boolean;
|
||||
hasDrawn: boolean;
|
||||
contextLost: boolean;
|
||||
grainSeed: number;
|
||||
destroyed: boolean;
|
||||
}
|
||||
|
||||
@@ -133,7 +182,6 @@ type WindowWithColorGrading = Window & {
|
||||
|
||||
interface RuntimeLutTexture {
|
||||
src: string;
|
||||
title: string | null;
|
||||
size: number;
|
||||
domainMin: CubeLutVec3;
|
||||
domainMax: CubeLutVec3;
|
||||
@@ -150,7 +198,8 @@ const LUT_CACHE = new Map<string, LutCacheEntry>();
|
||||
const COLOR_GRADING_CANVAS_ATTR = "data-hf-color-grading-canvas";
|
||||
const COLOR_GRADING_SOURCE_HIDDEN_ATTR = "data-hf-color-grading-source-hidden";
|
||||
const COLOR_GRADING_CANVAS_CLASS = "__hf_color_grading_canvas__";
|
||||
const MAX_LUT_SIZE = 64;
|
||||
// Map insertion order gives us simple FIFO eviction for authoring sessions that cycle LUTs.
|
||||
const MAX_LUT_CACHE_ENTRIES = 16;
|
||||
const DEFAULT_COMPARE: RuntimeColorGradingCompareState = {
|
||||
enabled: false,
|
||||
position: 0.5,
|
||||
@@ -195,10 +244,12 @@ const FRAGMENT_SHADER = [
|
||||
"#endif",
|
||||
"varying vec2 v_uv;",
|
||||
"uniform sampler2D u_source;",
|
||||
"uniform sampler2D u_blurSource;",
|
||||
"uniform sampler2D u_lut;",
|
||||
"uniform vec2 u_resolution;",
|
||||
"uniform vec2 u_uvScale;",
|
||||
"uniform vec2 u_uvOffset;",
|
||||
"uniform float u_blurReady;",
|
||||
"uniform float u_lutEnabled;",
|
||||
"uniform float u_lutSize;",
|
||||
"uniform vec2 u_lutTextureSize;",
|
||||
@@ -213,13 +264,44 @@ const FRAGMENT_SHADER = [
|
||||
"uniform float u_blacks;",
|
||||
"uniform float u_temperature;",
|
||||
"uniform float u_tint;",
|
||||
"uniform float u_vibrance;",
|
||||
"uniform float u_saturation;",
|
||||
"uniform float u_vignette;",
|
||||
"uniform float u_vignetteMidpoint;",
|
||||
"uniform float u_vignetteRoundness;",
|
||||
"uniform float u_vignetteFeather;",
|
||||
"uniform float u_grain;",
|
||||
"uniform float u_grainSize;",
|
||||
"uniform float u_grainRoughness;",
|
||||
"uniform float u_grainSeed;",
|
||||
"uniform float u_blur;",
|
||||
"uniform float u_pixelate;",
|
||||
"uniform float u_intensity;",
|
||||
"uniform float u_compareEnabled;",
|
||||
"uniform float u_comparePosition;",
|
||||
"uniform float u_compareSoftness;",
|
||||
"uniform float u_compareLineWidth;",
|
||||
"float lumaOf(vec3 c){ return dot(c, vec3(0.2126, 0.7152, 0.0722)); }",
|
||||
"float grainHash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }",
|
||||
"float colorSaturation(vec3 c){ return max(max(c.r, c.g), c.b) - min(min(c.r, c.g), c.b); }",
|
||||
"vec2 clampUv(vec2 uv){ return clamp(uv, vec2(0.0), vec2(1.0)); }",
|
||||
"vec4 sampleSource(vec2 uv){ return texture2D(u_source, clampUv(uv)); }",
|
||||
"vec4 sampleBlur(vec2 uv){ return texture2D(u_blurSource, clampUv(uv)); }",
|
||||
"vec4 sampleMedia(vec2 uv){",
|
||||
" float pixel = clamp(u_pixelate, 0.0, 1.0);",
|
||||
" vec2 sampleUv = uv;",
|
||||
" if (pixel > 0.0) {",
|
||||
" float blockSize = mix(1.0, 48.0, pixel);",
|
||||
" vec2 cells = max(u_resolution / blockSize, vec2(1.0));",
|
||||
" sampleUv = (floor(clamp(uv, vec2(0.0), vec2(0.999999)) * cells) + 0.5) / cells;",
|
||||
" }",
|
||||
" vec4 base = sampleSource(sampleUv);",
|
||||
" float blur = clamp(u_blur, 0.0, 1.0);",
|
||||
" if (blur > 0.0 && u_blurReady > 0.5) {",
|
||||
" base = mix(base, sampleBlur(sampleUv), blur);",
|
||||
" }",
|
||||
" return base;",
|
||||
"}",
|
||||
"vec3 sampleLut(float r, float g, float b){",
|
||||
" float size = max(u_lutSize, 2.0);",
|
||||
" float x = (r + b * size + 0.5) / max(u_lutTextureSize.x, 1.0);",
|
||||
@@ -257,24 +339,52 @@ const FRAGMENT_SHADER = [
|
||||
" gl_FragColor = vec4(0.0);",
|
||||
" return;",
|
||||
" }",
|
||||
" vec4 sampleColor = texture2D(u_source, uv);",
|
||||
" vec3 original = sampleColor.rgb;",
|
||||
" vec3 color = original * pow(2.0, u_exposure);",
|
||||
" vec4 originalSample = sampleSource(uv);",
|
||||
" vec4 sampleColor = sampleMedia(uv);",
|
||||
" vec3 original = originalSample.rgb;",
|
||||
" vec3 color = sampleColor.rgb * pow(2.0, u_exposure);",
|
||||
" float y = lumaOf(color);",
|
||||
" float shadowMask = 1.0 - smoothstep(0.0, 0.65, y);",
|
||||
" float highlightMask = smoothstep(0.35, 1.0, y);",
|
||||
" color += u_shadows * 0.35 * shadowMask;",
|
||||
" color += u_highlights * 0.35 * highlightMask;",
|
||||
" color += u_blacks * 0.25 * (1.0 - smoothstep(0.0, 0.35, y));",
|
||||
" color += u_whites * 0.25 * smoothstep(0.65, 1.0, y);",
|
||||
" float blackPoint = clamp(u_blacks * 0.18, -0.18, 0.18);",
|
||||
" float whitePoint = clamp(1.0 - u_whites * 0.18, 0.82, 1.18);",
|
||||
" color = (color - blackPoint) / max(whitePoint - blackPoint, 0.2);",
|
||||
" color.r += u_temperature * 0.08 + u_tint * 0.04;",
|
||||
" color.b -= u_temperature * 0.08 - u_tint * 0.04;",
|
||||
" color.g -= u_tint * 0.08;",
|
||||
" color = (color - 0.5) * max(0.0, 1.0 + u_contrast) + 0.5;",
|
||||
" float satLuma = lumaOf(color);",
|
||||
" float currentSat = clamp(colorSaturation(color), 0.0, 1.0);",
|
||||
" float skinLike = smoothstep(0.02, 0.18, color.r - color.g) * smoothstep(0.0, 0.16, color.g - color.b) * smoothstep(0.18, 0.82, satLuma);",
|
||||
" float vibranceWeight = (1.0 - currentSat * 0.72) * mix(1.0, 0.55, skinLike);",
|
||||
" color = mix(vec3(satLuma), color, max(0.0, 1.0 + u_vibrance * vibranceWeight));",
|
||||
" color = mix(vec3(satLuma), color, max(0.0, 1.0 + u_saturation));",
|
||||
" color = clamp(color, 0.0, 1.0);",
|
||||
" color = clamp(applyLut(color), 0.0, 1.0);",
|
||||
" vec2 vignetteAspect = u_resolution.x > u_resolution.y",
|
||||
" ? vec2(u_resolution.x / max(u_resolution.y, 1.0), 1.0)",
|
||||
" : vec2(1.0, u_resolution.y / max(u_resolution.x, 1.0));",
|
||||
" vec2 vignetteUv = abs((v_uv - vec2(0.5)) * 2.0) * vignetteAspect;",
|
||||
" float vignettePower = mix(8.0, 1.8, clamp(u_vignetteRoundness * 0.5 + 0.5, 0.0, 1.0));",
|
||||
" float vignetteDistance = pow(pow(vignetteUv.x, vignettePower) + pow(vignetteUv.y, vignettePower), 1.0 / vignettePower);",
|
||||
" float vignetteMidpoint = mix(0.22, 1.08, clamp(u_vignetteMidpoint, 0.0, 1.0));",
|
||||
" float vignetteFeather = mix(0.08, 0.72, clamp(u_vignetteFeather, 0.0, 1.0));",
|
||||
" float vignetteMask = smoothstep(vignetteMidpoint, vignetteMidpoint + vignetteFeather, vignetteDistance);",
|
||||
" color *= 1.0 - vignetteMask * clamp(u_vignette, 0.0, 1.0) * 0.75;",
|
||||
" float grainAmount = clamp(u_grain, 0.0, 1.0);",
|
||||
" if (grainAmount > 0.0) {",
|
||||
" float grainPixelSize = mix(1.0, 6.0, clamp(u_grainSize, 0.0, 1.0));",
|
||||
" vec2 grainCoord = floor(gl_FragCoord.xy / grainPixelSize) + vec2(u_grainSeed, u_grainSeed * 1.37);",
|
||||
" float grainBase = grainHash(grainCoord) - grainHash(grainCoord + vec2(19.19, 73.31));",
|
||||
" float grainFine = grainHash(gl_FragCoord.xy + vec2(u_grainSeed * 2.11, u_grainSeed * 0.71)) - 0.5;",
|
||||
" float grain = mix(grainBase * 0.7, grainBase + grainFine * 0.35, clamp(u_grainRoughness, 0.0, 1.0));",
|
||||
" float grainLuma = lumaOf(color);",
|
||||
" float grainMask = smoothstep(0.02, 0.55, grainLuma) * (1.0 - smoothstep(0.88, 1.0, grainLuma));",
|
||||
" color += grain * grainAmount * mix(0.025, 0.08, grainMask);",
|
||||
" }",
|
||||
" color = clamp(color, 0.0, 1.0);",
|
||||
" vec3 graded = mix(original, color, u_intensity);",
|
||||
" if (u_compareEnabled > 0.5) {",
|
||||
" float pos = clamp(u_comparePosition, 0.0, 1.0);",
|
||||
@@ -293,6 +403,54 @@ const FRAGMENT_SHADER = [
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
const BLUR_FRAGMENT_SHADER = [
|
||||
"#ifdef GL_FRAGMENT_PRECISION_HIGH",
|
||||
"precision highp float;",
|
||||
"#else",
|
||||
"precision mediump float;",
|
||||
"#endif",
|
||||
"varying vec2 v_uv;",
|
||||
"uniform sampler2D u_source;",
|
||||
"uniform vec2 u_resolution;",
|
||||
"uniform vec2 u_direction;",
|
||||
"uniform float u_radius;",
|
||||
"vec4 readSource(vec2 uv){",
|
||||
" vec4 color = texture2D(u_source, clamp(uv, vec2(0.0), vec2(1.0)));",
|
||||
" color.rgb *= color.a;",
|
||||
" return color;",
|
||||
"}",
|
||||
"void main(){",
|
||||
" vec2 stepUv = u_direction * max(u_radius, 0.0) / max(u_resolution, vec2(1.0)) / 12.0;",
|
||||
" vec4 color = readSource(v_uv) * 0.08077993;",
|
||||
" color += readSource(v_uv + stepUv * 1.0) * 0.07918038;",
|
||||
" color += readSource(v_uv - stepUv * 1.0) * 0.07918038;",
|
||||
" color += readSource(v_uv + stepUv * 2.0) * 0.07456928;",
|
||||
" color += readSource(v_uv - stepUv * 2.0) * 0.07456928;",
|
||||
" color += readSource(v_uv + stepUv * 3.0) * 0.06747307;",
|
||||
" color += readSource(v_uv - stepUv * 3.0) * 0.06747307;",
|
||||
" color += readSource(v_uv + stepUv * 4.0) * 0.05865827;",
|
||||
" color += readSource(v_uv - stepUv * 4.0) * 0.05865827;",
|
||||
" color += readSource(v_uv + stepUv * 5.0) * 0.04899551;",
|
||||
" color += readSource(v_uv - stepUv * 5.0) * 0.04899551;",
|
||||
" color += readSource(v_uv + stepUv * 6.0) * 0.03931982;",
|
||||
" color += readSource(v_uv - stepUv * 6.0) * 0.03931982;",
|
||||
" color += readSource(v_uv + stepUv * 7.0) * 0.03031761;",
|
||||
" color += readSource(v_uv - stepUv * 7.0) * 0.03031761;",
|
||||
" color += readSource(v_uv + stepUv * 8.0) * 0.02245983;",
|
||||
" color += readSource(v_uv - stepUv * 8.0) * 0.02245983;",
|
||||
" color += readSource(v_uv + stepUv * 9.0) * 0.01598624;",
|
||||
" color += readSource(v_uv - stepUv * 9.0) * 0.01598624;",
|
||||
" color += readSource(v_uv + stepUv * 10.0) * 0.01093238;",
|
||||
" color += readSource(v_uv - stepUv * 10.0) * 0.01093238;",
|
||||
" color += readSource(v_uv + stepUv * 11.0) * 0.00718308;",
|
||||
" color += readSource(v_uv - stepUv * 11.0) * 0.00718308;",
|
||||
" color += readSource(v_uv + stepUv * 12.0) * 0.00453456;",
|
||||
" color += readSource(v_uv - stepUv * 12.0) * 0.00453456;",
|
||||
" if (color.a > 0.0001) color.rgb /= color.a;",
|
||||
" gl_FragColor = color;",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
function isColorGradingMediaElement(value: Element): value is ColorGradingMediaElement {
|
||||
return value instanceof HTMLVideoElement || value instanceof HTMLImageElement;
|
||||
}
|
||||
@@ -314,9 +472,12 @@ function compileShader(
|
||||
return shader;
|
||||
}
|
||||
|
||||
function createProgram(gl: WebGLRenderingContext): WebGLProgram | null {
|
||||
function createProgram(
|
||||
gl: WebGLRenderingContext,
|
||||
fragmentSource = FRAGMENT_SHADER,
|
||||
): WebGLProgram | null {
|
||||
const vertex = compileShader(gl, VERTEX_SHADER, gl.VERTEX_SHADER);
|
||||
const fragment = compileShader(gl, FRAGMENT_SHADER, gl.FRAGMENT_SHADER);
|
||||
const fragment = compileShader(gl, fragmentSource, gl.FRAGMENT_SHADER);
|
||||
if (!vertex || !fragment) {
|
||||
if (vertex) gl.deleteShader(vertex);
|
||||
if (fragment) gl.deleteShader(fragment);
|
||||
@@ -349,6 +510,56 @@ function createTexture(gl: WebGLRenderingContext, filter = gl.LINEAR): WebGLText
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createBlurProgramInfo(
|
||||
gl: WebGLRenderingContext,
|
||||
quad: WebGLBuffer,
|
||||
): BlurProgramInfo | null {
|
||||
const program = createProgram(gl, BLUR_FRAGMENT_SHADER);
|
||||
if (!program) return null;
|
||||
return {
|
||||
program,
|
||||
quad,
|
||||
position: gl.getAttribLocation(program, "a_pos"),
|
||||
source: gl.getUniformLocation(program, "u_source"),
|
||||
resolution: gl.getUniformLocation(program, "u_resolution"),
|
||||
direction: gl.getUniformLocation(program, "u_direction"),
|
||||
radius: gl.getUniformLocation(program, "u_radius"),
|
||||
};
|
||||
}
|
||||
|
||||
function createRenderTarget(gl: WebGLRenderingContext): RenderTarget | null {
|
||||
const texture = createTexture(gl);
|
||||
const framebuffer = gl.createFramebuffer();
|
||||
if (!texture || !framebuffer) {
|
||||
if (texture) gl.deleteTexture(texture);
|
||||
if (framebuffer) gl.deleteFramebuffer(framebuffer);
|
||||
return null;
|
||||
}
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
|
||||
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
if (status !== gl.FRAMEBUFFER_COMPLETE) {
|
||||
gl.deleteTexture(texture);
|
||||
gl.deleteFramebuffer(framebuffer);
|
||||
return null;
|
||||
}
|
||||
return { texture, framebuffer, width: 1, height: 1 };
|
||||
}
|
||||
|
||||
function resizeRenderTarget(
|
||||
gl: WebGLRenderingContext,
|
||||
target: RenderTarget,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
if (target.width === width && target.height === height) return;
|
||||
target.width = width;
|
||||
target.height = height;
|
||||
gl.bindTexture(gl.TEXTURE_2D, target.texture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||
}
|
||||
|
||||
function createProgramInfo(canvas: HTMLCanvasElement): {
|
||||
gl: WebGLRenderingContext;
|
||||
program: ProgramInfo;
|
||||
@@ -384,12 +595,15 @@ function createProgramInfo(canvas: HTMLCanvasElement): {
|
||||
program,
|
||||
texture,
|
||||
lutTexture,
|
||||
quad,
|
||||
position: gl.getAttribLocation(program, "a_pos"),
|
||||
source: gl.getUniformLocation(program, "u_source"),
|
||||
blurSource: gl.getUniformLocation(program, "u_blurSource"),
|
||||
lut: gl.getUniformLocation(program, "u_lut"),
|
||||
resolution: gl.getUniformLocation(program, "u_resolution"),
|
||||
uvScale: gl.getUniformLocation(program, "u_uvScale"),
|
||||
uvOffset: gl.getUniformLocation(program, "u_uvOffset"),
|
||||
blurReady: gl.getUniformLocation(program, "u_blurReady"),
|
||||
lutEnabled: gl.getUniformLocation(program, "u_lutEnabled"),
|
||||
lutSize: gl.getUniformLocation(program, "u_lutSize"),
|
||||
lutTextureSize: gl.getUniformLocation(program, "u_lutTextureSize"),
|
||||
@@ -404,7 +618,18 @@ function createProgramInfo(canvas: HTMLCanvasElement): {
|
||||
blacks: gl.getUniformLocation(program, "u_blacks"),
|
||||
temperature: gl.getUniformLocation(program, "u_temperature"),
|
||||
tint: gl.getUniformLocation(program, "u_tint"),
|
||||
vibrance: gl.getUniformLocation(program, "u_vibrance"),
|
||||
saturation: gl.getUniformLocation(program, "u_saturation"),
|
||||
vignette: gl.getUniformLocation(program, "u_vignette"),
|
||||
vignetteMidpoint: gl.getUniformLocation(program, "u_vignetteMidpoint"),
|
||||
vignetteRoundness: gl.getUniformLocation(program, "u_vignetteRoundness"),
|
||||
vignetteFeather: gl.getUniformLocation(program, "u_vignetteFeather"),
|
||||
grain: gl.getUniformLocation(program, "u_grain"),
|
||||
grainSize: gl.getUniformLocation(program, "u_grainSize"),
|
||||
grainRoughness: gl.getUniformLocation(program, "u_grainRoughness"),
|
||||
grainSeed: gl.getUniformLocation(program, "u_grainSeed"),
|
||||
blur: gl.getUniformLocation(program, "u_blur"),
|
||||
pixelate: gl.getUniformLocation(program, "u_pixelate"),
|
||||
intensity: gl.getUniformLocation(program, "u_intensity"),
|
||||
compareEnabled: gl.getUniformLocation(program, "u_compareEnabled"),
|
||||
comparePosition: gl.getUniformLocation(program, "u_comparePosition"),
|
||||
@@ -452,6 +677,24 @@ function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : "LUT failed to load";
|
||||
}
|
||||
|
||||
function hashStringSeed(value: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0) % 10000;
|
||||
}
|
||||
|
||||
function seedForElement(element: ColorGradingMediaElement): number {
|
||||
const key =
|
||||
element.id ||
|
||||
element.currentSrc ||
|
||||
element.getAttribute("src") ||
|
||||
`${element.tagName}:${Array.prototype.indexOf.call(element.parentNode?.children ?? [], element)}`;
|
||||
return hashStringSeed(key);
|
||||
}
|
||||
|
||||
function getCubeLut(src: string): LutCacheEntry {
|
||||
const resolved = resolveLutUrl(src);
|
||||
if ("error" in resolved) return { state: "error", message: resolved.error };
|
||||
@@ -463,13 +706,26 @@ function getCubeLut(src: string): LutCacheEntry {
|
||||
if (!response.ok) throw new Error(`Failed to load LUT (${response.status})`);
|
||||
return response.text();
|
||||
})
|
||||
.then((text) => parseCubeLut(text, { maxSize: MAX_LUT_SIZE }));
|
||||
.then((text) => parseCubeLut(text, { maxSize: DEFAULT_MAX_CUBE_LUT_SIZE }));
|
||||
|
||||
const pending: LutCacheEntry = { state: "pending", promise };
|
||||
while (LUT_CACHE.size >= MAX_LUT_CACHE_ENTRIES) {
|
||||
const oldest = LUT_CACHE.keys().next().value;
|
||||
if (!oldest) break;
|
||||
LUT_CACHE.delete(oldest);
|
||||
}
|
||||
LUT_CACHE.set(resolved.href, pending);
|
||||
promise.then(
|
||||
(lut) => LUT_CACHE.set(resolved.href, { state: "ready", lut }),
|
||||
(err) => LUT_CACHE.set(resolved.href, { state: "error", message: errorMessage(err) }),
|
||||
(lut) => {
|
||||
if (LUT_CACHE.get(resolved.href) === pending) {
|
||||
LUT_CACHE.set(resolved.href, { state: "ready", lut });
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (LUT_CACHE.get(resolved.href) === pending) {
|
||||
LUT_CACHE.set(resolved.href, { state: "error", message: errorMessage(err) });
|
||||
}
|
||||
},
|
||||
);
|
||||
return pending;
|
||||
}
|
||||
@@ -483,7 +739,7 @@ function uploadEntryLut(
|
||||
const packed = packCubeLutToRgba8(lut);
|
||||
const { gl, program } = entry;
|
||||
try {
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.activeTexture(gl.TEXTURE2);
|
||||
gl.bindTexture(gl.TEXTURE_2D, program.lutTexture);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
||||
gl.texImage2D(
|
||||
@@ -499,7 +755,6 @@ function uploadEntryLut(
|
||||
);
|
||||
entry.lut = {
|
||||
src,
|
||||
title: lut.title,
|
||||
size: lut.size,
|
||||
domainMin: lut.domainMin,
|
||||
domainMax: lut.domainMax,
|
||||
@@ -518,6 +773,149 @@ function uploadEntryLut(
|
||||
}
|
||||
}
|
||||
|
||||
function destroyRenderTarget(gl: WebGLRenderingContext, target: RenderTarget): void {
|
||||
gl.deleteTexture(target.texture);
|
||||
gl.deleteFramebuffer(target.framebuffer);
|
||||
}
|
||||
|
||||
function destroyEffectTargets(entry: ColorGradingEntry): void {
|
||||
const targets = entry.effectTargets;
|
||||
if (!targets) return;
|
||||
entry.gl.deleteProgram(targets.blurProgram.program);
|
||||
destroyRenderTarget(entry.gl, targets.scratch);
|
||||
destroyRenderTarget(entry.gl, targets.blur);
|
||||
entry.effectTargets = null;
|
||||
}
|
||||
|
||||
function destroyProgramResources(entry: ColorGradingEntry): void {
|
||||
destroyEffectTargets(entry);
|
||||
entry.gl.deleteTexture(entry.program.texture);
|
||||
entry.gl.deleteTexture(entry.program.lutTexture);
|
||||
entry.gl.deleteBuffer(entry.program.quad);
|
||||
entry.gl.deleteProgram(entry.program.program);
|
||||
}
|
||||
|
||||
function replaceProgramResources(entry: ColorGradingEntry): boolean {
|
||||
const created = createProgramInfo(entry.canvas);
|
||||
if (!created) return false;
|
||||
destroyProgramResources(entry);
|
||||
entry.gl = created.gl;
|
||||
entry.program = created.program;
|
||||
entry.lut = null;
|
||||
entry.lutLoadingSrc = null;
|
||||
entry.lutError = null;
|
||||
entry.effectError = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
function restoreSourceElement(entry: ColorGradingEntry): void {
|
||||
if (!entry.sourceHidden) return;
|
||||
entry.element.removeAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR);
|
||||
const opacity = entry.element.style.getPropertyValue("opacity");
|
||||
const priority = entry.element.style.getPropertyPriority("opacity");
|
||||
if (opacity === "0" && priority === "important") {
|
||||
if (entry.sourceInlineOpacity === null) {
|
||||
entry.element.style.removeProperty("opacity");
|
||||
} else {
|
||||
entry.element.style.setProperty(
|
||||
"opacity",
|
||||
entry.sourceInlineOpacity,
|
||||
entry.sourceInlineOpacityPriority,
|
||||
);
|
||||
}
|
||||
}
|
||||
entry.sourceHidden = false;
|
||||
}
|
||||
|
||||
function ensureEffectTargets(entry: ColorGradingEntry): EffectTargets | null {
|
||||
if (entry.effectTargets) return entry.effectTargets;
|
||||
const { gl } = entry;
|
||||
const blurProgram = createBlurProgramInfo(gl, entry.program.quad);
|
||||
const scratch = createRenderTarget(gl);
|
||||
const blur = createRenderTarget(gl);
|
||||
if (!blurProgram || !scratch || !blur) {
|
||||
if (blurProgram) gl.deleteProgram(blurProgram.program);
|
||||
if (scratch) destroyRenderTarget(gl, scratch);
|
||||
if (blur) destroyRenderTarget(gl, blur);
|
||||
entry.effectError = "Framebuffer effects unavailable";
|
||||
return null;
|
||||
}
|
||||
entry.effectError = null;
|
||||
entry.effectTargets = { blurProgram, scratch, blur };
|
||||
return entry.effectTargets;
|
||||
}
|
||||
|
||||
function resizeEffectTargetPair(
|
||||
gl: WebGLRenderingContext,
|
||||
scratch: RenderTarget,
|
||||
output: RenderTarget,
|
||||
width: number,
|
||||
height: number,
|
||||
): { width: number; height: number } {
|
||||
const targetWidth = Math.max(1, Math.ceil(width));
|
||||
const targetHeight = Math.max(1, Math.ceil(height));
|
||||
resizeRenderTarget(gl, scratch, targetWidth, targetHeight);
|
||||
resizeRenderTarget(gl, output, targetWidth, targetHeight);
|
||||
return { width: targetWidth, height: targetHeight };
|
||||
}
|
||||
|
||||
function renderBlurPass(
|
||||
gl: WebGLRenderingContext,
|
||||
program: BlurProgramInfo,
|
||||
input: WebGLTexture,
|
||||
output: RenderTarget,
|
||||
layout: { width: number; height: number },
|
||||
direction: { x: number; y: number },
|
||||
radius: number,
|
||||
): void {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, output.framebuffer);
|
||||
gl.viewport(0, 0, layout.width, layout.height);
|
||||
gl.useProgram(program.program);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, input);
|
||||
gl.uniform1i(program.source, 0);
|
||||
gl.uniform2f(program.resolution, layout.width, layout.height);
|
||||
gl.uniform2f(program.direction, direction.x, direction.y);
|
||||
gl.uniform1f(program.radius, radius);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, program.quad);
|
||||
gl.enableVertexAttribArray(program.position);
|
||||
gl.vertexAttribPointer(program.position, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
}
|
||||
|
||||
function renderGaussianTexture(
|
||||
gl: WebGLRenderingContext,
|
||||
targets: EffectTargets,
|
||||
input: WebGLTexture,
|
||||
output: RenderTarget,
|
||||
layout: { width: number; height: number },
|
||||
radius: number,
|
||||
iterations: number,
|
||||
): void {
|
||||
let source = input;
|
||||
for (let pass = 0; pass < Math.max(1, Math.floor(iterations)); pass++) {
|
||||
renderBlurPass(
|
||||
gl,
|
||||
targets.blurProgram,
|
||||
source,
|
||||
targets.scratch,
|
||||
layout,
|
||||
{ x: 1, y: 0 },
|
||||
radius,
|
||||
);
|
||||
renderBlurPass(
|
||||
gl,
|
||||
targets.blurProgram,
|
||||
targets.scratch.texture,
|
||||
output,
|
||||
layout,
|
||||
{ x: 0, y: 1 },
|
||||
radius,
|
||||
);
|
||||
source = output.texture;
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function ensureEntryLut(entry: ColorGradingEntry): RuntimeLutTexture | null {
|
||||
const src = entry.grading.lut?.src.trim() ?? "";
|
||||
@@ -638,6 +1036,16 @@ function findRenderFrameImage(video: HTMLVideoElement): HTMLImageElement | null
|
||||
return frame instanceof HTMLImageElement && isDrawableSource(frame) ? frame : null;
|
||||
}
|
||||
|
||||
function isRenderFrameImage(source: TexImageSource): source is HTMLImageElement {
|
||||
return source instanceof HTMLImageElement && source.classList.contains("__render_frame__");
|
||||
}
|
||||
|
||||
function keepCanvasAboveSource(entry: ColorGradingEntry, source: HTMLImageElement): void {
|
||||
if (source.parentNode && source.nextSibling !== entry.canvas) {
|
||||
source.parentNode.insertBefore(entry.canvas, source.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
function getDrawableSource(element: ColorGradingMediaElement): TexImageSource | null {
|
||||
if (element instanceof HTMLVideoElement) {
|
||||
const renderFrame = findRenderFrameImage(element);
|
||||
@@ -782,15 +1190,19 @@ function applyUniforms(
|
||||
program: ProgramInfo,
|
||||
grading: NormalizedHfColorGrading,
|
||||
lut: RuntimeLutTexture | null,
|
||||
blurReady: boolean,
|
||||
compare: RuntimeColorGradingCompareState,
|
||||
layout: { width: number; height: number },
|
||||
uv: { scaleX: number; scaleY: number; offsetX: number; offsetY: number },
|
||||
grainSeed: number,
|
||||
): void {
|
||||
gl.uniform1i(program.source, 0);
|
||||
gl.uniform1i(program.lut, 1);
|
||||
gl.uniform1i(program.blurSource, 1);
|
||||
gl.uniform1i(program.lut, 2);
|
||||
gl.uniform2f(program.resolution, layout.width, layout.height);
|
||||
gl.uniform2f(program.uvScale, uv.scaleX, uv.scaleY);
|
||||
gl.uniform2f(program.uvOffset, uv.offsetX, uv.offsetY);
|
||||
gl.uniform1f(program.blurReady, blurReady ? 1 : 0);
|
||||
gl.uniform1f(program.lutEnabled, lut ? 1 : 0);
|
||||
gl.uniform1f(program.lutSize, lut?.size ?? 2);
|
||||
gl.uniform2f(program.lutTextureSize, lut?.textureWidth ?? 1, lut?.textureHeight ?? 1);
|
||||
@@ -815,7 +1227,18 @@ function applyUniforms(
|
||||
gl.uniform1f(program.blacks, grading.adjust.blacks);
|
||||
gl.uniform1f(program.temperature, grading.adjust.temperature);
|
||||
gl.uniform1f(program.tint, grading.adjust.tint);
|
||||
gl.uniform1f(program.vibrance, grading.adjust.vibrance);
|
||||
gl.uniform1f(program.saturation, grading.adjust.saturation);
|
||||
gl.uniform1f(program.vignette, grading.details.vignette);
|
||||
gl.uniform1f(program.vignetteMidpoint, grading.details.vignetteMidpoint);
|
||||
gl.uniform1f(program.vignetteRoundness, grading.details.vignetteRoundness);
|
||||
gl.uniform1f(program.vignetteFeather, grading.details.vignetteFeather);
|
||||
gl.uniform1f(program.grain, grading.details.grain);
|
||||
gl.uniform1f(program.grainSize, grading.details.grainSize);
|
||||
gl.uniform1f(program.grainRoughness, grading.details.grainRoughness);
|
||||
gl.uniform1f(program.grainSeed, grainSeed);
|
||||
gl.uniform1f(program.blur, grading.effects.blur);
|
||||
gl.uniform1f(program.pixelate, grading.effects.pixelate);
|
||||
gl.uniform1f(program.intensity, grading.intensity);
|
||||
gl.uniform1f(program.compareEnabled, compare.enabled ? 1 : 0);
|
||||
gl.uniform1f(program.comparePosition, compare.position);
|
||||
@@ -835,7 +1258,7 @@ function hideSourceElement(entry: ColorGradingEntry): void {
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function drawEntry(entry: ColorGradingEntry): boolean {
|
||||
if (entry.destroyed) return false;
|
||||
if (entry.destroyed || entry.contextLost) return false;
|
||||
const source = getDrawableSource(entry.element);
|
||||
if (!source) {
|
||||
if (!entry.hasDrawn) entry.canvas.style.display = "none";
|
||||
@@ -849,11 +1272,13 @@ function drawEntry(entry: ColorGradingEntry): boolean {
|
||||
const hiddenByColorGrading =
|
||||
entry.sourceHidden && sourceOpacity === "0" && sourceOpacityPriority === "important";
|
||||
const sourceVisibility = entry.element.style.getPropertyValue("visibility");
|
||||
if (!hiddenByColorGrading) {
|
||||
const computed = window.getComputedStyle(entry.element);
|
||||
const injectedFrameSource = isRenderFrameImage(source);
|
||||
if (injectedFrameSource) keepCanvasAboveSource(entry, source);
|
||||
if (injectedFrameSource || !hiddenByColorGrading) {
|
||||
const computed = window.getComputedStyle(injectedFrameSource ? source : entry.element);
|
||||
entry.sourceOpacityForCanvas = computed.opacity || "1";
|
||||
entry.sourceVisibleForCanvas =
|
||||
sourceVisibility !== "hidden" && computed.visibility !== "hidden";
|
||||
(injectedFrameSource || sourceVisibility !== "hidden") && computed.visibility !== "hidden";
|
||||
}
|
||||
const layout = updateCanvasLayout(entry, styleSource);
|
||||
if (!layout) return false;
|
||||
@@ -870,24 +1295,65 @@ function drawEntry(entry: ColorGradingEntry): boolean {
|
||||
const { gl, program } = entry;
|
||||
try {
|
||||
const lut = ensureEntryLut(entry);
|
||||
gl.viewport(0, 0, layout.width, layout.height);
|
||||
gl.useProgram(program.program);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, program.texture);
|
||||
// Browser media elements are top-left oriented; WebGL texture coordinates
|
||||
// are bottom-left oriented unless the upload is flipped.
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
||||
|
||||
let blurReady = false;
|
||||
const blurAmount = Math.min(1, Math.max(0, entry.grading.effects.blur));
|
||||
if (blurAmount > 0) {
|
||||
// If framebuffer setup fails, keep the non-blur shader path alive and surface status.
|
||||
const targets = ensureEffectTargets(entry);
|
||||
if (targets) {
|
||||
const blurLayout = resizeEffectTargetPair(
|
||||
gl,
|
||||
targets.scratch,
|
||||
targets.blur,
|
||||
layout.width,
|
||||
layout.height,
|
||||
);
|
||||
renderGaussianTexture(
|
||||
gl,
|
||||
targets,
|
||||
program.texture,
|
||||
targets.blur,
|
||||
blurLayout,
|
||||
0.75 + Math.pow(blurAmount, 1.35) * 32,
|
||||
blurAmount > 0.55 ? 3 : 2,
|
||||
);
|
||||
blurReady = true;
|
||||
}
|
||||
} else {
|
||||
entry.effectError = null;
|
||||
if (entry.effectTargets) destroyEffectTargets(entry);
|
||||
}
|
||||
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
gl.viewport(0, 0, layout.width, layout.height);
|
||||
gl.useProgram(program.program);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, program.texture);
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, entry.effectTargets?.blur.texture ?? program.texture);
|
||||
gl.activeTexture(gl.TEXTURE2);
|
||||
gl.bindTexture(gl.TEXTURE_2D, program.lutTexture);
|
||||
applyUniforms(gl, program, entry.grading, lut, entry.compare, layout, uv);
|
||||
const grainSeed =
|
||||
entry.grainSeed +
|
||||
(entry.element instanceof HTMLVideoElement ? Math.floor(entry.element.currentTime * 60) : 0);
|
||||
applyUniforms(gl, program, entry.grading, lut, blurReady, entry.compare, layout, uv, grainSeed);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, program.quad);
|
||||
gl.enableVertexAttribArray(program.position);
|
||||
gl.vertexAttribPointer(program.position, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
hideSourceElement(entry);
|
||||
entry.hasDrawn = true;
|
||||
entry.drawError = null;
|
||||
return true;
|
||||
} catch (err) {
|
||||
entry.drawError = err instanceof Error ? err.message : "Shader draw failed";
|
||||
swallow("runtime.colorGrading.drawEntry", err);
|
||||
return false;
|
||||
}
|
||||
@@ -949,6 +1415,24 @@ function installEntryListeners(entry: ColorGradingEntry): void {
|
||||
addListener(entry, entry.element, "play", () => scheduleVideoDraw(entry));
|
||||
addListener(entry, entry.element, "pause", redraw);
|
||||
}
|
||||
addListener(entry, entry.canvas, "webglcontextlost", (event) => {
|
||||
event.preventDefault();
|
||||
entry.contextLost = true;
|
||||
entry.drawError = "WebGL context lost";
|
||||
entry.canvas.style.display = "none";
|
||||
restoreSourceElement(entry);
|
||||
});
|
||||
addListener(entry, entry.canvas, "webglcontextrestored", () => {
|
||||
entry.contextLost = false;
|
||||
if (!replaceProgramResources(entry)) {
|
||||
entry.contextLost = true;
|
||||
entry.drawError = "WebGL context restore failed";
|
||||
restoreSourceElement(entry);
|
||||
return;
|
||||
}
|
||||
entry.drawError = null;
|
||||
drawEntry(entry);
|
||||
});
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
entry.resizeObserver = new ResizeObserver(redraw);
|
||||
entry.resizeObserver.observe(entry.element);
|
||||
@@ -963,25 +1447,8 @@ function destroyEntry(entry: ColorGradingEntry): void {
|
||||
for (const cleanup of entry.cleanup) cleanup();
|
||||
entry.cleanup.length = 0;
|
||||
entry.canvas.remove();
|
||||
entry.gl.deleteTexture(entry.program.texture);
|
||||
entry.gl.deleteTexture(entry.program.lutTexture);
|
||||
entry.gl.deleteProgram(entry.program.program);
|
||||
if (entry.sourceHidden) {
|
||||
entry.element.removeAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR);
|
||||
const opacity = entry.element.style.getPropertyValue("opacity");
|
||||
const priority = entry.element.style.getPropertyPriority("opacity");
|
||||
if (opacity === "0" && priority === "important") {
|
||||
if (entry.sourceInlineOpacity === null) {
|
||||
entry.element.style.removeProperty("opacity");
|
||||
} else {
|
||||
entry.element.style.setProperty(
|
||||
"opacity",
|
||||
entry.sourceInlineOpacity,
|
||||
entry.sourceInlineOpacityPriority,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
destroyProgramResources(entry);
|
||||
restoreSourceElement(entry);
|
||||
if (entry.touchedParent) {
|
||||
if (entry.parentInlinePosition === null) {
|
||||
entry.touchedParent.style.removeProperty("position");
|
||||
@@ -993,6 +1460,7 @@ function destroyEntry(entry: ColorGradingEntry): void {
|
||||
|
||||
function makeCanvas(element: ColorGradingMediaElement): HTMLCanvasElement {
|
||||
const canvas = document.createElement("canvas");
|
||||
if (element.id) canvas.id = `${HF_COLOR_GRADING_CANVAS_ID_PREFIX}${element.id}`;
|
||||
canvas.className = COLOR_GRADING_CANVAS_CLASS;
|
||||
canvas.setAttribute(COLOR_GRADING_CANVAS_ATTR, "true");
|
||||
canvas.setAttribute("data-hyperframes-ignore", "");
|
||||
@@ -1040,6 +1508,9 @@ export function createColorGradingRuntime(): RuntimeColorGradingApi {
|
||||
lut: null,
|
||||
lutLoadingSrc: null,
|
||||
lutError: null,
|
||||
drawError: null,
|
||||
effectTargets: null,
|
||||
effectError: null,
|
||||
source,
|
||||
animationFrame: null,
|
||||
videoFrameHandle: null,
|
||||
@@ -1053,6 +1524,8 @@ export function createColorGradingRuntime(): RuntimeColorGradingApi {
|
||||
sourceOpacityForCanvas: window.getComputedStyle(element).opacity || "1",
|
||||
sourceVisibleForCanvas: window.getComputedStyle(element).visibility !== "hidden",
|
||||
hasDrawn: false,
|
||||
contextLost: false,
|
||||
grainSeed: seedForElement(element),
|
||||
destroyed: false,
|
||||
};
|
||||
entries.set(element, entry);
|
||||
@@ -1158,8 +1631,14 @@ export function createColorGradingRuntime(): RuntimeColorGradingApi {
|
||||
if (!element) return { state: "missing", message: "Media not found" };
|
||||
const entry = entries.get(element);
|
||||
if (entry) {
|
||||
if (entry.effectError) {
|
||||
return { state: "unavailable", message: entry.effectError };
|
||||
}
|
||||
if (entry.drawError) {
|
||||
return { state: "unavailable", message: entry.drawError };
|
||||
}
|
||||
if (entry.lutError) {
|
||||
return { state: "unavailable", message: entry.lutError };
|
||||
return { state: "unavailable", message: `LUT error: ${entry.lutError}` };
|
||||
}
|
||||
if (entry.grading.lut && entry.lutLoadingSrc) {
|
||||
return { state: "pending", message: "Loading LUT" };
|
||||
|
||||
@@ -5,11 +5,12 @@ import { type Page } from "puppeteer-core";
|
||||
import {
|
||||
pageScreenshotCapture,
|
||||
cdpSessionCache,
|
||||
applyDomLayerMask,
|
||||
removeDomLayerMask,
|
||||
injectVideoFramesBatch,
|
||||
syncVideoFrameVisibility,
|
||||
shouldDefaultCaptureBeyondViewport,
|
||||
applyDomLayerMask,
|
||||
removeDomLayerMask,
|
||||
DOM_LAYER_MASK_STYLE_ID,
|
||||
} from "./screenshotService.js";
|
||||
|
||||
// Stub a Page + CDPSession just enough that pageScreenshotCapture can call
|
||||
@@ -268,7 +269,7 @@ describe("video-frame injection respects ancestor visibility", () => {
|
||||
|
||||
function setupHostHiddenScenario(
|
||||
hostStyle: StyleLike,
|
||||
options: { hostAttribute?: HostAttribute } = {},
|
||||
options: { hostAttribute?: HostAttribute; videoStyle?: StyleLike } = {},
|
||||
) {
|
||||
const hostAttribute = options.hostAttribute ?? "data-composition-src";
|
||||
const hostAttrMarkup =
|
||||
@@ -310,7 +311,13 @@ describe("video-frame injection respects ancestor visibility", () => {
|
||||
const styles = new Map<Element, StyleLike>();
|
||||
styles.set(host, hostStyle);
|
||||
styles.set(pipFrame, {});
|
||||
styles.set(video, { opacity: "1", objectFit: "cover", objectPosition: "center", zIndex: "1" });
|
||||
styles.set(video, {
|
||||
opacity: "1",
|
||||
objectFit: "cover",
|
||||
objectPosition: "center",
|
||||
zIndex: "1",
|
||||
...options.videoStyle,
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "getComputedStyle", {
|
||||
configurable: true,
|
||||
@@ -498,6 +505,47 @@ describe("video-frame injection respects ancestor visibility", () => {
|
||||
expect(sibling?.style.visibility).toBe("visible");
|
||||
});
|
||||
|
||||
it("does not copy color-grading source suppression opacity to the injected frame", async () => {
|
||||
const { teardown, setup } = withGlobals(
|
||||
setupHostHiddenScenario({}, { videoStyle: { opacity: "0" } }),
|
||||
);
|
||||
setup.video.setAttribute("data-hf-color-grading-source-hidden", "true");
|
||||
|
||||
try {
|
||||
await injectVideoFramesBatch(passthroughPage(), [
|
||||
{
|
||||
videoId: "pip",
|
||||
dataUri:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
teardown();
|
||||
}
|
||||
|
||||
const sibling = setup.video.nextElementSibling as HTMLElement | null;
|
||||
expect(sibling?.classList.contains("__render_frame__")).toBe(true);
|
||||
expect(sibling?.style.opacity).toBe("1");
|
||||
});
|
||||
|
||||
it("repairs stale injected-frame opacity while syncing color-graded active videos", async () => {
|
||||
const { teardown, setup } = withGlobals(setupHostHiddenScenario({}));
|
||||
setup.video.setAttribute("data-hf-color-grading-source-hidden", "true");
|
||||
const seededImg = setup.document.createElement("img");
|
||||
seededImg.classList.add("__render_frame__");
|
||||
seededImg.style.opacity = "0";
|
||||
setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);
|
||||
|
||||
try {
|
||||
await syncVideoFrameVisibility(passthroughPage(), ["pip"]);
|
||||
} finally {
|
||||
teardown();
|
||||
}
|
||||
|
||||
expect(seededImg.style.opacity).toBe("1");
|
||||
expect(seededImg.style.visibility).toBe("visible");
|
||||
});
|
||||
|
||||
it("syncVideoFrameVisibility shows the replacement <img> when a plain [data-start] host is visibility:hidden", async () => {
|
||||
const { teardown, setup } = withGlobals(
|
||||
setupHostHiddenScenario({ visibility: "hidden" }, { hostAttribute: "data-start" }),
|
||||
@@ -672,4 +720,26 @@ describe("video-frame injection respects ancestor visibility", () => {
|
||||
teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("applyDomLayerMask carries color grading canvases with their media element", async () => {
|
||||
const { window, document } = parseHTML(
|
||||
'<html><head></head><body><div id="root"><video id="pip"></video><canvas id="__hf_color_grading_pip"></canvas></div></body></html>',
|
||||
);
|
||||
const teardown = installDomMaskGlobals({ window, document });
|
||||
try {
|
||||
await applyDomLayerMask(passthroughPage(), ["pip"], []);
|
||||
expect(document.getElementById(DOM_LAYER_MASK_STYLE_ID)?.textContent).toContain(
|
||||
"#__hf_color_grading_pip",
|
||||
);
|
||||
|
||||
await applyDomLayerMask(passthroughPage(), ["root"], ["pip"]);
|
||||
const canvas = document.getElementById("__hf_color_grading_pip") as HTMLCanvasElement;
|
||||
expect(canvas.style.visibility).toBe("hidden");
|
||||
|
||||
await removeDomLayerMask(passthroughPage(), ["pip"]);
|
||||
expect(canvas.style.getPropertyValue("visibility") || "").toBe("");
|
||||
} finally {
|
||||
teardown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
|
||||
import { type Page } from "puppeteer-core";
|
||||
import { type CaptureOptions } from "../types.js";
|
||||
import { MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
|
||||
import {
|
||||
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
|
||||
MEDIA_VISUAL_STYLE_PROPERTIES,
|
||||
} from "@hyperframes/core";
|
||||
|
||||
export const cdpSessionCache = new WeakMap<Page, import("puppeteer-core").CDPSession>();
|
||||
const COLOR_GRADING_SOURCE_HIDDEN_ATTR = "data-hf-color-grading-source-hidden";
|
||||
|
||||
export async function getCdpSession(page: Page): Promise<import("puppeteer-core").CDPSession> {
|
||||
let client = cdpSessionCache.get(page);
|
||||
@@ -279,12 +283,12 @@ const DOM_LAYER_MASK_PREV_PRIORITY_ATTR = "data-hf-dom-layer-mask-prev-priority"
|
||||
*
|
||||
* 1. Inject a stylesheet that hides every body descendant
|
||||
* (`body * { visibility: hidden !important }`) and re-shows the layer's
|
||||
* elements (and their descendants and their injected `__render_frame_*`
|
||||
* siblings) via `visibility: visible !important`. CSS `visibility: visible`
|
||||
* elements (and their descendants, injected `__render_frame_*` siblings,
|
||||
* and media color-grading canvases) via `visibility: visible !important`. CSS `visibility: visible`
|
||||
* on a descendant overrides an ancestor's `visibility: hidden`, so deep
|
||||
* layer elements remain visible even though intermediate parents are
|
||||
* hidden by the mass-hide rule.
|
||||
* 2. Inline-hide each `extraHideId` (and its `__render_frame_*` sibling) with
|
||||
* 2. Inline-hide each `extraHideId` (and its render-frame/color-grading siblings) with
|
||||
* `visibility: hidden !important`, while first recording its previous
|
||||
* inline visibility. Inline `!important` beats stylesheet `!important`,
|
||||
* so this overrides the show rule for elements that fall under a show
|
||||
@@ -328,6 +332,7 @@ export async function applyDomLayerMask(
|
||||
hiddenAttr: string;
|
||||
prevVisibilityAttr: string;
|
||||
prevPriorityAttr: string;
|
||||
canvasIdPrefix: string;
|
||||
}) => {
|
||||
const existing = document.getElementById(args.styleId);
|
||||
if (existing) existing.remove();
|
||||
@@ -390,6 +395,8 @@ export async function applyDomLayerMask(
|
||||
showSelectors.push(`#${escaped}`, `#${escaped} *`);
|
||||
const renderEscaped = CSS.escape(`__render_frame_${id}__`);
|
||||
showSelectors.push(`#${renderEscaped}`, `#${renderEscaped} *`);
|
||||
const colorGradingEscaped = CSS.escape(`${args.canvasIdPrefix}${id}`);
|
||||
showSelectors.push(`#${colorGradingEscaped}`, `#${colorGradingEscaped} *`);
|
||||
}
|
||||
|
||||
const massHideRule = "body *{visibility:hidden !important;}";
|
||||
@@ -416,6 +423,10 @@ export async function applyDomLayerMask(
|
||||
if (img) {
|
||||
rememberAndHideElement(img);
|
||||
}
|
||||
const colorGradingCanvas = document.getElementById(`${args.canvasIdPrefix}${id}`);
|
||||
if (colorGradingCanvas instanceof HTMLElement) {
|
||||
rememberAndHideElement(colorGradingCanvas);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -425,6 +436,7 @@ export async function applyDomLayerMask(
|
||||
hiddenAttr: DOM_LAYER_MASK_HIDDEN_ATTR,
|
||||
prevVisibilityAttr: DOM_LAYER_MASK_PREV_VISIBILITY_ATTR,
|
||||
prevPriorityAttr: DOM_LAYER_MASK_PREV_PRIORITY_ATTR,
|
||||
canvasIdPrefix: HF_COLOR_GRADING_CANVAS_ID_PREFIX,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -434,7 +446,7 @@ export async function applyDomLayerMask(
|
||||
*
|
||||
* Removes the mask stylesheet and restores the inline `visibility` values
|
||||
* temporarily overwritten for hidden timed descendants, `extraHideIds`, and
|
||||
* their `__render_frame_*` siblings.
|
||||
* their render-frame/color-grading siblings.
|
||||
*
|
||||
* IMPORTANT: We do NOT strip inline `opacity` here. applyDomLayerMask only
|
||||
* ever sets `visibility` (never `opacity`), so any inline opacity present on
|
||||
@@ -492,7 +504,11 @@ export async function injectVideoFramesBatch(
|
||||
): Promise<string[]> {
|
||||
if (updates.length === 0) return [];
|
||||
return await page.evaluate(
|
||||
async (items: Array<{ videoId: string; dataUri: string }>, visualProperties: string[]) => {
|
||||
async (
|
||||
items: Array<{ videoId: string; dataUri: string }>,
|
||||
visualProperties: string[],
|
||||
colorGradingSourceHiddenAttr: string,
|
||||
) => {
|
||||
const injectedIds: string[] = [];
|
||||
const pendingDecodes: Array<Promise<void>> = [];
|
||||
const replacementLayoutProperties = new Set([
|
||||
@@ -570,7 +586,11 @@ export async function injectVideoFramesBatch(
|
||||
// `opacity: 0`), so its computed opacity is preserved across seeks
|
||||
// and accurately reflects the user's intent on every frame.
|
||||
const opacityParsed = parseFloat(computedStyle.opacity);
|
||||
const computedOpacity = Number.isNaN(opacityParsed) ? 1 : opacityParsed;
|
||||
const computedOpacity = video.hasAttribute(colorGradingSourceHiddenAttr)
|
||||
? 1
|
||||
: Number.isNaN(opacityParsed)
|
||||
? 1
|
||||
: opacityParsed;
|
||||
|
||||
if (isNewImage) {
|
||||
img = document.createElement("img");
|
||||
@@ -655,6 +675,7 @@ export async function injectVideoFramesBatch(
|
||||
},
|
||||
updates,
|
||||
[...MEDIA_VISUAL_STYLE_PROPERTIES],
|
||||
COLOR_GRADING_SOURCE_HIDDEN_ATTR,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -662,59 +683,66 @@ export async function syncVideoFrameVisibility(
|
||||
page: Page,
|
||||
activeVideoIds: string[],
|
||||
): Promise<void> {
|
||||
await page.evaluate((ids: string[]) => {
|
||||
// Mirror the ancestor-visibility guard from `injectVideoFramesBatch`.
|
||||
// See that copy for the full rationale on why `visibility: hidden` is
|
||||
// narrowed to sub-composition hosts only — keep these two functions in
|
||||
// sync so the inactive-arm decision matches the inject-time decision.
|
||||
const isVisualAncestorHidden = (el: HTMLElement): boolean => {
|
||||
let parent = el.parentElement;
|
||||
while (parent !== null && parent !== document.documentElement) {
|
||||
const computed = window.getComputedStyle(parent);
|
||||
if (computed.display === "none") return true;
|
||||
if (
|
||||
computed.visibility === "hidden" &&
|
||||
(parent.hasAttribute("data-composition-src") ||
|
||||
parent.hasAttribute("data-composition-file"))
|
||||
) {
|
||||
return true;
|
||||
await page.evaluate(
|
||||
(ids: string[], colorGradingSourceHiddenAttr: string) => {
|
||||
// Mirror the ancestor-visibility guard from `injectVideoFramesBatch`.
|
||||
// See that copy for the full rationale on why `visibility: hidden` is
|
||||
// narrowed to sub-composition hosts only — keep these two functions in
|
||||
// sync so the inactive-arm decision matches the inject-time decision.
|
||||
const isVisualAncestorHidden = (el: HTMLElement): boolean => {
|
||||
let parent = el.parentElement;
|
||||
while (parent !== null && parent !== document.documentElement) {
|
||||
const computed = window.getComputedStyle(parent);
|
||||
if (computed.display === "none") return true;
|
||||
if (
|
||||
computed.visibility === "hidden" &&
|
||||
(parent.hasAttribute("data-composition-src") ||
|
||||
parent.hasAttribute("data-composition-file"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const active = new Set(ids);
|
||||
const videos = Array.from(document.querySelectorAll("video[data-start]")) as HTMLVideoElement[];
|
||||
for (const video of videos) {
|
||||
const img = video.nextElementSibling as HTMLElement | null;
|
||||
const hasImg = img && img.classList.contains("__render_frame__");
|
||||
const ancestorHidden = isVisualAncestorHidden(video);
|
||||
if (active.has(video.id) && !ancestorHidden) {
|
||||
// Active video: show injected <img>, hide native <video>.
|
||||
// Do NOT clobber inline opacity here — GSAP-controlled opacity must
|
||||
// survive until injectVideoFramesBatch reads it via getComputedStyle.
|
||||
// visibility:hidden alone hides the native element without affecting
|
||||
// its computed opacity.
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("pointer-events", "none", "important");
|
||||
if (hasImg) {
|
||||
img.style.visibility = "visible";
|
||||
}
|
||||
} else {
|
||||
// Inactive (or ancestor-hidden) video: hide both. Use visibility only
|
||||
// (never opacity) so we never clobber GSAP-controlled inline opacity.
|
||||
// Use `!important` on the <img> hide so `applyDomLayerMask`'s
|
||||
// important stylesheet rule (`#${showId} *{visibility:visible !important}`)
|
||||
// cannot revive a stale frame when the sub-comp host lands in the
|
||||
// active layer's `show` set — same mask-defense reasoning as the
|
||||
// `isVisualAncestorHidden` branch in `injectVideoFramesBatch`.
|
||||
video.style.removeProperty("display");
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("pointer-events", "none", "important");
|
||||
if (hasImg) {
|
||||
img.style.setProperty("visibility", "hidden", "important");
|
||||
return false;
|
||||
};
|
||||
const active = new Set(ids);
|
||||
const videos = Array.from(
|
||||
document.querySelectorAll("video[data-start]"),
|
||||
) as HTMLVideoElement[];
|
||||
for (const video of videos) {
|
||||
const img = video.nextElementSibling as HTMLElement | null;
|
||||
const hasImg = img && img.classList.contains("__render_frame__");
|
||||
const ancestorHidden = isVisualAncestorHidden(video);
|
||||
if (active.has(video.id) && !ancestorHidden) {
|
||||
// Active video: show injected <img>, hide native <video>.
|
||||
// Do NOT clobber inline opacity here — GSAP-controlled opacity must
|
||||
// survive until injectVideoFramesBatch reads it via getComputedStyle.
|
||||
// visibility:hidden alone hides the native element without affecting
|
||||
// its computed opacity.
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("pointer-events", "none", "important");
|
||||
if (hasImg) {
|
||||
if (video.hasAttribute(colorGradingSourceHiddenAttr)) img.style.opacity = "1";
|
||||
img.style.visibility = "visible";
|
||||
}
|
||||
} else {
|
||||
// Inactive (or ancestor-hidden) video: hide both. Use visibility only
|
||||
// (never opacity) so we never clobber GSAP-controlled inline opacity.
|
||||
// Use `!important` on the <img> hide so `applyDomLayerMask`'s
|
||||
// important stylesheet rule (`#${showId} *{visibility:visible !important}`)
|
||||
// cannot revive a stale frame when the sub-comp host lands in the
|
||||
// active layer's `show` set — same mask-defense reasoning as the
|
||||
// `isVisualAncestorHidden` branch in `injectVideoFramesBatch`.
|
||||
video.style.removeProperty("display");
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("pointer-events", "none", "important");
|
||||
if (hasImg) {
|
||||
img.style.setProperty("visibility", "hidden", "important");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, activeVideoIds);
|
||||
},
|
||||
activeVideoIds,
|
||||
COLOR_GRADING_SOURCE_HIDDEN_ATTR,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { type FrameLookupTable } from "./videoFrameExtractor.js";
|
||||
import { injectVideoFramesBatch, syncVideoFrameVisibility } from "./screenshotService.js";
|
||||
import { type BeforeCaptureHook } from "./frameCapture.js";
|
||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
import { HF_COLOR_GRADING_CANVAS_ID_PREFIX } from "@hyperframes/core";
|
||||
|
||||
export interface VideoFrameInjectorOptions extends Partial<
|
||||
Pick<EngineConfig, "frameDataUriCacheLimit" | "frameDataUriCacheBytesLimitMb">
|
||||
@@ -275,16 +276,24 @@ export interface VideoElementBounds {
|
||||
*/
|
||||
export async function hideVideoElements(page: Page, videoIds: string[]): Promise<void> {
|
||||
if (videoIds.length === 0) return;
|
||||
await page.evaluate((ids: string[]) => {
|
||||
for (const id of ids) {
|
||||
const el = document.getElementById(id) as HTMLVideoElement | null;
|
||||
if (el) {
|
||||
el.style.setProperty("visibility", "hidden", "important");
|
||||
const img = document.getElementById(`__render_frame_${id}__`);
|
||||
if (img) img.style.setProperty("visibility", "hidden", "important");
|
||||
await page.evaluate(
|
||||
(ids: string[], canvasIdPrefix: string) => {
|
||||
for (const id of ids) {
|
||||
const el = document.getElementById(id) as HTMLVideoElement | null;
|
||||
if (el) {
|
||||
el.style.setProperty("visibility", "hidden", "important");
|
||||
const img = document.getElementById(`__render_frame_${id}__`);
|
||||
if (img) img.style.setProperty("visibility", "hidden", "important");
|
||||
const colorGradingCanvas = document.getElementById(`${canvasIdPrefix}${id}`);
|
||||
if (colorGradingCanvas) {
|
||||
colorGradingCanvas.style.setProperty("visibility", "hidden", "important");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, videoIds);
|
||||
},
|
||||
videoIds,
|
||||
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,16 +301,22 @@ export async function hideVideoElements(page: Page, videoIds: string[]): Promise
|
||||
*/
|
||||
export async function showVideoElements(page: Page, videoIds: string[]): Promise<void> {
|
||||
if (videoIds.length === 0) return;
|
||||
await page.evaluate((ids: string[]) => {
|
||||
for (const id of ids) {
|
||||
const el = document.getElementById(id) as HTMLVideoElement | null;
|
||||
if (el) {
|
||||
el.style.removeProperty("visibility");
|
||||
const img = document.getElementById(`__render_frame_${id}__`);
|
||||
if (img) img.style.removeProperty("visibility");
|
||||
await page.evaluate(
|
||||
(ids: string[], canvasIdPrefix: string) => {
|
||||
for (const id of ids) {
|
||||
const el = document.getElementById(id) as HTMLVideoElement | null;
|
||||
if (el) {
|
||||
el.style.removeProperty("visibility");
|
||||
const img = document.getElementById(`__render_frame_${id}__`);
|
||||
if (img) img.style.removeProperty("visibility");
|
||||
const colorGradingCanvas = document.getElementById(`${canvasIdPrefix}${id}`);
|
||||
if (colorGradingCanvas) colorGradingCanvas.style.removeProperty("visibility");
|
||||
}
|
||||
}
|
||||
}
|
||||
}, videoIds);
|
||||
},
|
||||
videoIds,
|
||||
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user