fix(core): accept 65-point .cube LUTs, the size grading apps export (#6921)

* fix(core): accept 65-point .cube LUTs, the size grading apps export

* fix(core): shrink LUTs wider than the GPU texture limit instead of rendering black
This commit is contained in:
Miguel Ángel
2026-11-01 20:46:08 +00:00
committed by GitHub
parent 5540f49121
commit 9284e69fff
10 changed files with 165 additions and 18 deletions
+1 -1
View File
@@ -93,7 +93,7 @@ puts skin tones on one known line.
## LUTs
A LUT (a `.cube` file, up to 64 points per side) maps every color to another, blended in at the strength you set.
A LUT (a `.cube` file, up to 65 points per side) maps every color to another, blended in at the strength you set.
Use creative Rec.709 LUTs. HyperFrames doesn't convert camera LOG footage first, so a LOG clip needs its camera's own
transform or comes out wrong.
+1 -1
View File
@@ -447,7 +447,7 @@ export async function prepareGradeCompareTempProject(opts: {
}
const lutText = readFileSync(sourcePath, "utf-8");
try {
parseCubeLut(lutText, { maxSize: 64 });
parseCubeLut(lutText);
} catch (err) {
throw new Error(
`LUT for "${cell.label}" is not a valid .cube: ${normalizeErrorMessage(err)}`,
@@ -7,7 +7,7 @@
import { readFileSync } from "node:fs";
import { isMainModule } from "./main-module.mjs";
export const DEFAULT_MAX_CUBE_LUT_SIZE = 64;
export const DEFAULT_MAX_CUBE_LUT_SIZE = 65;
const DEFAULT_DOMAIN_MIN = [0, 0, 0];
const DEFAULT_DOMAIN_MAX = [1, 1, 1];
@@ -28,9 +28,14 @@ test("accepts a valid minimal 3D cube LUT", () => {
});
test("rejects oversize LUTs with the core parser message", () => {
const result = validateCube("LUT_3D_SIZE 65", { maxSize: 64 });
const result = validateCube("LUT_3D_SIZE 66");
assert.equal(result.ok, false);
assert.match(result.error, /LUT_3D_SIZE 65 exceeds max 64/);
assert.match(result.error, /LUT_3D_SIZE 66 exceeds max 65/);
});
test("accepts the 65-point cube that grading tools export", () => {
const rows = Array.from({ length: 65 ** 3 }, () => "0 0 0").join("\n");
assert.deepEqual(validateCube(`LUT_3D_SIZE 65\n${rows}`), { ok: true, size: 65 });
});
test("rejects data rows before LUT_3D_SIZE", () => {
@@ -96,7 +101,7 @@ test("CLI exits zero for valid files and non-zero for invalid files", () => {
const valid = join(dir, "valid.cube");
const invalid = join(dir, "invalid.cube");
writeFileSync(valid, IDENTITY_2);
writeFileSync(invalid, "LUT_3D_SIZE 65");
writeFileSync(invalid, "LUT_3D_SIZE 66");
const out = execFileSync(
process.execPath,
@@ -117,7 +122,7 @@ test("CLI exits zero for valid files and non-zero for invalid files", () => {
stdio: "pipe",
},
),
(err) => err.status === 1 && String(err.stderr).includes("exceeds max 64"),
(err) => err.status === 1 && String(err.stderr).includes("exceeds max 65"),
);
} finally {
rmSync(dir, { recursive: true, force: true });
+1 -1
View File
@@ -1314,7 +1314,7 @@ test("--from rejects invalid lut cube without registering or leaving a frozen fi
assert.equal(proc.status, 1, proc.stderr);
const parsed = JSON.parse(proc.stdout);
assert.equal(parsed.ok, false);
assert.match(parsed.error, /^ingested LUT is invalid: LUT_3D_SIZE 999 exceeds max 64/);
assert.match(parsed.error, /^ingested LUT is invalid: LUT_3D_SIZE 999 exceeds max 65/);
assert.equal(readManifest(tmp).length, 0);
const lutDir = join(tmp, ".media/luts");
assert.deepEqual(existsSync(lutDir) ? readdirSync(lutDir) : [], []);
+1 -1
View File
@@ -155,7 +155,7 @@ describe("color grading", () => {
expect(capabilities.palettes.map(({ id }) => id)).toContain("handheld-green");
expect(capabilities.animatable.map(({ path }) => path)).toContain("effects.kuwahara");
expect(capabilities.palette).toEqual({ minColors: 2, maxColors: 6, colorFormat: "#rrggbb" });
expect(capabilities.lut).toMatchObject({ format: "3d-cube", maxCubeSize: 64 });
expect(capabilities.lut).toMatchObject({ format: "3d-cube", maxCubeSize: 65 });
expect(capabilities.effects.find(({ key }) => key === "ascii")).toMatchObject({
supportsPalette: true,
renderLane: "single-pass",
+79 -4
View File
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { CubeLutParseError, packCubeLutToRgba8, parseCubeLut } from "./colorLuts";
import {
CubeLutParseError,
fitCubeLutToTexture,
packCubeLutToRgba8,
parseCubeLut,
type CubeLut3D,
} from "./colorLuts";
const IDENTITY_2 = `
# comment
@@ -102,9 +108,12 @@ describe("cube LUT parsing", () => {
`),
).toThrow("Expected 8 LUT rows");
expect(() => parseCubeLut("LUT_3D_SIZE 65", { maxSize: 64 })).toThrow(
"LUT_3D_SIZE 65 exceeds max 64",
);
expect(() => parseCubeLut("LUT_3D_SIZE 66")).toThrow("LUT_3D_SIZE 66 exceeds max 65");
});
it("accepts the 65-point cube that grading tools export", () => {
const rows = Array.from({ length: 65 ** 3 }, () => "0 0 0").join("\n");
expect(parseCubeLut(`LUT_3D_SIZE 65\n${rows}`).size).toBe(65);
});
it("reports invalid numbers as cube parse errors", () => {
@@ -116,3 +125,69 @@ describe("cube LUT parsing", () => {
).toThrow(CubeLutParseError);
});
});
function identityCube(size: number): CubeLut3D {
const data = new Float32Array(size ** 3 * 3);
for (let b = 0; b < size; b++)
for (let g = 0; g < size; g++)
for (let r = 0; r < size; r++) {
const at = ((b * size + g) * size + r) * 3;
data.set([r / (size - 1), g / (size - 1), b / (size - 1)], at);
}
return { title: null, size, domainMin: [0, 0, 0], domainMax: [1, 1, 1], data };
}
describe("fitCubeLutToTexture", () => {
it("resamples a cube whose packed row is wider than the texture limit, keeping an identity identity", () => {
const fitted = fitCubeLutToTexture(identityCube(65), 4096);
expect(fitted.size).toBe(64);
expect(packCubeLutToRgba8(fitted).width).toBeLessThanOrEqual(4096);
const expected = identityCube(64).data;
const worst = expected.reduce((m, v, i) => Math.max(m, Math.abs(v - fitted.data[i]!)), 0);
expect(worst).toBeLessThan(1e-5);
});
it("keeps the domain and title of the cube it resamples", () => {
const cube = {
...identityCube(5),
title: "warm",
domainMin: [0, 0, 0.1],
domainMax: [1, 2, 3],
};
const fitted = fitCubeLutToTexture(cube as CubeLut3D, 16);
expect(fitted).toMatchObject({
size: 4,
title: "warm",
domainMin: [0, 0, 0.1],
domainMax: [1, 2, 3],
});
});
it("interpolates between the source points rather than dropping some", () => {
const cube = identityCube(5);
[0, 0, 1, 1, 1].forEach((red, r) => {
cube.data[r * 3] = red;
});
const fitted = fitCubeLutToTexture(cube, 16);
const alongRed = [0, 1, 2, 3].map((r) => fitted.data[r * 3]!);
expect(alongRed[0]).toBeCloseTo(0, 5);
expect(alongRed[1]).toBeCloseTo(1 / 3, 5);
expect(alongRed[2]).toBeCloseTo(1, 5);
expect(alongRed[3]).toBeCloseTo(1, 5);
});
it.each([
["a cube that already fits", 64, 4096],
["a texture limit it could not read", 65, Number.NaN],
["a texture limit of zero", 65, 0],
])("returns the same cube for %s", (_label, size, limit) => {
const cube = identityCube(size);
expect(fitCubeLutToTexture(cube, limit)).toBe(cube);
});
});
+36 -1
View File
@@ -30,7 +30,7 @@ export class CubeLutParseError extends Error {
const DEFAULT_DOMAIN_MIN: CubeLutVec3 = [0, 0, 0];
const DEFAULT_DOMAIN_MAX: CubeLutVec3 = [1, 1, 1];
export const DEFAULT_MAX_CUBE_LUT_SIZE = 64;
export const DEFAULT_MAX_CUBE_LUT_SIZE = 65;
function stripComment(line: string): string {
let inQuote = false;
@@ -224,3 +224,38 @@ export function packCubeLutToRgba8(lut: CubeLut3D): PackedCubeLut2D {
return { width, height, data: packed };
}
export function fitCubeLutToTexture(lut: CubeLut3D, maxTextureSize: number): CubeLut3D {
if (!(maxTextureSize >= 4) || lut.size * lut.size <= maxTextureSize) return lut;
const size = Math.max(2, Math.floor(Math.sqrt(maxTextureSize)));
const data = new Float32Array(size * size * size * 3);
const last = lut.size - 1;
const at = (r: number, g: number, b: number, c: number): number =>
lut.data[((b * lut.size + g) * lut.size + r) * 3 + c] ?? 0;
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
for (let b = 0; b < size; b++) {
for (let g = 0; g < size; g++) {
for (let r = 0; r < size; r++) {
const pr = (r / (size - 1)) * last;
const pg = (g / (size - 1)) * last;
const pb = (b / (size - 1)) * last;
const r0 = Math.floor(pr);
const g0 = Math.floor(pg);
const b0 = Math.floor(pb);
const r1 = Math.min(last, r0 + 1);
const g1 = Math.min(last, g0 + 1);
const b1 = Math.min(last, b0 + 1);
for (let c = 0; c < 3; c++) {
const plane = (bi: number): number =>
lerp(
lerp(at(r0, g0, bi, c), at(r1, g0, bi, c), pr - r0),
lerp(at(r0, g1, bi, c), at(r1, g1, bi, c), pr - r0),
pg - g0,
);
data[((b * size + g) * size + r) * 3 + c] = lerp(plane(b0), plane(b1), pb - b0);
}
}
}
}
return { ...lut, size, data };
}
@@ -32,11 +32,20 @@ LUT_3D_SIZE 2
1 1 1
`;
const IDENTITY_3 = `
LUT_3D_SIZE 3
${Array.from({ length: 27 }, (_, i) => {
const [r, g, b] = [i % 3, Math.floor(i / 3) % 3, Math.floor(i / 9)];
return `${r / 2} ${g / 2} ${b / 2}`;
}).join("\n")}
`;
function createMockWebGl(
options: {
failMediaUpload?: boolean;
halfFloatSupported?: boolean;
drawingBuffer?: { width: number; height: number };
maxTextureSize?: number;
} = {},
): WebGLRenderingContext {
const shader = {};
@@ -58,6 +67,8 @@ function createMockWebGl(
TEXTURE_MIN_FILTER: 0x2801,
TEXTURE_MAG_FILTER: 0x2800,
CLAMP_TO_EDGE: 0x812f,
MAX_TEXTURE_SIZE: 0x0d33,
getParameter: vi.fn(() => options.maxTextureSize ?? 16384),
LINEAR: 0x2601,
NEAREST: 0x2600,
RGBA: 0x1908,
@@ -568,6 +579,24 @@ describe("createColorGradingRuntime", () => {
toDataUrl.mockRestore();
});
it("shrinks a LUT whose packed width exceeds the GPU texture limit", async () => {
getContextSpy.mockImplementation((type: string) =>
type === "webgl" ? createMockWebGl({ maxTextureSize: 4 }) : mock2d(),
);
const video = makeDrawableVideo();
video.removeAttribute(HF_COLOR_GRADING_ATTR);
document.body.appendChild(video);
stubCubeLutFetch(IDENTITY_3);
runtime = createColorGradingRuntime();
await runtime.renderPreviews("#hero-video", [
{ id: "lut", grading: { lut: { src: "/looks/test.cube", intensity: 1 } } },
]);
const lutUpload = texImage2DCalls.filter((args) => args.length === 9).at(-1);
expect(lutUpload?.slice(3, 5)).toEqual([4, 2]);
});
it("serializes preview batches while a LUT is loading", async () => {
const video = makeDrawableVideo();
video.removeAttribute(HF_COLOR_GRADING_ATTR);
+7 -4
View File
@@ -34,6 +34,7 @@ import {
} from "../colorGradingCurves";
import {
DEFAULT_MAX_CUBE_LUT_SIZE,
fitCubeLutToTexture,
packCubeLutToRgba8,
parseCubeLut,
unitFloatToByte,
@@ -2057,13 +2058,14 @@ function uploadEntryLut(
lut: CubeLut3D,
): RuntimeLutTexture | null {
if (entry.lut?.src === src) return entry.lut;
const packed = packCubeLutToRgba8(lut);
const { gl, program } = entry;
const fitted = fitCubeLutToTexture(lut, gl.getParameter(gl.MAX_TEXTURE_SIZE));
const packed = packCubeLutToRgba8(fitted);
try {
uploadLutTexture(gl, program.lutTexture, packed);
entry.lut = {
src,
size: lut.size,
size: fitted.size,
domainMin: lut.domainMin,
domainMax: lut.domainMax,
textureWidth: packed.width,
@@ -2114,12 +2116,13 @@ function uploadPreviewLut(
src: string,
lut: CubeLut3D,
): RuntimeLutTexture {
const packed = packCubeLutToRgba8(lut);
const { gl, program } = renderer;
const fitted = fitCubeLutToTexture(lut, gl.getParameter(gl.MAX_TEXTURE_SIZE));
const packed = packCubeLutToRgba8(fitted);
uploadLutTexture(gl, program.lutTexture, packed);
return {
src,
size: lut.size,
size: fitted.size,
domainMin: lut.domainMin,
domainMax: lut.domainMax,
textureWidth: packed.width,