perf(core): a color grade compiles only the effects it turns on (#5889)

This commit is contained in:
Miguel Ángel
2026-10-19 17:42:12 +00:00
committed by GitHub
parent 563869f0f5
commit 44bbc5885f
3 changed files with 725 additions and 94 deletions
@@ -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 = `<!doctype html><body style="margin:0">
<img id="media" style="display:block;width:96px;height:64px" data-color-grading='{"adjust":{"contrast":0.2}}'>
<script>
// Records the main shader's sources, and compiles window.extraStage into each when set.
window.mainSources = [];
const shaderSource = WebGLRenderingContext.prototype.shaderSource;
WebGLRenderingContext.prototype.shaderSource = function (shader, source) {
if (source.includes("#ifdef HF_STAGE_")) {
if (window.extraStage) source = "#define " + window.extraStage + "\\n" + source;
window.mainSources.push(source);
}
return shaderSource.call(this, shader, source);
};
const pattern = document.createElement("canvas");
pattern.width = 96;
pattern.height = 64;
const ctx = pattern.getContext("2d");
const ramp = ctx.createLinearGradient(0, 0, 96, 64);
ramp.addColorStop(0, "#0b1d3a");
ramp.addColorStop(0.5, "#d9822b");
ramp.addColorStop(1, "#f2f0e6");
ctx.fillStyle = ramp;
ctx.fillRect(0, 0, 96, 64);
ctx.fillStyle = "#2fbf71";
ctx.fillRect(10, 8, 30, 20);
ctx.fillStyle = "#c62f6a";
ctx.beginPath();
ctx.arc(64, 40, 16, 0, Math.PI * 2);
ctx.fill();
document.getElementById("media").src = pattern.toDataURL("image/png");
</script>
<script>${RUNTIME}</script>
</body>`;
// 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<string, Awaited<ReturnType<typeof draw>>> = {};
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<HTMLCanvasElement>(
"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);
});
+243 -1
View File
@@ -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 } }));
+304 -93
View File
@@ -73,12 +73,17 @@ interface VideoFrameCallbackHost {
type FloatUniformBinding<K extends string> = readonly [key: K, location: WebGLUniformLocation];
interface ProgramInfo {
program: WebGLProgram;
texture: WebGLTexture;
lutTexture: WebGLTexture;
advancedTexture: WebGLTexture;
advancedSignature: string | null;
quad: WebGLBuffer;
shaders: Map<string, MainShader>;
failedStageDefines: Set<string>;
}
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<K extends string>(
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<ProgramInfo, "shaders" | "failedStageDefines"> | 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<string, unknown> {
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");
}