diff --git a/packages/cli/src/browser/colorGradingShaderStages.browser.test.ts b/packages/cli/src/browser/colorGradingShaderStages.browser.test.ts
new file mode 100644
index 0000000000..85ebc5e17b
--- /dev/null
+++ b/packages/cli/src/browser/colorGradingShaderStages.browser.test.ts
@@ -0,0 +1,178 @@
+import { fileURLToPath } from "node:url";
+import { buildSync } from "esbuild";
+import puppeteer, { type Browser, type Page } from "puppeteer-core";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { HF_COLOR_GRADING_DETAIL_KEYS, HF_COLOR_GRADING_EFFECT_KEYS } from "@hyperframes/core";
+import { ensureBrowser } from "./manager.js";
+
+// A grade compiles only the shader stages it turns on, on the software GL distributed renders use.
+// Compiling the whole shader takes tens of seconds there, so each stage is checked on its own.
+
+const RUNTIME = buildSync({
+ stdin: {
+ contents: `import { createColorGradingRuntime } from ${JSON.stringify(
+ fileURLToPath(new URL("../../../core/src/runtime/colorGrading.ts", import.meta.url)),
+ )};
+window.createColorGradingRuntime = createColorGradingRuntime;`,
+ resolveDir: fileURLToPath(new URL(".", import.meta.url)),
+ loader: "ts",
+ },
+ bundle: true,
+ write: false,
+ format: "iife",
+ platform: "browser",
+}).outputFiles[0]?.text;
+
+const PAGE = `
+
+
+
+`;
+
+// The grade values that turn a stage on. Everything else only tunes a stage that is on.
+const STAGE_SWITCHES = new Set([
+ "blur",
+ "kuwahara",
+ "pixelate",
+ "tapeDamage",
+ "chromaBleed",
+ "chromaticAberration",
+ "crtCurvature",
+ "digitalGlitch",
+ "filmArtifacts",
+ "monoScreen",
+ "engraving",
+ "crosshatch",
+ "halftone",
+ "twoInkPrint",
+ "dither",
+ "ascii",
+ "bloom",
+ "scanlines",
+ "grain",
+ "vignette",
+]);
+
+const tuning = (keys: readonly string[]) =>
+ Object.fromEntries(
+ keys
+ .filter((key) => !STAGE_SWITCHES.has(key))
+ .map((key) => [key, key === "bloomRadius" ? 40 : key === "asciiStyle" ? 3 : 0.6]),
+ );
+
+// Every stage off, every stage's tuning on: what a compiled-in stage would read if it leaked.
+const EVERY_STAGE_OFF = {
+ adjust: { contrast: 0.2 },
+ details: tuning(HF_COLOR_GRADING_DETAIL_KEYS),
+ effects: tuning(HF_COLOR_GRADING_EFFECT_KEYS),
+};
+
+let browser: Browser;
+let page: Page;
+
+const definedStages = (source: string) =>
+ [...source.matchAll(/^#define (HF_STAGE_\w+)$/gm)].map((match) => match[1]);
+
+beforeAll(async () => {
+ const { executablePath } = await ensureBrowser();
+ browser = await puppeteer.launch({
+ executablePath,
+ headless: true,
+ args: [
+ "--no-sandbox",
+ "--use-gl=angle",
+ "--use-angle=swiftshader",
+ "--enable-unsafe-swiftshader",
+ ],
+ });
+ page = await browser.newPage();
+ await page.setContent(PAGE);
+ await page.evaluate(() => (document.getElementById("media") as HTMLImageElement).decode());
+}, 60_000);
+
+afterAll(async () => {
+ await browser?.close();
+});
+
+describe("color grading shader stages on software GL", () => {
+ it("draws the same pixels whether a stage the grade leaves off is compiled in or out", async () => {
+ const drawn = await page.evaluate(async (grading) => {
+ const draw = async (stage: string | null) => {
+ Reflect.set(window, "extraStage", stage);
+ const runtime = Reflect.get(window, "createColorGradingRuntime")();
+ const batch = await runtime.renderPreviews("media", [{ id: "off", grading }]);
+ runtime.destroy();
+ const sources: string[] = Reflect.get(window, "mainSources");
+ return { image: batch?.images[0]?.dataUrl ?? null, source: sources.at(-1) ?? "" };
+ };
+ const stagesOut = await draw(null);
+ const stages = [...new Set(stagesOut.source.match(/(?<=#ifdef )HF_STAGE_\w+/g))];
+ const stagesIn: Record>> = {};
+ for (const stage of stages) stagesIn[stage] = await draw(stage);
+ return { stagesOut, stagesIn };
+ }, EVERY_STAGE_OFF);
+
+ expect(drawn.stagesOut.image).toMatch(/^data:image\/png/);
+ expect(definedStages(drawn.stagesOut.source)).toEqual([]);
+ expect(Object.keys(drawn.stagesIn).length).toBeGreaterThanOrEqual(20);
+ for (const [stage, { image, source }] of Object.entries(drawn.stagesIn)) {
+ expect(definedStages(source), stage).toEqual([stage]);
+ expect(image, stage).toBe(drawn.stagesOut.image);
+ }
+ }, 120_000);
+
+ it("compiles and draws a stage that turns on mid-render", async () => {
+ const frames = await page.evaluate(() => {
+ Reflect.set(window, "extraStage", null);
+ const runtime = Reflect.get(window, "createColorGradingRuntime")();
+ const media = document.getElementById("media") as HTMLImageElement;
+ const sources: string[] = Reflect.get(window, "mainSources");
+ const frame = () => {
+ runtime.redraw();
+ const canvas = document.querySelector(
+ "canvas[data-hf-color-grading-canvas]",
+ );
+ return { image: canvas?.toDataURL("image/png") ?? null, source: sources.at(-1) ?? "" };
+ };
+ runtime.refresh();
+ const before = frame();
+ media.style.setProperty("--hf-color-grading-blur", "0.5");
+ const after = frame();
+ runtime.destroy();
+ return { before, after };
+ });
+
+ expect(definedStages(frames.before.source)).toEqual([]);
+ expect(definedStages(frames.after.source)).toEqual(["HF_STAGE_BLUR"]);
+ expect(frames.before.image).toMatch(/^data:image\/png/);
+ expect(frames.after.image).not.toBe(frames.before.image);
+ }, 60_000);
+});
diff --git a/packages/core/src/runtime/colorGrading.test.ts b/packages/core/src/runtime/colorGrading.test.ts
index 2af15f05c8..fb15a82b64 100644
--- a/packages/core/src/runtime/colorGrading.test.ts
+++ b/packages/core/src/runtime/colorGrading.test.ts
@@ -1,6 +1,10 @@
// fallow-ignore-file code-duplication
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { HF_COLOR_GRADING_ATTR, serializeHfColorGrading } from "../colorGrading";
+import {
+ HF_COLOR_GRADING_ATTR,
+ HF_COLOR_GRADING_EFFECT_KEYS,
+ serializeHfColorGrading,
+} from "../colorGrading";
import {
createColorGradingRuntime,
installAuthoredOpacityCapture,
@@ -1497,6 +1501,244 @@ describe("createColorGradingRuntime", () => {
expect(fragment).toContain("float time = u_effectTime * speed;");
});
+ function mainFragmentSources(): string[] {
+ return lastShaderSources.filter((source) =>
+ source.includes("vec4 sampleColor = sampleMedia(uv);"),
+ );
+ }
+
+ function stageNames(source: string, directive: "define" | "ifdef"): string[] {
+ const pattern = new RegExp(`^#${directive} (HF_STAGE_\\w+)$`, "gm");
+ return [...new Set([...source.matchAll(pattern)].map((match) => match[1] ?? ""))].sort();
+ }
+
+ it("compiles an adjust-only grade with every optional shader stage left out", () => {
+ startRuntimeWithVideo();
+
+ const fragments = mainFragmentSources();
+ expect(fragments).not.toHaveLength(0);
+ for (const fragment of fragments) expect(stageNames(fragment, "define")).toEqual([]);
+ expect(stageNames(fragments[0] ?? "", "ifdef")).toContain("HF_STAGE_ASCII");
+ });
+
+ it("compiles exactly the stages a grade enables", () => {
+ const video = makeDrawableVideo();
+ video.setAttribute(
+ HF_COLOR_GRADING_ATTR,
+ serializeHfColorGrading({ details: { vignette: 0.3 }, effects: { ascii: 0.5 } }),
+ );
+ startRuntimeWithVideo(video);
+
+ expect(stageNames(mainFragmentSources().at(-1) ?? "", "define")).toEqual([
+ "HF_STAGE_ASCII",
+ "HF_STAGE_VIGNETTE",
+ ]);
+ });
+
+ it.each([
+ ["HF_STAGE_BLUR", { effects: { blur: 0.4 } }],
+ ["HF_STAGE_KUWAHARA", { effects: { kuwahara: 0.4 } }],
+ ["HF_STAGE_PIXELATE", { effects: { pixelate: 0.4 } }],
+ ["HF_STAGE_TAPE", { effects: { tapeDamage: 0.4 } }],
+ ["HF_STAGE_CHROMA_BLEED", { effects: { chromaBleed: 0.4 } }],
+ ["HF_STAGE_CHROMATIC", { effects: { chromaticAberration: 0.4 } }],
+ ["HF_STAGE_CRT", { effects: { crtCurvature: 0.4 } }],
+ ["HF_STAGE_GLITCH", { effects: { digitalGlitch: 0.4 } }],
+ ["HF_STAGE_GRAIN", { details: { grain: 0.4 } }],
+ ["HF_STAGE_FILM_ARTIFACTS", { effects: { filmArtifacts: 0.4 } }],
+ ["HF_STAGE_MONO_SCREEN", { effects: { monoScreen: 0.4 } }],
+ ["HF_STAGE_ENGRAVING", { effects: { engraving: 0.4 } }],
+ ["HF_STAGE_CROSSHATCH", { effects: { crosshatch: 0.4 } }],
+ ["HF_STAGE_HALFTONE", { effects: { halftone: 0.4 } }],
+ ["HF_STAGE_TWO_INK_PRINT", { effects: { twoInkPrint: 0.4 } }],
+ ["HF_STAGE_DITHER", { effects: { dither: 0.4 } }],
+ ["HF_STAGE_ASCII", { effects: { ascii: 0.4 } }],
+ ["HF_STAGE_BLOOM", { effects: { bloom: 0.4 } }],
+ ["HF_STAGE_SCANLINES", { effects: { scanlines: 0.4 } }],
+ ["HF_STAGE_VIGNETTE", { details: { vignette: 0.4 } }],
+ [
+ "HF_STAGE_RGB_CURVES",
+ {
+ curves: {
+ master: [
+ [0, 0],
+ [0.5, 0.6],
+ [1, 1],
+ ],
+ },
+ },
+ ],
+ [
+ "HF_STAGE_HUE_CURVES",
+ {
+ hueCurves: {
+ hueVsHue: [
+ [0, 0],
+ [120, 12],
+ [240, 0],
+ ],
+ },
+ },
+ ],
+ [
+ "HF_STAGE_SECONDARIES",
+ {
+ secondaries: [
+ {
+ key: { hue: { center: 350, range: 20, softness: 10 } },
+ correction: { saturation: 0.15 },
+ },
+ ],
+ },
+ ],
+ ["HF_STAGE_LUT", { lut: { src: "/looks/test.cube" } }],
+ ] as const)("keys %s to its own grade value and nothing else", (stage, grading) => {
+ const { video } = startRuntimeWithVideo();
+ runtime!.setGrading(`#${video.id}`, grading);
+
+ expect(stageNames(mainFragmentSources().at(-1) ?? "", "define")).toEqual([stage]);
+ });
+
+ it("can enable every stage the shader guards, and no stage it does not", () => {
+ const { video } = startRuntimeWithVideo();
+ runtime!.setGrading(`#${video.id}`, {
+ details: { grain: 0.5, vignette: 0.5 },
+ effects: Object.fromEntries(HF_COLOR_GRADING_EFFECT_KEYS.map((key) => [key, 0.5])),
+ curves: {
+ master: [
+ [0, 0],
+ [0.5, 0.6],
+ [1, 1],
+ ],
+ },
+ hueCurves: {
+ hueVsHue: [
+ [0, 0],
+ [120, 12],
+ [240, 0],
+ ],
+ },
+ secondaries: [
+ {
+ key: { hue: { center: 350, range: 20, softness: 10 } },
+ correction: { saturation: 0.15 },
+ },
+ ],
+ lut: { src: "/looks/test.cube" },
+ });
+ runtime!.setCompare(`#${video.id}`, { enabled: true });
+
+ const fragment = mainFragmentSources().at(-1) ?? "";
+ expect(stageNames(fragment, "ifdef")).not.toHaveLength(0);
+ expect(stageNames(fragment, "define")).toEqual(stageNames(fragment, "ifdef"));
+ });
+
+ it("compiles a variant once per stage set and reuses it when the grade returns", () => {
+ const { video } = startRuntimeWithVideo();
+ const compiled = () => mainFragmentSources().length;
+ const baseline = compiled();
+
+ runtime!.setGrading(`#${video.id}`, { effects: { halftone: 0.6 } });
+ expect(compiled()).toBe(baseline + 1);
+ runtime!.setGrading(`#${video.id}`, { adjust: { exposure: 0.1 } });
+ runtime!.setGrading(`#${video.id}`, { effects: { halftone: 0.2 } });
+ expect(compiled()).toBe(baseline + 1);
+ });
+
+ it("evicts the least recently used variant, not a variant still in use", () => {
+ const { video } = startRuntimeWithVideo();
+ const amounts = [
+ "blur",
+ "kuwahara",
+ "pixelate",
+ "tapeDamage",
+ "chromaBleed",
+ "chromaticAberration",
+ "crtCurvature",
+ "digitalGlitch",
+ "filmArtifacts",
+ "monoScreen",
+ "engraving",
+ "crosshatch",
+ "halftone",
+ "twoInkPrint",
+ "dither",
+ "ascii",
+ "bloom",
+ "scanlines",
+ ] as const;
+ const distinctGrades = [
+ ...amounts.map((key) => ({ effects: { [key]: 0.5 } })),
+ ...amounts
+ .slice(1)
+ .map((key, index) => ({ effects: { [amounts[index]!]: 0.5, [key]: 0.5 } })),
+ ];
+ const compiled = () => mainFragmentSources().length;
+ const adjustOnly = { adjust: { exposure: 0.5 } };
+
+ // The adjust-only variant plus 31 more fills the 32-entry cache.
+ for (const grading of distinctGrades.slice(0, 31)) runtime!.setGrading(`#${video.id}`, grading);
+ runtime!.setGrading(`#${video.id}`, adjustOnly);
+ const beforeEviction = compiled();
+ runtime!.setGrading(`#${video.id}`, distinctGrades[31]!);
+ runtime!.setGrading(`#${video.id}`, adjustOnly);
+
+ expect(compiled()).toBe(beforeEviction + 1);
+ });
+
+ it("selects the variant from the animated grade, not the authored one", () => {
+ const video = makeDrawableVideo();
+ video.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ effects: { blur: 0 } }));
+ video.style.setProperty("--hf-color-grading-blur", "0.45");
+ startRuntimeWithVideo(video);
+
+ expect(stageNames(mainFragmentSources().at(-1) ?? "", "define")).toEqual(["HF_STAGE_BLUR"]);
+ });
+
+ it("compiles each look preview with its own stages", async () => {
+ const video = makeDrawableVideo();
+ video.removeAttribute(HF_COLOR_GRADING_ATTR);
+ document.body.appendChild(video);
+ vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue("data:image/png;base64,x");
+ runtime = createColorGradingRuntime();
+
+ await runtime.renderPreviews("#hero-video", [
+ { id: "halftone", grading: { effects: { halftone: 0.5 } } },
+ ]);
+
+ expect(stageNames(mainFragmentSources().at(-1) ?? "", "define")).toEqual(["HF_STAGE_HALFTONE"]);
+ });
+
+ it("draws a variant the driver rejects through every stage, and stops retrying it", () => {
+ const { video } = startRuntimeWithVideo();
+ const gl = getContextSpy.mock.results[0]?.value as WebGLRenderingContext;
+ const rejected = "#define HF_STAGE_HALFTONE\n#ifdef GL_FRAGMENT_PRECISION_HIGH";
+ gl.getShaderParameter = vi.fn(() => !lastShaderSources.at(-1)?.startsWith(rejected));
+ const attempts = () => lastShaderSources.filter((source) => source.startsWith(rejected)).length;
+
+ runtime!.setGrading(`#${video.id}`, { effects: { halftone: 0.6 } });
+ const fallback = mainFragmentSources().at(-1) ?? "";
+ expect(stageNames(fallback, "define")).toEqual(stageNames(fallback, "ifdef"));
+ runtime!.setGrading(`#${video.id}`, { adjust: { exposure: 0.1 } });
+ runtime!.setGrading(`#${video.id}`, { effects: { halftone: 0.2 } });
+
+ expect(attempts()).toBe(1);
+ expect(runtime!.isGraded(video)).toBe(true);
+ });
+
+ it("stops compiling once even the every-stage variant is rejected", () => {
+ const { video } = startRuntimeWithVideo();
+ const gl = getContextSpy.mock.results[0]?.value as WebGLRenderingContext;
+ gl.getShaderParameter = vi.fn(() => false);
+ const compiled = () => mainFragmentSources().length;
+ const afterCreation = compiled();
+
+ runtime!.setGrading(`#${video.id}`, { effects: { halftone: 0.6 } });
+ runtime!.setGrading(`#${video.id}`, { effects: { halftone: 0.2 } });
+
+ expect(compiled()).toBe(afterCreation + 2);
+ });
+
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 } }));
diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts
index 2a11118ebe..02740790b0 100644
--- a/packages/core/src/runtime/colorGrading.ts
+++ b/packages/core/src/runtime/colorGrading.ts
@@ -73,12 +73,17 @@ interface VideoFrameCallbackHost {
type FloatUniformBinding = readonly [key: K, location: WebGLUniformLocation];
interface ProgramInfo {
- program: WebGLProgram;
texture: WebGLTexture;
lutTexture: WebGLTexture;
advancedTexture: WebGLTexture;
advancedSignature: string | null;
quad: WebGLBuffer;
+ shaders: Map;
+ failedStageDefines: Set;
+}
+
+interface MainShader {
+ program: WebGLProgram;
position: number;
source: WebGLUniformLocation | null;
blurSource: WebGLUniformLocation | null;
@@ -523,12 +528,16 @@ const FRAGMENT_SHADER = [
"}",
"vec4 samplePrepared(vec2 uv){",
" vec4 base = sampleSource(uv);",
+ "#ifdef HF_STAGE_BLUR",
" float blur = clamp(u_blur, 0.0, 1.0);",
" if (blur > 0.0 && u_blurReady > 0.5) base = mix(base, sampleBlur(uv), blur);",
+ "#endif",
+ "#ifdef HF_STAGE_KUWAHARA",
" float kuwahara = clamp(u_kuwahara, 0.0, 1.0);",
" if (kuwahara > 0.0 && u_kuwaharaReady > 0.5) {",
" base.rgb = mix(base.rgb, sampleKuwahara(uv), kuwahara);",
" }",
+ "#endif",
" return base;",
"}",
"float tapeTrackingBand(float y, float center, float width){",
@@ -539,11 +548,14 @@ const FRAGMENT_SHADER = [
"vec4 sampleMedia(vec2 uv){",
" float pixel = clamp(u_pixelate, 0.0, 1.0);",
" vec2 sampleUv = uv;",
+ "#ifdef HF_STAGE_PIXELATE",
" 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;",
" }",
+ "#endif",
+ "#ifdef HF_STAGE_TAPE",
" float tapeDamage = clamp(u_tapeDamage, 0.0, 1.0);",
" float tapeTracking = clamp(u_tapeTracking, 0.0, 1.0);",
" float tapeNoise = clamp(u_tapeNoise, 0.0, 1.0);",
@@ -560,9 +572,15 @@ const FRAGMENT_SHADER = [
" trackingShift += tapeTrackingBand(sampleUv.y, fract(0.89 + tapeTime * 0.029), 0.032) * 0.55;",
" trackingShift *= tapeTracking * 48.0;",
" float tapeShift = (lineJitter + slowWobble + headSwitch + trackingShift) * tapeDamage;",
+ "#endif",
" float texelX = 1.0 / max(u_resolution.x * max(u_uvScale.x, 0.00001), 1.0);",
+ "#ifdef HF_STAGE_TAPE",
" vec2 tapeUv = sampleUv + vec2(tapeShift * texelX, 0.0);",
+ "#else",
+ " vec2 tapeUv = sampleUv;",
+ "#endif",
" vec4 base = samplePrepared(tapeUv);",
+ "#ifdef HF_STAGE_TAPE",
" if (tapeDamage > 0.0) {",
" vec2 lumaStep = vec2(texelX * mix(1.0, 3.5, tapeDamage), 0.0);",
" vec3 leftTape = samplePrepared(tapeUv - lumaStep).rgb;",
@@ -578,6 +596,8 @@ const FRAGMENT_SHADER = [
" tapeColor += (fineNoise * 0.035 + dropout * 0.12) * tapeDamage * tapeNoise;",
" base.rgb = mix(base.rgb, clamp(tapeColor, 0.0, 1.0), tapeDamage);",
" }",
+ "#endif",
+ "#ifdef HF_STAGE_CHROMA_BLEED",
" float chromaBleed = clamp(u_chromaBleed, 0.0, 1.0);",
" if (chromaBleed > 0.0) {",
" float radius = mix(1.0, 4.0, chromaBleed);",
@@ -591,8 +611,10 @@ const FRAGMENT_SHADER = [
" vec3 blurredChroma = ((c0 - vec3(lumaOf(c0))) * 6.0 + (c1 - vec3(lumaOf(c1))) * 4.0 + (c2 - vec3(lumaOf(c2))) * 4.0 + (c3 - vec3(lumaOf(c3))) + (c4 - vec3(lumaOf(c4)))) / 16.0;",
" base.rgb = mix(base.rgb, clamp(vec3(centerLuma) + blurredChroma, 0.0, 1.0), chromaBleed);",
" }",
+ "#endif",
" return base;",
"}",
+ "#ifdef HF_STAGE_CHROMATIC",
"vec4 sampleChromaticMedia(vec2 uv, vec4 center){",
" float amount = clamp(u_chromaticAberration, 0.0, 1.0);",
" if (amount <= 0.0) return center;",
@@ -603,6 +625,9 @@ const FRAGMENT_SHADER = [
" vec3 split = vec3(positive.r, center.g, negative.b);",
" return vec4(split, center.a);",
"}",
+ "#else",
+ "vec4 sampleChromaticMedia(vec2 uv, vec4 center){ return center; }",
+ "#endif",
"vec3 sampleDigitalSplit(vec2 uv, vec2 block, float time, float amount){",
" float split = amount * 0.06;",
" float direction = digitalHash(block * 0.4 + vec2(floor(time * 3.7), floor(time * 5.3)));",
@@ -610,6 +635,7 @@ const FRAGMENT_SHADER = [
" vec4 center = sampleMedia(uv);",
" return vec3(sampleMedia(uv + axis * split).r, center.g, sampleMedia(uv - axis * split).b);",
"}",
+ "#ifdef HF_STAGE_GLITCH",
"vec3 applyDigitalGlitch(vec2 uv, vec3 source){",
" float amount = clamp(u_digitalGlitch, 0.0, 1.0);",
" if (amount <= 0.0) return source;",
@@ -678,6 +704,9 @@ const FRAGMENT_SHADER = [
" }",
" return mix(source, clamp(glitch, 0.0, 1.0), amount);",
"}",
+ "#else",
+ "vec3 applyDigitalGlitch(vec2 uv, vec3 source){ return source; }",
+ "#endif",
"float dustMask(vec2 uv, float frameBucket){",
" vec2 grid = vec2(128.0, 72.0);",
" vec2 cell = floor(uv * grid);",
@@ -706,6 +735,7 @@ const FRAGMENT_SHADER = [
" float shapeRadius = shape > 3.5 ? radius * 0.45 : radius;",
" return 1.0 - smoothstep(shapeRadius, shapeRadius + softness, distanceToInk);",
"}",
+ "#ifdef HF_STAGE_MONO_SCREEN",
"vec3 applyMonoScreen(vec3 source, float amount){",
" if (amount <= 0.0) return source;",
" float invert = step(0.5, u_monoScreenInvert);",
@@ -723,6 +753,10 @@ const FRAGMENT_SHADER = [
" vec3 paper = paletteColor(max(u_paletteSize - 1.0, 1.0));",
" return mix(source, mix(paper, ink, inkMask), amount);",
"}",
+ "#else",
+ "vec3 applyMonoScreen(vec3 source, float amount){ return source; }",
+ "#endif",
+ "#ifdef HF_STAGE_ENGRAVING",
"vec3 applyEngraving(vec3 source, float amount){",
" if (amount <= 0.0) return source;",
" float spacing = mix(3.0, 20.0, clamp(u_engravingSpacing, 0.0, 1.0));",
@@ -749,6 +783,9 @@ const FRAGMENT_SHADER = [
" vec3 paper = paletteColor(max(u_paletteSize - 1.0, 1.0));",
" return mix(source, mix(paper, ink, inkMask), amount);",
"}",
+ "#else",
+ "vec3 applyEngraving(vec3 source, float amount){ return source; }",
+ "#endif",
"float crosshatchLine(vec2 pixel, float angle, float spacing, float thickness, float seed){",
" vec2 alongAxis = vec2(cos(angle), sin(angle));",
" vec2 acrossAxis = vec2(-alongAxis.y, alongAxis.x);",
@@ -776,6 +813,7 @@ const FRAGMENT_SHADER = [
" float gy = -tl - 2.0 * tc - tr + bl + 2.0 * bc + br;",
" return length(vec2(gx, gy));",
"}",
+ "#ifdef HF_STAGE_CROSSHATCH",
"vec3 applyCrosshatch(vec2 uv, vec3 source, float amount){",
" if (amount <= 0.0) return source;",
" float spacing = mix(5.0, 30.0, clamp(u_crosshatchSpacing, 0.0, 1.0));",
@@ -800,6 +838,10 @@ const FRAGMENT_SHADER = [
" result = mix(result, ink, clamp(edge, 0.0, 1.0));",
" return mix(source, result, amount);",
"}",
+ "#else",
+ "vec3 applyCrosshatch(vec2 uv, vec3 source, float amount){ return source; }",
+ "#endif",
+ "#ifdef HF_STAGE_HALFTONE",
"vec3 applyHalftone(vec3 source, float amount){",
" if (amount <= 0.0) return source;",
" vec3 cmy = 1.0 - source;",
@@ -819,6 +861,10 @@ const FRAGMENT_SHADER = [
" printColor *= mix(vec3(1.0), vec3(0.035), blackInk * 0.92);",
" return mix(source, printColor, amount);",
"}",
+ "#else",
+ "vec3 applyHalftone(vec3 source, float amount){ return source; }",
+ "#endif",
+ "#ifdef HF_STAGE_TWO_INK_PRINT",
"vec3 applyTwoInkPrint(vec3 source, float amount){",
" if (amount <= 0.0) return source;",
" float sourceLuma = lumaOf(source);",
@@ -845,6 +891,9 @@ const FRAGMENT_SHADER = [
" printColor += overprint * redDot * tealDot;",
" return mix(source, clamp(printColor, 0.0, 1.0), amount);",
"}",
+ "#else",
+ "vec3 applyTwoInkPrint(vec3 source, float amount){ return source; }",
+ "#endif",
"vec3 paletteColor(float index){",
" if (index < 0.5) return u_palette0;",
" if (index < 1.5) return u_palette1;",
@@ -1029,6 +1078,7 @@ const FRAGMENT_SHADER = [
" color += u_highlightWheel.z * weights.z * 0.25;",
" return color;",
"}",
+ "#ifdef HF_STAGE_RGB_CURVES",
"vec3 applyRgbCurves(vec3 color){",
" if (u_rgbCurvesEnabled < 0.5) return color;",
" vec3 master = vec3(",
@@ -1042,7 +1092,11 @@ const FRAGMENT_SHADER = [
" sampleAdvancedCurve(master.b, 0.0).b",
" );",
"}",
+ "#else",
+ "vec3 applyRgbCurves(vec3 color){ return color; }",
+ "#endif",
"float decodeSigned(float value){ return (value * 255.0 - 128.0) / 127.0; }",
+ "#ifdef HF_STAGE_HUE_CURVES",
"vec3 applyHueCurves(vec3 color){",
" if (u_hueCurvesEnabled < 0.5) return color;",
" vec3 hsv = rgbToHsv(clamp(color, 0.0, 1.0));",
@@ -1054,6 +1108,9 @@ const FRAGMENT_SHADER = [
" shifted += vec3(originalLuma - lumaOf(shifted) + decodeSigned(curves.b));",
" return shifted;",
"}",
+ "#else",
+ "vec3 applyHueCurves(vec3 color){ return color; }",
+ "#endif",
"float softRangeMask(float value, float minimum, float maximum, float softness){",
" if (value < minimum) {",
" if (softness <= 0.0) return 0.0;",
@@ -1103,10 +1160,12 @@ const FRAGMENT_SHADER = [
" color = applyTonalWheels(color);",
" color = applyRgbCurves(color);",
" color = applyHueCurves(color);",
+ "#ifdef HF_STAGE_SECONDARIES",
" if (u_secondaryCount > 0.5) color = applySecondary(color, 0.0);",
" if (u_secondaryCount > 1.5) color = applySecondary(color, 1.0);",
" if (u_secondaryCount > 2.5) color = applySecondary(color, 2.0);",
" if (u_secondaryCount > 3.5) color = applySecondary(color, 3.0);",
+ "#endif",
" return color;",
"}",
"vec3 sampleLut(float r, float g, float b){",
@@ -1115,6 +1174,7 @@ const FRAGMENT_SHADER = [
" float y = (g + 0.5) / max(u_lutTextureSize.y, 1.0);",
" return texture2D(u_lut, vec2(x, y)).rgb;",
"}",
+ "#ifdef HF_STAGE_LUT",
"vec3 applyLut(vec3 color){",
" if (u_lutEnabled < 0.5) return color;",
" float size = max(u_lutSize, 2.0);",
@@ -1140,6 +1200,9 @@ const FRAGMENT_SHADER = [
" vec3 lutColor = mix(c0, c1, f.b);",
" return mix(color, lutColor, clamp(u_lutIntensity, 0.0, 1.0));",
"}",
+ "#else",
+ "vec3 applyLut(vec3 color){ return color; }",
+ "#endif",
"vec3 applyPrimaryGrade(vec3 color){",
" color *= pow(2.0, u_exposure);",
" float y = lumaOf(color);",
@@ -1167,6 +1230,7 @@ const FRAGMENT_SHADER = [
" color = applyAdvancedGrade(color);",
" return clamp(applyLut(clamp(color, 0.0, 1.0)), 0.0, 1.0);",
"}",
+ "#ifdef HF_STAGE_DITHER",
"vec3 applyDither(vec3 source, float amount){",
" if (amount <= 0.0) return source;",
" float scale = max(min(u_resolution.x, u_resolution.y) / 540.0, 0.25);",
@@ -1176,6 +1240,9 @@ const FRAGMENT_SHADER = [
" float index = floor(clamp(lumaOf(source) * (levels - 1.0) + bayer4(block), 0.0, levels - 1.0));",
" return mix(source, paletteColor(index), amount);",
"}",
+ "#else",
+ "vec3 applyDither(vec3 source, float amount){ return source; }",
+ "#endif",
"vec2 asciiEdgeDirection(vec2 uv, vec2 stepUv){",
" float topLeft = bt601Luma(sampleMedia(uv + vec2(-stepUv.x, -stepUv.y)).rgb);",
" float top = bt601Luma(sampleMedia(uv + vec2(0.0, -stepUv.y)).rgb);",
@@ -1195,6 +1262,7 @@ const FRAGMENT_SHADER = [
" vec2 centered = uv - 0.5;",
" return vec2(centered.x * cosine - centered.y * sine, centered.x * sine + centered.y * cosine) + 0.5;",
"}",
+ "#ifdef HF_STAGE_ASCII",
"vec3 applyAscii(vec3 source, float amount){",
" if (amount <= 0.0) return source;",
" float scale = max(min(u_resolution.x, u_resolution.y) / 540.0, 0.25);",
@@ -1221,6 +1289,10 @@ const FRAGMENT_SHADER = [
" vec3 asciiColor = mix(background, inkColor, ink);",
" return mix(source, asciiColor, amount);",
"}",
+ "#else",
+ "vec3 applyAscii(vec3 source, float amount){ return source; }",
+ "#endif",
+ "#ifdef HF_STAGE_SCANLINES",
"vec3 applyScanlines(vec3 source, float amount){",
" if (amount <= 0.0) return source;",
" float count = mix(50.0, 500.0, clamp(u_scanlineCount, 0.0, 1.0));",
@@ -1229,6 +1301,9 @@ const FRAGMENT_SHADER = [
" float line = mix(1.0 - wave, pow(1.0 - wave, 2.2), softness);",
" return source * (1.0 - line * amount);",
"}",
+ "#else",
+ "vec3 applyScanlines(vec3 source, float amount){ return source; }",
+ "#endif",
"void main(){",
" vec2 uv = (v_uv - u_uvOffset) / u_uvScale;",
" if (uv.x < 0.0 || uv.y < 0.0 || uv.x > 1.0 || uv.y > 1.0) {",
@@ -1237,13 +1312,16 @@ const FRAGMENT_SHADER = [
" }",
" vec4 originalSample = sampleSource(uv);",
" uv = applyCrtWarp(uv);",
+ "#ifdef HF_STAGE_CRT",
" vec2 displayEdge = smoothstep(vec2(0.0), vec2(0.006), uv) * (1.0 - smoothstep(vec2(0.994), vec2(1.0), uv));",
" float displayMask = displayEdge.x * displayEdge.y;",
+ "#endif",
" vec4 sampleColor = sampleMedia(uv);",
" sampleColor = sampleChromaticMedia(uv, sampleColor);",
" sampleColor.rgb = applyDigitalGlitch(uv, sampleColor.rgb);",
" vec3 original = originalSample.rgb;",
" vec3 color = mix(sampleColor.rgb, applyColorGrade(sampleColor.rgb), u_intensity);",
+ "#ifdef HF_STAGE_GRAIN",
" 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));",
@@ -1255,6 +1333,8 @@ const FRAGMENT_SHADER = [
" 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);",
" }",
+ "#endif",
+ "#ifdef HF_STAGE_FILM_ARTIFACTS",
" float filmArtifacts = clamp(u_filmArtifacts, 0.0, 1.0);",
" if (filmArtifacts > 0.0) {",
" float filmFrame = floor(u_grainSeed);",
@@ -1267,6 +1347,7 @@ const FRAGMENT_SHADER = [
" float scratch = (1.0 - smoothstep(0.0, 1.4 / max(u_resolution.x, 1.0), abs(v_uv.x - scratchX))) * scratchLife;",
" color = mix(color, vec3(0.94, 0.88, 0.76), scratch * 0.28 * filmArtifacts);",
" }",
+ "#endif",
" color = applyMonoScreen(clamp(color, 0.0, 1.0), clamp(u_monoScreen, 0.0, 1.0));",
" color = applyEngraving(clamp(color, 0.0, 1.0), clamp(u_engraving, 0.0, 1.0));",
" color = applyCrosshatch(uv, clamp(color, 0.0, 1.0), clamp(u_crosshatch, 0.0, 1.0));",
@@ -1274,8 +1355,11 @@ const FRAGMENT_SHADER = [
" color = applyTwoInkPrint(clamp(color, 0.0, 1.0), clamp(u_twoInkPrint, 0.0, 1.0));",
" color = applyDither(clamp(color, 0.0, 1.0), clamp(u_dither, 0.0, 1.0));",
" color = applyAscii(clamp(color, 0.0, 1.0), clamp(u_ascii, 0.0, 1.0));",
+ "#ifdef HF_STAGE_BLOOM",
" if (u_bloomReady > 0.5 && u_bloom > 0.0) color += sampleBloom(uv) * u_bloom;",
+ "#endif",
" color = applyScanlines(clamp(color, 0.0, 1.0), clamp(u_scanlines, 0.0, 1.0));",
+ "#ifdef HF_STAGE_VIGNETTE",
" 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));",
@@ -1286,9 +1370,13 @@ const FRAGMENT_SHADER = [
" 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;",
+ "#endif",
+ "#ifdef HF_STAGE_CRT",
" float warpActive = step(0.0001, u_crtCurvature);",
" color *= mix(1.0, displayMask, warpActive);",
+ "#endif",
" vec3 graded = clamp(color, 0.0, 1.0);",
+ "#ifdef HF_STAGE_COMPARE",
" if (u_compareEnabled > 0.5) {",
" float pos = clamp(u_comparePosition, 0.0, 1.0);",
" float softness = max(u_compareSoftness, 0.00001);",
@@ -1302,10 +1390,64 @@ const FRAGMENT_SHADER = [
" gl_FragColor = vec4(mix(splitColor, vec3(1.0), lineMask * 0.82), sampleColor.a);",
" return;",
" }",
+ "#endif",
" gl_FragColor = vec4(graded, sampleColor.a);",
"}",
].join("\n");
+type FragmentShaderStage = readonly [
+ define: string,
+ active: (grading: ResolvedHfColorGrading, compare: RuntimeColorGradingCompareState) => boolean,
+];
+
+/** Software GL pays for a stage even at 0, so each grade compiles only the stages it enables. */
+const FRAGMENT_SHADER_STAGES: readonly FragmentShaderStage[] = [
+ ["HF_STAGE_BLUR", (grading) => grading.effects.blur > 0],
+ ["HF_STAGE_KUWAHARA", (grading) => grading.effects.kuwahara > 0],
+ ["HF_STAGE_PIXELATE", (grading) => grading.effects.pixelate > 0],
+ ["HF_STAGE_TAPE", (grading) => grading.effects.tapeDamage > 0],
+ ["HF_STAGE_CHROMA_BLEED", (grading) => grading.effects.chromaBleed > 0],
+ ["HF_STAGE_CHROMATIC", (grading) => grading.effects.chromaticAberration > 0],
+ ["HF_STAGE_CRT", (grading) => grading.effects.crtCurvature > 0],
+ ["HF_STAGE_GLITCH", (grading) => grading.effects.digitalGlitch > 0],
+ ["HF_STAGE_GRAIN", (grading) => grading.details.grain > 0],
+ ["HF_STAGE_FILM_ARTIFACTS", (grading) => grading.effects.filmArtifacts > 0],
+ ["HF_STAGE_MONO_SCREEN", (grading) => grading.effects.monoScreen > 0],
+ ["HF_STAGE_ENGRAVING", (grading) => grading.effects.engraving > 0],
+ ["HF_STAGE_CROSSHATCH", (grading) => grading.effects.crosshatch > 0],
+ ["HF_STAGE_HALFTONE", (grading) => grading.effects.halftone > 0],
+ ["HF_STAGE_TWO_INK_PRINT", (grading) => grading.effects.twoInkPrint > 0],
+ ["HF_STAGE_DITHER", (grading) => grading.effects.dither > 0],
+ ["HF_STAGE_ASCII", (grading) => grading.effects.ascii > 0],
+ ["HF_STAGE_BLOOM", (grading) => grading.effects.bloom > 0],
+ ["HF_STAGE_SCANLINES", (grading) => grading.effects.scanlines > 0],
+ ["HF_STAGE_VIGNETTE", (grading) => grading.details.vignette > 0],
+ ["HF_STAGE_RGB_CURVES", (grading) => advancedGradeState(grading).rgbCurvesEnabled],
+ ["HF_STAGE_HUE_CURVES", (grading) => advancedGradeState(grading).hueCurvesEnabled],
+ ["HF_STAGE_SECONDARIES", (grading) => advancedGradeState(grading).secondaryCount > 0],
+ ["HF_STAGE_LUT", (grading) => grading.lut !== null],
+ ["HF_STAGE_COMPARE", (_grading, compare) => compare.enabled],
+];
+
+function fragmentShaderStageDefines(
+ grading: ResolvedHfColorGrading,
+ compare: RuntimeColorGradingCompareState,
+): string {
+ let defines = "";
+ for (const [define, active] of FRAGMENT_SHADER_STAGES) {
+ if (active(grading, compare)) defines += `#define ${define}\n`;
+ }
+ return defines;
+}
+
+const ALL_FRAGMENT_SHADER_STAGE_DEFINES = FRAGMENT_SHADER_STAGES.map(
+ ([define]) => `#define ${define}\n`,
+).join("");
+
+const MAX_PREVIEW_CANDIDATES = 32;
+
+const MAX_CACHED_MAIN_SHADERS = MAX_PREVIEW_CANDIDATES;
+
const BLUR_FRAGMENT_SHADER = [
"#ifdef GL_FRAGMENT_PRECISION_HIGH",
"precision highp float;",
@@ -1652,54 +1794,20 @@ function createFloatUniformBindings(
return bindings;
}
-function deleteProgramResources(
+function deleteTextures(
gl: WebGLRenderingContext,
- program: WebGLProgram | null,
textures: readonly (WebGLTexture | null)[],
): void {
- if (program) gl.deleteProgram(program);
for (const texture of textures) {
if (texture) gl.deleteTexture(texture);
}
}
-function createProgramInfo(canvas: HTMLCanvasElement): {
- gl: WebGLRenderingContext;
- program: ProgramInfo;
-} | null {
- const gl = canvas.getContext("webgl", {
- alpha: true,
- premultipliedAlpha: false,
- });
- if (!gl) return null;
- const program = createProgramResources(gl);
- return program ? { gl, program } : null;
-}
-
-function createProgramResources(gl: WebGLRenderingContext): ProgramInfo | null {
- const program = createProgram(gl);
- const texture = createTexture(gl);
- const lutTexture = createTexture(gl, gl.NEAREST);
- const advancedTexture = createTexture(gl, gl.NEAREST);
- if (!program || !texture || !lutTexture || !advancedTexture) {
- deleteProgramResources(gl, program, [texture, lutTexture, advancedTexture]);
- return null;
- }
- const quad = gl.createBuffer();
- if (!quad) {
- deleteProgramResources(gl, program, [texture, lutTexture, advancedTexture]);
- return null;
- }
- gl.bindBuffer(gl.ARRAY_BUFFER, quad);
- gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
-
+function createMainShader(gl: WebGLRenderingContext, stageDefines: string): MainShader | null {
+ const program = createProgram(gl, stageDefines + FRAGMENT_SHADER);
+ if (!program) return null;
return {
program,
- texture,
- lutTexture,
- advancedTexture,
- advancedSignature: null,
- quad,
position: gl.getAttribLocation(program, "a_pos"),
source: gl.getUniformLocation(program, "u_source"),
blurSource: gl.getUniformLocation(program, "u_blurSource"),
@@ -1745,6 +1853,95 @@ function createProgramResources(gl: WebGLRenderingContext): ProgramInfo | null {
};
}
+function createProgramInfo(canvas: HTMLCanvasElement): {
+ gl: WebGLRenderingContext;
+ program: ProgramInfo;
+} | null {
+ const gl = canvas.getContext("webgl", {
+ alpha: true,
+ premultipliedAlpha: false,
+ });
+ if (!gl) return null;
+ const program = createProgramResources(gl);
+ return program ? { gl, program } : null;
+}
+
+function createProgramResources(gl: WebGLRenderingContext): ProgramInfo | null {
+ const baseShader = createMainShader(gl, "");
+ if (!baseShader) return null;
+ const resources = createMainResources(gl);
+ if (!resources) {
+ gl.deleteProgram(baseShader.program);
+ return null;
+ }
+ return { ...resources, shaders: new Map([["", baseShader]]), failedStageDefines: new Set() };
+}
+
+function createMainResources(
+ gl: WebGLRenderingContext,
+): Omit | null {
+ const texture = createTexture(gl);
+ const lutTexture = createTexture(gl, gl.NEAREST);
+ const advancedTexture = createTexture(gl, gl.NEAREST);
+ if (!texture || !lutTexture || !advancedTexture) {
+ deleteTextures(gl, [texture, lutTexture, advancedTexture]);
+ return null;
+ }
+ const quad = gl.createBuffer();
+ if (!quad) {
+ deleteTextures(gl, [texture, lutTexture, advancedTexture]);
+ return null;
+ }
+ gl.bindBuffer(gl.ARRAY_BUFFER, quad);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
+ return { texture, lutTexture, advancedTexture, advancedSignature: null, quad };
+}
+
+function useMainShader(
+ gl: WebGLRenderingContext,
+ program: ProgramInfo,
+ grading: ResolvedHfColorGrading,
+ compare: RuntimeColorGradingCompareState,
+): MainShader {
+ const shader = mainShaderFor(gl, program, fragmentShaderStageDefines(grading, compare));
+ gl.useProgram(shader.program);
+ return shader;
+}
+
+function mainShaderFor(
+ gl: WebGLRenderingContext,
+ program: ProgramInfo,
+ requested: string,
+): MainShader {
+ const stageDefines = program.failedStageDefines.has(requested)
+ ? ALL_FRAGMENT_SHADER_STAGE_DEFINES
+ : requested;
+ if (program.failedStageDefines.has(stageDefines)) {
+ throw new Error("Color grading shader failed to compile");
+ }
+ const cached = program.shaders.get(stageDefines);
+ if (cached) {
+ // Re-inserting a hit keeps the Map in least-recently-used order for eviction.
+ program.shaders.delete(stageDefines);
+ program.shaders.set(stageDefines, cached);
+ return cached;
+ }
+ const compiled = createMainShader(gl, stageDefines);
+ if (!compiled) {
+ program.failedStageDefines.add(stageDefines);
+ return mainShaderFor(gl, program, ALL_FRAGMENT_SHADER_STAGE_DEFINES);
+ }
+ if (program.shaders.size >= MAX_CACHED_MAIN_SHADERS) {
+ for (const [key, evicted] of program.shaders) {
+ gl.deleteProgram(evicted.program);
+ program.shaders.delete(key);
+ break;
+ }
+ }
+ program.shaders.set(stageDefines, compiled);
+ return compiled;
+}
+
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -1949,7 +2146,8 @@ function destroyMainProgramResources(gl: WebGLRenderingContext, program: Program
gl.deleteTexture(program.lutTexture);
gl.deleteTexture(program.advancedTexture);
gl.deleteBuffer(program.quad);
- gl.deleteProgram(program.program);
+ for (const shader of program.shaders.values()) gl.deleteProgram(shader.program);
+ program.shaders.clear();
}
function replaceProgramResources(entry: ColorGradingEntry): boolean {
@@ -2878,10 +3076,26 @@ function setWheelUniform(
gl.uniform3f(location, wheel.hue / 360, wheel.amount, wheel.level);
}
+function advancedGradeState(grading: ResolvedHfColorGrading): {
+ rgbCurvesEnabled: boolean;
+ hueCurvesEnabled: boolean;
+ secondaryCount: number;
+} {
+ const { curves, hueCurves, secondaries } = grading;
+ return {
+ rgbCurvesEnabled: hasHfColorGradingRgbCurveValues(curves),
+ hueCurvesEnabled: hasHfColorGradingHueCurveValues(hueCurves),
+ secondaryCount: hasHfColorGradingSecondaryValues(secondaries)
+ ? secondaries.reduce((count, secondary) => count + Number(secondary.enabled), 0)
+ : 0,
+ };
+}
+
// fallow-ignore-next-line complexity
function applyUniforms(
gl: WebGLRenderingContext,
program: ProgramInfo,
+ shader: MainShader,
grading: ResolvedHfColorGrading,
lut: RuntimeLutTexture | null,
blurReady: boolean,
@@ -2893,58 +3107,53 @@ function applyUniforms(
grainSeed: number,
effectTime: number,
): void {
- gl.uniform1i(program.source, 0);
- gl.uniform1i(program.blurSource, 1);
- gl.uniform1i(program.lut, 2);
- gl.uniform1i(program.kuwaharaSource, 3);
- gl.uniform1i(program.bloomSource, 4);
- gl.uniform1i(program.advanced, 5);
- 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.bloomReady, bloomReady ? 1 : 0);
- gl.uniform1f(program.kuwaharaReady, kuwaharaReady ? 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);
+ gl.uniform1i(shader.source, 0);
+ gl.uniform1i(shader.blurSource, 1);
+ gl.uniform1i(shader.lut, 2);
+ gl.uniform1i(shader.kuwaharaSource, 3);
+ gl.uniform1i(shader.bloomSource, 4);
+ gl.uniform1i(shader.advanced, 5);
+ gl.uniform2f(shader.resolution, layout.width, layout.height);
+ gl.uniform2f(shader.uvScale, uv.scaleX, uv.scaleY);
+ gl.uniform2f(shader.uvOffset, uv.offsetX, uv.offsetY);
+ gl.uniform1f(shader.blurReady, blurReady ? 1 : 0);
+ gl.uniform1f(shader.bloomReady, bloomReady ? 1 : 0);
+ gl.uniform1f(shader.kuwaharaReady, kuwaharaReady ? 1 : 0);
+ gl.uniform1f(shader.lutEnabled, lut ? 1 : 0);
+ gl.uniform1f(shader.lutSize, lut?.size ?? 2);
+ gl.uniform2f(shader.lutTextureSize, lut?.textureWidth ?? 1, lut?.textureHeight ?? 1);
gl.uniform3f(
- program.lutDomainMin,
+ shader.lutDomainMin,
lut?.domainMin[0] ?? 0,
lut?.domainMin[1] ?? 0,
lut?.domainMin[2] ?? 0,
);
gl.uniform3f(
- program.lutDomainMax,
+ shader.lutDomainMax,
lut?.domainMax[0] ?? 1,
lut?.domainMax[1] ?? 1,
lut?.domainMax[2] ?? 1,
);
- gl.uniform1f(program.lutIntensity, grading.lut?.intensity ?? 0);
- const { curves, hueCurves, secondaries } = grading;
- const rgbCurvesEnabled = hasHfColorGradingRgbCurveValues(curves);
- const hueCurvesEnabled = hasHfColorGradingHueCurveValues(hueCurves);
- const secondaryCount = hasHfColorGradingSecondaryValues(secondaries)
- ? secondaries.reduce((count, secondary) => count + Number(secondary.enabled), 0)
- : 0;
+ gl.uniform1f(shader.lutIntensity, grading.lut?.intensity ?? 0);
+ const { rgbCurvesEnabled, hueCurvesEnabled, secondaryCount } = advancedGradeState(grading);
if (rgbCurvesEnabled || hueCurvesEnabled || secondaryCount > 0) {
- ensureAdvancedTexture(gl, program, grading, secondaries);
+ ensureAdvancedTexture(gl, program, grading, grading.secondaries);
}
- setWheelUniform(gl, program.shadowWheel, grading.wheels.shadows);
- setWheelUniform(gl, program.midtoneWheel, grading.wheels.midtones);
- setWheelUniform(gl, program.highlightWheel, grading.wheels.highlights);
- gl.uniform1f(program.rgbCurvesEnabled, rgbCurvesEnabled ? 1 : 0);
- gl.uniform1f(program.hueCurvesEnabled, hueCurvesEnabled ? 1 : 0);
- gl.uniform1f(program.secondaryCount, secondaryCount);
- for (const [key, location] of program.adjustUniforms) {
+ setWheelUniform(gl, shader.shadowWheel, grading.wheels.shadows);
+ setWheelUniform(gl, shader.midtoneWheel, grading.wheels.midtones);
+ setWheelUniform(gl, shader.highlightWheel, grading.wheels.highlights);
+ gl.uniform1f(shader.rgbCurvesEnabled, rgbCurvesEnabled ? 1 : 0);
+ gl.uniform1f(shader.hueCurvesEnabled, hueCurvesEnabled ? 1 : 0);
+ gl.uniform1f(shader.secondaryCount, secondaryCount);
+ for (const [key, location] of shader.adjustUniforms) {
gl.uniform1f(location, grading.adjust[key]);
}
- for (const [key, location] of program.detailUniforms) {
+ for (const [key, location] of shader.detailUniforms) {
gl.uniform1f(location, grading.details[key]);
}
- gl.uniform1f(program.grainSeed, grainSeed);
- gl.uniform1f(program.effectTime, effectTime);
- for (const [key, location] of program.effectUniforms) {
+ gl.uniform1f(shader.grainSeed, grainSeed);
+ gl.uniform1f(shader.effectTime, effectTime);
+ for (const [key, location] of shader.effectUniforms) {
gl.uniform1f(location, grading.effects[key]);
}
const palette =
@@ -2953,18 +3162,18 @@ function applyUniforms(
? DEFAULT_ART_PALETTE
: DEFAULT_EFFECT_PALETTE);
const lastColor = palette[palette.length - 1] ?? DEFAULT_EFFECT_PALETTE[1];
- gl.uniform1f(program.paletteSize, palette.length);
- setPaletteColorUniform(gl, program.palette0, palette[0] ?? DEFAULT_EFFECT_PALETTE[0]);
- setPaletteColorUniform(gl, program.palette1, palette[1] ?? lastColor);
- setPaletteColorUniform(gl, program.palette2, palette[2] ?? lastColor);
- setPaletteColorUniform(gl, program.palette3, palette[3] ?? lastColor);
- setPaletteColorUniform(gl, program.palette4, palette[4] ?? lastColor);
- setPaletteColorUniform(gl, program.palette5, palette[5] ?? lastColor);
- gl.uniform1f(program.intensity, grading.intensity);
- gl.uniform1f(program.compareEnabled, compare.enabled ? 1 : 0);
- gl.uniform1f(program.comparePosition, compare.position);
- gl.uniform1f(program.compareSoftness, compare.softness);
- gl.uniform1f(program.compareLineWidth, compare.lineWidth);
+ gl.uniform1f(shader.paletteSize, palette.length);
+ setPaletteColorUniform(gl, shader.palette0, palette[0] ?? DEFAULT_EFFECT_PALETTE[0]);
+ setPaletteColorUniform(gl, shader.palette1, palette[1] ?? lastColor);
+ setPaletteColorUniform(gl, shader.palette2, palette[2] ?? lastColor);
+ setPaletteColorUniform(gl, shader.palette3, palette[3] ?? lastColor);
+ setPaletteColorUniform(gl, shader.palette4, palette[4] ?? lastColor);
+ setPaletteColorUniform(gl, shader.palette5, palette[5] ?? lastColor);
+ gl.uniform1f(shader.intensity, grading.intensity);
+ gl.uniform1f(shader.compareEnabled, compare.enabled ? 1 : 0);
+ gl.uniform1f(shader.comparePosition, compare.position);
+ gl.uniform1f(shader.compareSoftness, compare.softness);
+ gl.uniform1f(shader.compareLineWidth, compare.lineWidth);
}
function hideSourceElement(entry: ColorGradingEntry): void {
@@ -3224,13 +3433,14 @@ function renderGradedFrame(
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, layout.width, layout.height);
- gl.useProgram(program.program);
+ const shader = useMainShader(gl, program, grading, entry.compare);
bindProgramTextures(gl, program, prepared);
const frameTime = canonicalFrameTime(entry.element);
const grainSeed = entry.grainSeed + Math.floor(frameTime * 60);
applyUniforms(
gl,
program,
+ shader,
grading,
lut,
prepared.blurReady,
@@ -3242,7 +3452,7 @@ function renderGradedFrame(
grainSeed,
frameTime,
);
- drawFullscreenQuad(gl, program);
+ drawFullscreenQuad(gl, { quad: program.quad, position: shader.position });
presentEntry(entry, layout);
}
@@ -3390,7 +3600,7 @@ async function renderPreviewBatch(
const { dimensions, uv, grainSeed, effectTime } = frame;
const images: RuntimeColorGradingPreviewBatch["images"] = [];
- for (const candidate of candidates.slice(0, 32)) {
+ for (const candidate of candidates.slice(0, MAX_PREVIEW_CANDIDATES)) {
const grading = normalizeHfColorGrading(candidate.grading);
if (!grading) {
images.push({ id: candidate.id, dataUrl: null, error: "Invalid grading" });
@@ -3442,12 +3652,13 @@ function renderPreviewCandidate(
});
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, dimensions.width, dimensions.height);
- gl.useProgram(program.program);
+ const shader = useMainShader(gl, program, grading, DEFAULT_COMPARE);
bindProgramTextures(gl, program, prepared);
if (!lut) renderer.lut = null;
applyUniforms(
gl,
program,
+ shader,
grading,
lut,
prepared.blurReady,
@@ -3459,7 +3670,7 @@ function renderPreviewCandidate(
grainSeed,
effectTime,
);
- drawFullscreenQuad(gl, program);
+ drawFullscreenQuad(gl, { quad: program.quad, position: shader.position });
return canvas.toDataURL("image/png");
}