diff --git a/packages/core/src/colorGrading.test.ts b/packages/core/src/colorGrading.test.ts
index 608576870..ff2b7f897 100644
--- a/packages/core/src/colorGrading.test.ts
+++ b/packages/core/src/colorGrading.test.ts
@@ -13,6 +13,7 @@ import {
normalizeHfColorGradingWithVariables,
serializeHfColorGrading,
} from "./colorGrading";
+import { lintHyperframeHtml } from "./lint";
describe("color grading", () => {
it("derives grade and effect preset views from their actual payloads", () => {
@@ -28,6 +29,32 @@ describe("color grading", () => {
expect(HF_COLOR_GRADING_PRESETS).toHaveLength(18);
});
+ it("keeps every canonical grading key accepted by lint", async () => {
+ const grading = normalizeHfColorGrading("neutral");
+ expect(grading).not.toBeNull();
+ const html = (attribute: string) => `
+
+
+

+
+
+
+ `;
+
+ const valid = await lintHyperframeHtml(html(serializeHfColorGrading(grading)));
+ expect(valid.findings.filter((finding) => finding.severity === "error")).toEqual([]);
+
+ const invalid = await lintHyperframeHtml(html('{"effects":{"notARealEffect":1}}'));
+ expect(invalid.findings).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: "color_grading_invalid_structure",
+ severity: "error",
+ }),
+ ]),
+ );
+ });
+
it("parses preset shorthand", () => {
const grading = normalizeHfColorGrading("warm-daylight");
expect(grading?.preset).toBe("warm-daylight");
diff --git a/packages/core/src/runtime/colorGrading.test.ts b/packages/core/src/runtime/colorGrading.test.ts
index a02d6bdb3..a2be6c460 100644
--- a/packages/core/src/runtime/colorGrading.test.ts
+++ b/packages/core/src/runtime/colorGrading.test.ts
@@ -11,6 +11,7 @@ let lastUniform1f: ReturnType | null = null;
let lastUniform3f: ReturnType | null = null;
let lastShaderSources: string[] = [];
let texImage2DCalls: unknown[][] = [];
+let loseContextCalls = 0;
const IDENTITY_2 = `
LUT_3D_SIZE 2
@@ -24,7 +25,9 @@ LUT_3D_SIZE 2
1 1 1
`;
-function createMockWebGl(options: { failMediaUpload?: boolean } = {}): WebGLRenderingContext {
+function createMockWebGl(
+ options: { failMediaUpload?: boolean; halfFloatSupported?: boolean } = {},
+): WebGLRenderingContext {
const shader = {};
const program = {};
const texture = {};
@@ -54,6 +57,7 @@ function createMockWebGl(options: { failMediaUpload?: boolean } = {}): WebGLRend
TEXTURE1: 0x84c1,
TEXTURE2: 0x84c2,
TEXTURE3: 0x84c3,
+ TEXTURE4: 0x84c4,
FLOAT: 0x1406,
TRIANGLE_STRIP: 0x0005,
UNPACK_FLIP_Y_WEBGL: 0x9240,
@@ -88,6 +92,14 @@ function createMockWebGl(options: { failMediaUpload?: boolean } = {}): WebGLRend
framebufferTexture2D: vi.fn(),
checkFramebufferStatus: vi.fn(() => 0x8cd5),
deleteFramebuffer: vi.fn(),
+ getExtension: vi.fn((name: string) => {
+ if (name === "WEBGL_lose_context") {
+ return { loseContext: () => (loseContextCalls += 1) };
+ }
+ if (options.halfFloatSupported === false) return null;
+ if (name === "OES_texture_half_float") return { HALF_FLOAT_OES: 0x8d61 };
+ return name === "EXT_color_buffer_half_float" ? {} : null;
+ }),
createBuffer: vi.fn(() => buffer),
bindBuffer: vi.fn(),
bufferData: vi.fn(),
@@ -138,6 +150,21 @@ function makeDrawableVideo(): HTMLVideoElement {
return video;
}
+function makeDrawableImage(): HTMLImageElement {
+ const image = document.createElement("img");
+ image.id = "hero-image";
+ image.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ effects: { blur: 0 } }));
+ image.style.setProperty("--hf-color-grading-blur", "0");
+ Object.defineProperty(image, "complete", { value: true, configurable: true });
+ Object.defineProperty(image, "naturalWidth", { value: 640, configurable: true });
+ Object.defineProperty(image, "naturalHeight", { value: 360, configurable: true });
+ Object.defineProperty(image, "offsetWidth", { value: 640, configurable: true });
+ Object.defineProperty(image, "offsetHeight", { value: 360, configurable: true });
+ image.getBoundingClientRect = () =>
+ ({ width: 640, height: 360, left: 0, top: 0, right: 640, bottom: 360 }) as DOMRect;
+ return image;
+}
+
function stubCubeLutFetch(text = IDENTITY_2): ReturnType {
const fetchMock = vi.fn(() =>
Promise.resolve({
@@ -160,6 +187,7 @@ describe("createColorGradingRuntime", () => {
lastUniform3f = null;
lastShaderSources = [];
texImage2DCalls = [];
+ loseContextCalls = 0;
getContextSpy = vi
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockImplementation((type: string) =>
@@ -174,6 +202,7 @@ describe("createColorGradingRuntime", () => {
getContextSpy.mockRestore();
delete window.__hfVariables;
delete window.__hfVariablesByComp;
+ delete window.__player;
document.head.innerHTML = "";
document.body.innerHTML = "";
});
@@ -189,6 +218,32 @@ describe("createColorGradingRuntime", () => {
return { video, canvas };
}
+ it.each([
+ { pixelRatio: 1, width: 640, height: 360 },
+ { pixelRatio: 2, width: 1280, height: 720 },
+ ])(
+ "matches the WebGL drawing buffer to the displayed media at DPR $pixelRatio",
+ ({ pixelRatio, width, height }) => {
+ vi.stubGlobal("devicePixelRatio", pixelRatio);
+
+ const { canvas } = startRuntimeWithVideo();
+
+ expect(canvas.style.width).toBe("640px");
+ expect(canvas.style.height).toBe("360px");
+ expect(canvas.width).toBe(width);
+ expect(canvas.height).toBe(height);
+ },
+ );
+
+ it("uses the default non-preserved drawing buffer outside capture instrumentation", () => {
+ startRuntimeWithVideo();
+
+ expect(getContextSpy).toHaveBeenCalledWith("webgl", {
+ alpha: true,
+ premultipliedAlpha: false,
+ });
+ });
+
async function flushLutLoad(): Promise {
await Promise.resolve();
await Promise.resolve();
@@ -250,7 +305,7 @@ describe("createColorGradingRuntime", () => {
expect(texImage2DCalls.length).toBe(drawsBefore);
});
- it("re-hides source media after timeline visibility sync", () => {
+ it("releases inactive attribute grading and recreates it when visible", () => {
const { video, canvas } = startRuntimeWithVideo();
expect(canvas.id).toBe("__hf_color_grading_hero-video");
@@ -261,23 +316,177 @@ describe("createColorGradingRuntime", () => {
expect(canvas?.style.visibility).toBe("visible");
expect(canvas?.style.opacity).toBe("1");
- video.style.visibility = "visible";
- runtime.setSourceVisibility(video, true);
- runtime.redraw();
-
- expect(video.style.getPropertyValue("visibility")).toBe("visible");
- expect(video.style.getPropertyValue("opacity")).toBe("0");
- expect(video.style.getPropertyPriority("opacity")).toBe("important");
- expect(canvas?.style.visibility).toBe("visible");
-
video.style.visibility = "hidden";
- runtime.setSourceVisibility(video, false);
- runtime.redraw();
+ expect(runtime.setSourceVisibility(video, false)).toBe(true);
expect(video.style.getPropertyValue("visibility")).toBe("hidden");
+ expect(video.style.getPropertyValue("opacity")).toBe("");
+ expect(video.hasAttribute("data-hf-color-grading-source-hidden")).toBe(false);
+ expect(canvas.isConnected).toBe(false);
+
+ video.style.visibility = "visible";
+ expect(runtime.setSourceVisibility(video, true)).toBe(true);
+
+ const recreated = document.querySelector("[data-hf-color-grading-canvas]");
+ expect(recreated).not.toBeNull();
+ expect(recreated).toBe(canvas);
+ expect(getContextSpy).toHaveBeenCalledTimes(1);
expect(video.style.getPropertyValue("opacity")).toBe("0");
expect(video.style.getPropertyPriority("opacity")).toBe("important");
- expect(canvas?.style.visibility).toBe("hidden");
+ });
+
+ it("defers WebGL setup for hidden attributed media until it becomes visible", () => {
+ const video = makeDrawableVideo();
+ video.style.display = "none";
+ document.body.appendChild(video);
+
+ runtime = createColorGradingRuntime();
+
+ expect(getContextSpy).not.toHaveBeenCalled();
+ expect(document.querySelector("[data-hf-color-grading-canvas]")).toBeNull();
+ expect(runtime.getStatus(video)).toEqual({
+ state: "pending",
+ message: "Waiting for visible media",
+ });
+
+ video.style.display = "block";
+ expect(runtime.setSourceVisibility(video, true)).toBe(true);
+ expect(getContextSpy).toHaveBeenCalledTimes(1);
+ expect(document.querySelector("[data-hf-color-grading-canvas]")).not.toBeNull();
+ });
+
+ it("renders exact preset previews for ungraded media without replacing the source", async () => {
+ const video = makeDrawableVideo();
+ video.removeAttribute(HF_COLOR_GRADING_ATTR);
+ document.body.appendChild(video);
+ const toDataUrl = vi
+ .spyOn(HTMLCanvasElement.prototype, "toDataURL")
+ .mockReturnValue("data:image/png;base64,preview");
+ runtime = createColorGradingRuntime();
+
+ const batch = await runtime.renderPreviews(
+ "#hero-video",
+ [
+ { id: "clean", grading: "clean-studio" },
+ { id: "warm", grading: "warm-daylight" },
+ ],
+ { maxDimension: 160 },
+ );
+
+ expect(batch).toMatchObject({
+ width: 160,
+ height: 90,
+ images: [
+ { id: "clean", dataUrl: "data:image/png;base64,preview" },
+ { id: "warm", dataUrl: "data:image/png;base64,preview" },
+ ],
+ });
+ expect(getContextSpy).toHaveBeenCalledTimes(1);
+ expect(document.querySelector("[data-hf-color-grading-canvas]")).toBeNull();
+ expect(video.style.opacity).toBe("");
+
+ await runtime.renderPreviews("#hero-video", [{ id: "mono", grading: "mono-clean" }]);
+ expect(getContextSpy).toHaveBeenCalledTimes(1);
+
+ const retinaBatch = await runtime.renderPreviews(
+ "#hero-video",
+ [{ id: "retina", grading: "neutral" }],
+ { maxDimension: 400 },
+ );
+ expect(retinaBatch).toMatchObject({ width: 320, height: 180 });
+ toDataUrl.mockRestore();
+ });
+
+ it("plays only a selected video for preview and restores its media state", () => {
+ const video = makeDrawableVideo();
+ let paused = true;
+ Object.defineProperty(video, "paused", { configurable: true, get: () => paused });
+ Object.defineProperty(video, "currentTime", {
+ configurable: true,
+ value: 3.25,
+ writable: true,
+ });
+ Object.defineProperty(video, "duration", { configurable: true, value: 10 });
+ const play = vi.spyOn(video, "play").mockImplementation(() => {
+ paused = false;
+ return Promise.resolve();
+ });
+ const pause = vi.spyOn(video, "pause").mockImplementation(() => {
+ paused = true;
+ });
+ video.loop = false;
+ video.muted = false;
+ document.body.appendChild(video);
+ runtime = createColorGradingRuntime();
+
+ const stop = runtime.startPreviewPlayback("#hero-video");
+
+ expect(stop).not.toBeNull();
+ expect(play).toHaveBeenCalledTimes(1);
+ expect(video.loop).toBe(true);
+ expect(video.muted).toBe(true);
+ video.currentTime = 6;
+ stop?.();
+ expect(pause).toHaveBeenCalledTimes(1);
+ expect(video.currentTime).toBe(3.25);
+ expect(video.loop).toBe(false);
+ expect(video.muted).toBe(false);
+ });
+
+ it("does not start preview playback for an image", () => {
+ const image = makeDrawableImage();
+ document.body.appendChild(image);
+ runtime = createColorGradingRuntime();
+
+ expect(runtime.startPreviewPlayback("#hero-image")).toBeNull();
+ });
+
+ it("uses selected-video time for isolated animated preview frames", async () => {
+ const video = makeDrawableVideo();
+ Object.defineProperty(video, "currentTime", { configurable: true, value: 6.5 });
+ document.body.appendChild(video);
+ window.__player = { getTime: () => 1.25 };
+ vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue(
+ "data:image/png;base64,preview",
+ );
+ runtime = createColorGradingRuntime();
+
+ await runtime.renderPreviews("#hero-video", [{ id: "vhs", grading: { effects: { vhs: 1 } } }], {
+ maxDimension: 160,
+ useMediaTime: true,
+ });
+
+ expect(lastUniform1f?.mock.calls).toContainEqual(["u_effectTime", 6.5]);
+ });
+
+ it("uses the LUT cache and canonical multipass renderer in preview batches", async () => {
+ const video = makeDrawableVideo();
+ video.removeAttribute(HF_COLOR_GRADING_ATTR);
+ document.body.appendChild(video);
+ const fetchMock = stubCubeLutFetch();
+ const toDataUrl = vi
+ .spyOn(HTMLCanvasElement.prototype, "toDataURL")
+ .mockReturnValue("data:image/png;base64,lut-preview");
+ runtime = createColorGradingRuntime();
+
+ const batch = await runtime.renderPreviews("#hero-video", [
+ {
+ id: "lut",
+ grading: { preset: "warm-daylight", lut: { src: "/looks/test.cube", intensity: 0.6 } },
+ },
+ { id: "blur", grading: { effects: { blur: 0.5 } } },
+ { id: "bloom", grading: { effects: { bloom: 0.5, bloomRadius: 8 } } },
+ { id: "kuwahara", grading: { effects: { kuwahara: 1, kuwaharaRadius: 0.25 } } },
+ ]);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(batch?.images).toEqual([
+ { id: "lut", dataUrl: "data:image/png;base64,lut-preview" },
+ { id: "blur", dataUrl: "data:image/png;base64,lut-preview" },
+ { id: "bloom", dataUrl: "data:image/png;base64,lut-preview" },
+ { id: "kuwahara", dataUrl: "data:image/png;base64,lut-preview" },
+ ]);
+ toDataUrl.mockRestore();
});
it("resolves grading values from the nearest sub-composition variable scope", () => {
@@ -323,6 +532,151 @@ describe("createColorGradingRuntime", () => {
expect(lastUniform1f).toHaveBeenCalledWith("u_exposure", 0.35);
});
+ it("samples seek-derived grading values from inline CSS properties on every redraw", () => {
+ const video = makeDrawableVideo();
+ video.setAttribute(
+ HF_COLOR_GRADING_ATTR,
+ serializeHfColorGrading({
+ adjust: { exposure: 0.5 },
+ effects: {
+ blur: 0.2,
+ bloom: 0.2,
+ kuwahara: 0.2,
+ pixelate: 0.3,
+ ascii: 0.4,
+ dither: 0.5,
+ },
+ lut: { src: "assets/luts/test.cube", intensity: 0.4 },
+ }),
+ );
+ video.style.setProperty("--hf-color-grading-intensity", "0.25");
+ video.style.setProperty("--hf-color-grading-lut-intensity", "0.35");
+ video.style.setProperty("--hf-color-grading-exposure", "-0.15");
+ video.style.setProperty("--hf-color-grading-blur", "0.45");
+ video.style.setProperty("--hf-color-grading-bloom", "0.45");
+ video.style.setProperty("--hf-color-grading-kuwahara", "0.45");
+ video.style.setProperty("--hf-color-grading-pixelate", "0.55");
+ video.style.setProperty("--hf-color-grading-ascii", "0.65");
+ video.style.setProperty("--hf-color-grading-dither", "0.75");
+ stubCubeLutFetch();
+ startRuntimeWithVideo(video);
+
+ if (!lastUniform1f) throw new Error("Expected WebGL uniform calls");
+ expect(lastUniform1f).toHaveBeenCalledWith("u_intensity", 0.25);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_lutIntensity", 0.35);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_exposure", -0.15);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_blur", 0.45);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_bloom", 0.45);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_kuwahara", 0.45);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_pixelate", 0.55);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_ascii", 0.65);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_dither", 0.75);
+
+ video.style.setProperty("--hf-color-grading-intensity", "0.75");
+ video.style.setProperty("--hf-color-grading-lut-intensity", "0.65");
+ video.style.setProperty("--hf-color-grading-exposure", "0.15");
+ video.style.setProperty("--hf-color-grading-blur", "0.15");
+ video.style.setProperty("--hf-color-grading-bloom", "0.15");
+ video.style.setProperty("--hf-color-grading-kuwahara", "0.15");
+ video.style.setProperty("--hf-color-grading-pixelate", "0.05");
+ video.style.setProperty("--hf-color-grading-ascii", "0.15");
+ video.style.setProperty("--hf-color-grading-dither", "0.25");
+ lastUniform1f.mockClear();
+ runtime?.redraw();
+
+ expect(lastUniform1f).toHaveBeenCalledWith("u_intensity", 0.75);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_lutIntensity", 0.65);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_exposure", 0.15);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_blur", 0.15);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_bloom", 0.15);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_kuwahara", 0.15);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_pixelate", 0.05);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_ascii", 0.15);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_dither", 0.25);
+
+ video.style.setProperty("--hf-color-grading-intensity", "invalid");
+ video.style.setProperty("--hf-color-grading-lut-intensity", "2");
+ video.style.setProperty("--hf-color-grading-exposure", "-3");
+ video.style.setProperty("--hf-color-grading-blur", "-1");
+ video.style.setProperty("--hf-color-grading-bloom", "invalid");
+ video.style.setProperty("--hf-color-grading-kuwahara", "invalid");
+ video.style.setProperty("--hf-color-grading-pixelate", "invalid");
+ video.style.setProperty("--hf-color-grading-ascii", "-1");
+ video.style.setProperty("--hf-color-grading-dither", "invalid");
+ lastUniform1f.mockClear();
+ runtime?.redraw();
+
+ expect(lastUniform1f).toHaveBeenCalledWith("u_intensity", 1);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_lutIntensity", 1);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_exposure", -2);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_blur", 0);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_bloom", 0.2);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_kuwahara", 0.2);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_pixelate", 0.3);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_ascii", 0);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_dither", 0.5);
+ });
+
+ it("initializes a zero-start grade when an animated property declares future state", () => {
+ const video = makeDrawableVideo();
+ video.setAttribute(
+ HF_COLOR_GRADING_ATTR,
+ serializeHfColorGrading({
+ intensity: 0,
+ adjust: { exposure: 0.5 },
+ effects: { kuwahara: 0 },
+ }),
+ );
+ video.style.setProperty("--hf-color-grading-intensity", "0");
+ video.style.setProperty("--hf-color-grading-kuwahara", "0");
+ document.body.appendChild(video);
+
+ runtime = createColorGradingRuntime();
+
+ expect(getContextSpy).toHaveBeenCalledTimes(1);
+ if (!lastUniform1f) throw new Error("Expected WebGL uniform calls");
+ expect(lastUniform1f).toHaveBeenCalledWith("u_intensity", 0);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_kuwahara", 0);
+ expect(texImage2DCalls.some((args) => args.includes(0x8d61))).toBe(true);
+
+ video.style.setProperty("--hf-color-grading-intensity", "1");
+ video.style.setProperty("--hf-color-grading-kuwahara", "1");
+ lastUniform1f.mockClear();
+ runtime.redraw();
+
+ expect(lastUniform1f).toHaveBeenCalledWith("u_intensity", 1);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_kuwahara", 1);
+ });
+
+ it("redraws animated still images from the transport tick", () => {
+ const image = makeDrawableImage();
+ document.body.appendChild(image);
+ runtime = createColorGradingRuntime();
+ image.style.setProperty("--hf-color-grading-blur", "0.7");
+ lastUniform1f?.mockClear();
+
+ expect(runtime.redrawAnimated()).toBe(1);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_blur", 0.7);
+ });
+
+ it("redraws animated held video frames without duplicating active video draws", () => {
+ const video = makeDrawableVideo();
+ video.style.setProperty("--hf-color-grading-blur", "0.1");
+ startRuntimeWithVideo(video);
+ Object.defineProperty(video, "paused", { value: false, configurable: true });
+ Object.defineProperty(video, "ended", { value: false, configurable: true });
+
+ expect(runtime?.redrawAnimated()).toBe(0);
+
+ Object.defineProperty(video, "paused", { value: true, configurable: true });
+ Object.defineProperty(video, "ended", { value: true, configurable: true });
+ video.style.setProperty("--hf-color-grading-blur", "0.8");
+ lastUniform1f?.mockClear();
+
+ expect(runtime?.redrawAnimated()).toBe(1);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_blur", 0.8);
+ });
+
it("keeps the last shader frame visible while a video seek is waiting for a drawable frame", () => {
const { video, canvas } = startRuntimeWithVideo();
@@ -373,6 +727,45 @@ describe("createColorGradingRuntime", () => {
expect(canvas.style.opacity).toBe("0.75");
});
+ it("allows a drawable producer render frame to initialize hidden source grading", () => {
+ const video = makeDrawableVideo();
+ video.style.display = "none";
+ document.body.appendChild(video);
+
+ const frame = document.createElement("img");
+ frame.id = "__render_frame_hero-video__";
+ frame.className = "__render_frame__";
+ frame.style.visibility = "visible";
+ 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.after(frame);
+
+ runtime = createColorGradingRuntime();
+
+ expect(getContextSpy).toHaveBeenCalledTimes(1);
+ expect(document.querySelector("[data-hf-color-grading-canvas]")).not.toBeNull();
+ });
+
+ it("does not recreate an inactive clip from its hidden producer frame", () => {
+ const { video, canvas } = startRuntimeWithVideo();
+ const frame = document.createElement("img");
+ frame.id = "__render_frame_hero-video__";
+ frame.className = "__render_frame__";
+ frame.style.visibility = "hidden";
+ 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.visibility = "hidden";
+ expect(runtime.setSourceVisibility(video, false)).toBe(true);
+ runtime.refresh();
+ expect(canvas.isConnected).toBe(false);
+ expect(getContextSpy).toHaveBeenCalledTimes(1);
+ expect(video.hasAttribute("data-hf-color-grading-source-hidden")).toBe(false);
+ });
+
it("moves the canvas above producer render-frame images before capture", () => {
const video = makeDrawableVideo();
Object.defineProperty(video, "readyState", {
@@ -427,20 +820,82 @@ describe("createColorGradingRuntime", () => {
it("passes finishing detail uniforms into the shader", () => {
const video = makeDrawableVideo();
+ const details = {
+ vignette: 0.4,
+ vignetteMidpoint: 0.35,
+ vignetteRoundness: -0.25,
+ vignetteFeather: 0.8,
+ grain: 0.2,
+ grainSize: 0.7,
+ grainRoughness: 0.3,
+ };
+ const effects = {
+ blur: 0.3,
+ pixelate: 0.1,
+ chromaBleed: 0.25,
+ tapeDamage: 0.35,
+ tapeTracking: 0.45,
+ tapeNoise: 0.55,
+ tapeSpeed: 0.65,
+ filmArtifacts: 0.45,
+ halftone: 0.55,
+ halftoneSize: 0.65,
+ twoInkPrint: 0.75,
+ twoInkPrintSize: 0.85,
+ ascii: 0.6,
+ asciiSize: 0.4,
+ asciiInvert: 1,
+ dither: 0.7,
+ ditherSize: 0.3,
+ asciiStyle: 4,
+ asciiColor: 1,
+ asciiRotation: 1,
+ monoScreen: 0.2,
+ monoScreenSize: 0.3,
+ monoScreenAngle: 0.4,
+ monoScreenSpread: 0.5,
+ monoScreenShape: 3,
+ monoScreenInvert: 1,
+ scanlines: 0.25,
+ scanlineCount: 0.35,
+ scanlineSoftness: 0.45,
+ chromaticAberration: 0.3,
+ chromaticAngle: 0.4,
+ crtCurvature: 0.2,
+ digitalGlitch: 0.35,
+ digitalGlitchColorSplit: 0.4,
+ digitalGlitchLineTear: 0.45,
+ digitalGlitchPixelate: 0.5,
+ digitalGlitchBlockAmount: 0.55,
+ digitalGlitchBlockDisplacement: 0.65,
+ digitalGlitchBlockOpacity: 0.15,
+ digitalGlitchSpeed: 0.75,
+ engraving: 0.8,
+ engravingSpacing: 0.41,
+ engravingMinThickness: 0.2,
+ engravingMaxThickness: 0.46,
+ engravingAngle: 0.25,
+ engravingContrast: 0.47,
+ engravingSharpness: 0.59,
+ engravingWave: 0.2,
+ engravingWaveFrequency: 0.22,
+ crosshatch: 0.85,
+ crosshatchSpacing: 0.28,
+ crosshatchThickness: 0.25,
+ crosshatchAngle: 0.25,
+ crosshatchContrast: 0.33,
+ crosshatchEdges: 0.5,
+ crosshatchLineWeight: 0.15,
+ crosshatchWave: 0.33,
+ crosshatchWaveFrequency: 0.22,
+ };
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 },
+ details,
+ effects,
+ palette: ["#080717", "#3c185f", "#d9339f", "#ff6b66"],
}),
);
document.body.appendChild(video);
@@ -448,17 +903,192 @@ describe("createColorGradingRuntime", () => {
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);
+ for (const [key, value] of Object.entries({ vibrance: 0.35, ...details, ...effects })) {
+ expect(lastUniform1f).toHaveBeenCalledWith(`u_${key}`, value);
+ }
expect(lastUniform1f).toHaveBeenCalledWith("u_grainSeed", expect.any(Number));
- expect(lastUniform1f).toHaveBeenCalledWith("u_blur", 0.3);
- expect(lastUniform1f).toHaveBeenCalledWith("u_pixelate", 0.1);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_paletteSize", 4);
+ if (!lastUniform3f) throw new Error("Expected WebGL palette uniform calls");
+ expect(lastUniform3f).toHaveBeenCalledWith("u_palette0", 8 / 255, 7 / 255, 23 / 255);
+ expect(lastUniform3f).toHaveBeenCalledWith("u_palette3", 1, 107 / 255, 102 / 255);
+
+ const fragment = lastShaderSources.find((source) => source.includes("sampleMedia"));
+ expect(fragment).toContain("float centerLuma = lumaOf(base.rgb);");
+ expect(fragment).toContain("vec3(centerLuma) + blurredChroma");
+ expect(fragment).toContain("float headSwitch");
+ expect(fragment).toContain("float tapeTrackingBand");
+ expect(fragment).toContain("float tapeTime = u_effectTime");
+ expect(fragment).toContain("float tapeFrame = floor(tapeTime * 60.0);");
+ expect(fragment).not.toContain(
+ "tapeTrackingBand(float y, float center, float width, float phase)",
+ );
+ expect(fragment).toContain("float lineJitter = (digitalHash");
+ expect(fragment).toContain("float dustMask");
+ expect(fragment).toContain("float screenDot");
+ expect(fragment).toContain("vec3 applyHalftone");
+ expect(fragment).toContain("vec3 applyTwoInkPrint");
+ expect(fragment).toContain("float standardAsciiSample");
+ expect(fragment).toContain("float bayer4");
+ expect(fragment).toContain("vec3 applyAscii");
+ expect(fragment).toContain("vec3 applyDither");
+ expect(fragment).toContain("vec3 applyMonoScreen");
+ expect(fragment).toContain("vec2 applyCrtWarp");
+ expect(fragment).toContain("vec4 sampleChromaticMedia");
+ expect(fragment).toContain("vec3 applyScanlines");
+ expect(fragment).toContain("vec3 applyDigitalGlitch");
+ expect(fragment).toContain("vec3 applyEngraving");
+ expect(fragment).toContain("vec3 applyCrosshatch");
+ expect(fragment).toContain("float crosshatchEdge");
+ expect(fragment).toContain("vec3 applyCrosshatch(vec2 uv");
+ expect(fragment).toContain("crosshatchEdge(uv)");
+ expect(fragment).not.toContain("crosshatchEdge(v_uv)");
+ expect(fragment).toContain("vec2 texel = 1.0 / max(u_resolution * u_uvScale, vec2(1.0));");
+ expect(fragment).toContain("float baseAngle = -clamp(u_crosshatchAngle, 0.0, 1.0) * PI;");
+ expect(fragment).toContain("float variation = mix(1.0, 0.5 + digitalHash");
+ expect(fragment).toContain("baseAngle + PI * 0.5");
+ expect(fragment).toContain("baseAngle + PI * 0.25");
+ expect(fragment).toContain("baseAngle - PI * 0.25");
+ expect(fragment).toContain("asciiStyleSample(floor(u_asciiStyle + 0.5)");
+ expect(fragment).toContain("float cellHeight = mix(4.0, 80.0");
+ expect(fragment).toContain("vec2 asciiEdgeDirection");
+ expect(fragment).toContain("vec3 background = paletteColor(0.0);");
+ expect(fragment).not.toContain("grainHash(cell + vec2(17.0, 43.0))");
+ expect(fragment).toContain("sampleColor = sampleChromaticMedia(uv, sampleColor);");
+ expect(fragment).toContain("sampleColor.rgb = applyDigitalGlitch(uv, sampleColor.rgb);");
+ expect(fragment).toContain("vec3 sampleDigitalSplit");
+ expect(fragment).toContain("float digitalHash(vec2 p)");
+ expect(fragment).toContain("float direction = digitalHash");
+ expect(fragment).toContain("float randomA = digitalHash");
+ expect(fragment).toContain("float colorSplit = clamp(u_digitalGlitchColorSplit");
+ expect(fragment).toContain("float pixelate = clamp(u_digitalGlitchPixelate");
+ expect(fragment).toContain("blockDisplacement > 0.0 && blockOpacity > 0.0");
+ expect(fragment).toContain("displaced = mix(uv, displaced, blockOpacity);");
+ expect(fragment).not.toContain("vec3 electronicBlock");
+ expect(fragment).toContain("color = applyMonoScreen");
+ expect(fragment).toContain("color = applyEngraving");
+ expect(fragment).toContain("color = applyCrosshatch");
+ expect(fragment).toContain("color = applyScanlines");
+ expect(fragment).toContain("float curvature = clamp(u_crtCurvature, 0.0, 1.0) * 0.5;");
+ expect(fragment).toContain("float dist = dot(centered, centered);");
+ expect(fragment).toContain("centered *= 1.0 + curvature * dist;");
+ expect(fragment).toContain("float wave = 0.5 + 0.5 * sin(v_uv.y * count * PI);");
+ expect(fragment).toContain("float line = mix(1.0 - wave, pow(1.0 - wave, 2.2), softness);");
+ expect(fragment).not.toContain("float hardLine = step(0.78, wave);");
+ expect(fragment.indexOf("float vignettePower")).toBeGreaterThan(
+ fragment.indexOf("color = applyScanlines"),
+ );
+ expect(fragment).toContain("amount * 0.02");
+ expect(fragment).not.toContain("mix(center.rgb, split, amount)");
+ });
+
+ it("renders Kuwahara through bounded moment passes before the main shader", () => {
+ const video = makeDrawableVideo();
+ video.setAttribute(
+ HF_COLOR_GRADING_ATTR,
+ serializeHfColorGrading({
+ effects: {
+ kuwahara: 0.8,
+ kuwaharaRadius: 0.25,
+ kuwaharaSharpness: 0.4,
+ kuwaharaSaturation: 0.6,
+ },
+ }),
+ );
+ document.body.appendChild(video);
+
+ runtime = createColorGradingRuntime();
+
+ if (!lastUniform1f) throw new Error("Expected WebGL uniform calls");
+ expect(lastUniform1f).toHaveBeenCalledWith("u_kuwahara", 0.8);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_kuwaharaRadius", 0.25);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_kuwaharaSharpness", 0.4);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_kuwaharaSaturation", 0.6);
+ expect(lastShaderSources.some((source) => source.includes("u_kuwaharaMoments"))).toBe(true);
+ expect(
+ lastShaderSources.some((source) => source.includes("meanSquare - dot(mean, mean)")),
+ ).toBe(true);
+ expect(texImage2DCalls.some((args) => args.includes(0x8d61))).toBe(true);
+ });
+
+ it("falls back to the untreated shader output when half-float targets are unavailable", () => {
+ getContextSpy.mockImplementation((type: string) =>
+ type === "webgl" ? createMockWebGl({ halfFloatSupported: false }) : null,
+ );
+ const video = makeDrawableVideo();
+ video.setAttribute(
+ HF_COLOR_GRADING_ATTR,
+ serializeHfColorGrading({ effects: { kuwahara: 1 } }),
+ );
+ const { canvas } = startRuntimeWithVideo(video);
+
+ if (!lastUniform1f) throw new Error("Expected WebGL uniform calls");
+ expect(lastUniform1f).toHaveBeenCalledWith("u_kuwaharaReady", 0);
+ expect(canvas.style.display).toBe("block");
+ expect(runtime?.getStatus(video)).toEqual({
+ state: "unavailable",
+ message: "Kuwahara requires half-float framebuffer support",
+ });
+ expect(texImage2DCalls.some((args) => args.includes(0x8d61))).toBe(false);
+ });
+
+ it("releases lazy Kuwahara GPU resources when attributed media becomes inactive", () => {
+ const video = makeDrawableVideo();
+ video.setAttribute(
+ HF_COLOR_GRADING_ATTR,
+ serializeHfColorGrading({ effects: { kuwahara: 1 } }),
+ );
+ startRuntimeWithVideo(video);
+ const gl = getContextSpy.mock.results[0]?.value as WebGLRenderingContext;
+
+ expect(runtime?.setSourceVisibility(video, false)).toBe(true);
+ expect(gl.deleteFramebuffer).toHaveBeenCalledTimes(2);
+ expect(gl.deleteTexture).toHaveBeenCalledTimes(2);
+ expect(gl.deleteProgram).toHaveBeenCalledTimes(2);
+ expect(loseContextCalls).toBe(0);
+ expect(document.querySelector("[data-hf-color-grading-canvas]")).toBeNull();
+ });
+
+ it("releases pooled WebGL contexts when the runtime is destroyed", () => {
+ const { video } = startRuntimeWithVideo();
+
+ expect(runtime?.setSourceVisibility(video, false)).toBe(true);
+ expect(loseContextCalls).toBe(0);
+
+ runtime?.destroy();
+ runtime = null;
+ expect(loseContextCalls).toBe(1);
+ });
+
+ it("drives temporal shader effects from canonical composition time", () => {
+ let playerTime = 2.25;
+ Object.defineProperty(window, "__player", {
+ value: { getTime: () => playerTime },
+ configurable: true,
+ });
+ const video = makeDrawableVideo();
+ Object.defineProperty(video, "currentTime", { value: 19.5, configurable: true });
+ video.setAttribute(
+ HF_COLOR_GRADING_ATTR,
+ serializeHfColorGrading({ effects: { digitalGlitch: 1 } }),
+ );
+ document.body.appendChild(video);
+
+ runtime = createColorGradingRuntime();
+
+ if (!lastUniform1f) throw new Error("Expected WebGL uniform calls");
+ expect(lastUniform1f).toHaveBeenCalledWith("u_effectTime", 2.25);
+ const firstGrainSeed = lastUniform1f.mock.calls.findLast(
+ ([uniform]) => uniform === "u_grainSeed",
+ )?.[1] as number | undefined;
+ playerTime = 2.5;
+ runtime.redraw();
+ const secondGrainSeed = lastUniform1f.mock.calls.findLast(
+ ([uniform]) => uniform === "u_grainSeed",
+ )?.[1] as number | undefined;
+ expect(firstGrainSeed).toBeTypeOf("number");
+ expect(secondGrainSeed).toBe((firstGrainSeed ?? 0) + 15);
+ const fragment = lastShaderSources.find((source) => source.includes("sampleMedia"));
+ expect(fragment).toContain("float time = u_effectTime * speed;");
});
it("uses the effected media sample as the graded shader input", () => {
@@ -480,7 +1110,11 @@ describe("createColorGradingRuntime", () => {
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).toContain("vec3 applyPrimaryGrade(vec3 color)");
+ expect(fragment).toContain(
+ "vec3 color = mix(sampleColor.rgb, applyPrimaryGrade(sampleColor.rgb), u_intensity);",
+ );
+ expect(fragment).not.toContain("mix(original, color, u_intensity)");
expect(fragment).not.toContain("sampleSoft");
const blurFragment = lastShaderSources.find((source) =>
source.includes("uniform vec2 u_direction;"),
@@ -505,6 +1139,47 @@ describe("createColorGradingRuntime", () => {
).toBe(true);
});
+ it("renders article bloom with thresholded half-resolution Gaussian passes", () => {
+ const video = makeDrawableVideo();
+ video.setAttribute(
+ HF_COLOR_GRADING_ATTR,
+ serializeHfColorGrading({ effects: { bloom: 0.5, bloomRadius: 8 } }),
+ );
+ document.body.appendChild(video);
+
+ runtime = createColorGradingRuntime();
+
+ expect(lastUniform1f).toHaveBeenCalledWith("u_bloom", 0.5);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_bloomReady", 1);
+ expect(lastUniform1f).toHaveBeenCalledWith("u_radius", 4);
+ expect(texImage2DCalls.some((args) => args[3] === 320 && args[4] === 180)).toBe(true);
+ const bloomFragment = lastShaderSources.find((source) => source.includes("u_bloomPass"));
+ expect(bloomFragment).toContain("vec3(0.299, 0.587, 0.114)");
+ expect(bloomFragment).toContain("0.227027");
+ expect(bloomFragment).toContain("0.1945946");
+ expect(bloomFragment).toContain("0.016216");
+ });
+
+ it("releases the lazy bloom output used only when blur and bloom coexist", () => {
+ const video = makeDrawableVideo();
+ video.setAttribute(
+ HF_COLOR_GRADING_ATTR,
+ serializeHfColorGrading({
+ effects: { blur: 0.4, bloom: 0.5, bloomRadius: 8 },
+ }),
+ );
+ startRuntimeWithVideo(video);
+ const gl = getContextSpy.mock.results[0]?.value as WebGLRenderingContext;
+
+ expect(texImage2DCalls.some((args) => args[3] === 640 && args[4] === 360)).toBe(true);
+ expect(texImage2DCalls.some((args) => args[3] === 320 && args[4] === 180)).toBe(true);
+
+ expect(runtime?.setSourceVisibility(video, false)).toBe(true);
+ expect(gl.deleteFramebuffer).toHaveBeenCalledTimes(3);
+ expect(gl.deleteTexture).toHaveBeenCalledTimes(3);
+ expect(gl.deleteProgram).toHaveBeenCalledTimes(1);
+ });
+
it("loads cube LUTs and enables LUT uniforms", async () => {
const fetchMock = stubCubeLutFetch();
const origin = window.location.origin;
@@ -532,6 +1207,40 @@ describe("createColorGradingRuntime", () => {
expect(runtime.getStatus("#hero-video").message).toBe("Shader + LUT active");
});
+ it("waits for active LUTs before deterministic capture", async () => {
+ let releaseFetch: (() => void) | undefined;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(
+ () =>
+ new Promise<{ ok: boolean; status: number; text: () => Promise }>((resolve) => {
+ releaseFetch = () =>
+ resolve({ ok: true, status: 200, text: () => Promise.resolve(IDENTITY_2) });
+ }),
+ ),
+ );
+ const video = makeDrawableVideo();
+ video.setAttribute(
+ HF_COLOR_GRADING_ATTR,
+ serializeHfColorGrading({ lut: { src: "assets/luts/identity.cube", intensity: 1 } }),
+ );
+ document.body.appendChild(video);
+ runtime = createColorGradingRuntime();
+
+ expect(runtime.getStatus(video)).toEqual({ state: "pending", message: "Loading LUT" });
+ const ready = runtime.waitForActiveLuts();
+ let settled = false;
+ void ready.then(() => {
+ settled = true;
+ });
+ await Promise.resolve();
+ expect(settled).toBe(false);
+
+ releaseFetch?.();
+ expect(await ready).toBe(1);
+ expect(runtime.getStatus(video).message).toBe("Shader + LUT active");
+ });
+
it("bounds the runtime LUT cache", async () => {
const fetchMock = stubCubeLutFetch();
const origin = window.location.origin;
@@ -644,15 +1353,16 @@ describe("installAuthoredOpacityCapture", () => {
el.remove();
});
- it("stamps an already-inserted element the moment it GAINS grading at runtime", async () => {
+ it("stamps ungraded media before a live Studio grade can hide it", async () => {
installAuthoredOpacityCapture();
const el = document.createElement("img");
el.style.opacity = "0.9";
document.body.appendChild(el);
await Promise.resolve();
- expect(el.hasAttribute("data-hf-authored-opacity")).toBe(false);
+ expect(el.getAttribute("data-hf-authored-opacity")).toBe("0.9");
- // Studio applies a preset to a previously ungraded element — no re-insert.
+ // The live runtime hides the source before Studio persists the attribute.
+ el.style.setProperty("opacity", "0", "important");
el.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ adjust: { exposure: 0.5 } }));
await Promise.resolve();
expect(el.getAttribute("data-hf-authored-opacity")).toBe("0.9");
diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts
index 4fd00faf0..09bdbdc2a 100644
--- a/packages/core/src/runtime/colorGrading.ts
+++ b/packages/core/src/runtime/colorGrading.ts
@@ -1,9 +1,17 @@
import {
+ HF_COLOR_GRADING_ADJUST_KEYS,
+ HF_COLOR_GRADING_ANIMATABLE_PROPERTIES,
HF_COLOR_GRADING_ATTR,
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
+ HF_COLOR_GRADING_DETAIL_KEYS,
+ HF_COLOR_GRADING_EFFECT_KEYS,
isHfColorGradingActive,
normalizeHfColorGrading,
normalizeHfColorGradingWithVariables,
+ type HfColorGradingAdjustKey,
+ type HfColorGradingAnimatablePath,
+ type HfColorGradingDetailKey,
+ type HfColorGradingEffectKey,
type HfColorGradingTarget,
type NormalizedHfColorGrading,
COLOR_GRADING_SOURCE_HIDDEN_ATTR,
@@ -39,6 +47,8 @@ interface VideoFrameCallbackHost {
cancelVideoFrameCallback?: (handle: number) => void;
}
+type FloatUniformBinding = readonly [key: K, location: WebGLUniformLocation];
+
interface ProgramInfo {
program: WebGLProgram;
texture: WebGLTexture;
@@ -47,37 +57,33 @@ interface ProgramInfo {
position: number;
source: WebGLUniformLocation | null;
blurSource: WebGLUniformLocation | null;
+ bloomSource: WebGLUniformLocation | null;
+ kuwaharaSource: WebGLUniformLocation | null;
lut: WebGLUniformLocation | null;
resolution: WebGLUniformLocation | null;
uvScale: WebGLUniformLocation | null;
uvOffset: WebGLUniformLocation | null;
blurReady: WebGLUniformLocation | null;
+ bloomReady: WebGLUniformLocation | null;
+ kuwaharaReady: WebGLUniformLocation | null;
lutEnabled: WebGLUniformLocation | null;
lutSize: WebGLUniformLocation | null;
lutTextureSize: WebGLUniformLocation | null;
lutDomainMin: WebGLUniformLocation | null;
lutDomainMax: WebGLUniformLocation | null;
lutIntensity: WebGLUniformLocation | null;
- exposure: WebGLUniformLocation | null;
- contrast: WebGLUniformLocation | null;
- highlights: WebGLUniformLocation | null;
- shadows: WebGLUniformLocation | null;
- whites: WebGLUniformLocation | null;
- 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;
+ adjustUniforms: readonly FloatUniformBinding[];
+ detailUniforms: readonly FloatUniformBinding[];
+ effectUniforms: readonly FloatUniformBinding[];
grainSeed: WebGLUniformLocation | null;
- blur: WebGLUniformLocation | null;
- pixelate: WebGLUniformLocation | null;
+ effectTime: WebGLUniformLocation | null;
+ paletteSize: WebGLUniformLocation | null;
+ palette0: WebGLUniformLocation | null;
+ palette1: WebGLUniformLocation | null;
+ palette2: WebGLUniformLocation | null;
+ palette3: WebGLUniformLocation | null;
+ palette4: WebGLUniformLocation | null;
+ palette5: WebGLUniformLocation | null;
intensity: WebGLUniformLocation | null;
compareEnabled: WebGLUniformLocation | null;
comparePosition: WebGLUniformLocation | null;
@@ -93,11 +99,14 @@ interface BlurProgramInfo {
resolution: WebGLUniformLocation | null;
direction: WebGLUniformLocation | null;
radius: WebGLUniformLocation | null;
+ bloomPass: WebGLUniformLocation | null;
+ threshold: WebGLUniformLocation | null;
}
interface RenderTarget {
texture: WebGLTexture;
framebuffer: WebGLFramebuffer;
+ type: number;
width: number;
height: number;
}
@@ -106,6 +115,39 @@ interface EffectTargets {
blurProgram: BlurProgramInfo;
scratch: RenderTarget;
blur: RenderTarget;
+ bloom: RenderTarget | null;
+}
+
+interface KuwaharaHorizontalProgramInfo {
+ program: WebGLProgram;
+ quad: WebGLBuffer;
+ position: number;
+ source: WebGLUniformLocation | null;
+ blurSource: WebGLUniformLocation | null;
+ texel: WebGLUniformLocation | null;
+ uvScale: WebGLUniformLocation | null;
+ uvOffset: WebGLUniformLocation | null;
+ blurReady: WebGLUniformLocation | null;
+ blur: WebGLUniformLocation | null;
+ radius: WebGLUniformLocation | null;
+}
+
+interface KuwaharaResolveProgramInfo {
+ program: WebGLProgram;
+ quad: WebGLBuffer;
+ position: number;
+ moments: WebGLUniformLocation | null;
+ texel: WebGLUniformLocation | null;
+ radius: WebGLUniformLocation | null;
+ sharpness: WebGLUniformLocation | null;
+ saturation: WebGLUniformLocation | null;
+}
+
+interface KuwaharaTargets {
+ horizontalProgram: KuwaharaHorizontalProgramInfo;
+ resolveProgram: KuwaharaResolveProgramInfo;
+ moments: RenderTarget;
+ output: RenderTarget;
}
interface RuntimeColorGradingCompareState {
@@ -115,19 +157,26 @@ interface RuntimeColorGradingCompareState {
lineWidth: number;
}
-interface ColorGradingEntry {
- element: ColorGradingMediaElement;
- canvas: HTMLCanvasElement;
+interface EffectRenderState {
gl: WebGLRenderingContext;
program: ProgramInfo;
+ effectTargets: EffectTargets | null;
+ kuwaharaTargets: KuwaharaTargets | null;
+ effectError: string | null;
+}
+
+interface ColorGradingRenderer extends EffectRenderState {
+ canvas: HTMLCanvasElement;
+}
+
+interface ColorGradingEntry extends ColorGradingRenderer {
+ element: ColorGradingMediaElement;
grading: NormalizedHfColorGrading;
compare: RuntimeColorGradingCompareState;
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;
@@ -146,9 +195,26 @@ interface ColorGradingEntry {
destroyed: boolean;
}
+interface ColorGradingPreviewRenderer extends ColorGradingRenderer {
+ lut: RuntimeLutTexture | null;
+}
+
+export interface RuntimeColorGradingPreviewCandidate {
+ id: string;
+ grading: unknown;
+}
+
+export interface RuntimeColorGradingPreviewBatch {
+ width: number;
+ height: number;
+ images: Array<{ id: string; dataUrl: string | null; error?: string }>;
+}
+
export interface RuntimeColorGradingApi {
refresh: () => number;
redraw: () => number;
+ redrawAnimated: () => number;
+ waitForActiveLuts: () => Promise;
setGrading: (
target: HfColorGradingTarget | string | null | undefined,
rawGrading: unknown,
@@ -161,6 +227,14 @@ export interface RuntimeColorGradingApi {
getStatus: (
target: HfColorGradingTarget | string | null | undefined,
) => RuntimeColorGradingStatus;
+ renderPreviews: (
+ target: HfColorGradingTarget | string | null | undefined,
+ candidates: readonly RuntimeColorGradingPreviewCandidate[],
+ options?: { maxDimension?: number; useMediaTime?: boolean },
+ ) => Promise;
+ startPreviewPlayback: (
+ target: HfColorGradingTarget | string | null | undefined,
+ ) => (() => void) | null;
destroy: () => void;
}
@@ -172,6 +246,9 @@ export type RuntimeColorGradingStatus =
| { state: "unavailable"; message: string };
type WindowWithColorGrading = Window & {
+ __player?: {
+ getTime?: () => number;
+ };
__hf?: {
colorGrading?: RuntimeColorGradingApi;
};
@@ -200,27 +277,7 @@ const LUT_CACHE = new Map();
const COLOR_GRADING_CANVAS_ATTR = "data-hf-color-grading-canvas";
const COLOR_GRADING_CANVAS_CLASS = "__hf_color_grading_canvas__";
-/**
- * Capture each color-graded element's AUTHORED inline opacity before any
- * animation engine can mutate it.
- *
- * The grading engine hides its source elements with `opacity: 0 !important`
- * and mirrors their pixels onto a canvas — so at runtime, a graded element's
- * inline/computed opacity no longer represents authored state. Everything that
- * later re-reads element state (GSAP from()-tween re-initialization after an
- * invalidate or a studio soft reload, restoring the source when grading is
- * removed, lint/selection tooling) needs the authored value, and by then it is
- * unrecoverable from the DOM. Stamp it onto the element as
- * `data-hf-authored-opacity` (empty string = no authored inline opacity).
- *
- * Must be installed at runtime-bundle evaluation, while the document is still
- * parsing: the runtime `
+