From 9284e69fff2af4928d41cc376f36d5cfbd789bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 1 Nov 2026 20:46:08 +0000 Subject: [PATCH] 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 --- docs/guides/media-effects.mdx | 2 +- packages/cli/src/commands/grade-compare.ts | 2 +- .../cli/src/media-use/lib/cube-validate.mjs | 2 +- .../src/media-use/lib/cube-validate.test.mjs | 13 ++- packages/cli/src/media-use/resolve.test.mjs | 2 +- packages/core/src/colorGrading.test.ts | 2 +- packages/core/src/colorLuts.test.ts | 83 ++++++++++++++++++- packages/core/src/colorLuts.ts | 37 ++++++++- .../core/src/runtime/colorGrading.test.ts | 29 +++++++ packages/core/src/runtime/colorGrading.ts | 11 ++- 10 files changed, 165 insertions(+), 18 deletions(-) diff --git a/docs/guides/media-effects.mdx b/docs/guides/media-effects.mdx index c77e9a9dae..55d8540d71 100644 --- a/docs/guides/media-effects.mdx +++ b/docs/guides/media-effects.mdx @@ -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. diff --git a/packages/cli/src/commands/grade-compare.ts b/packages/cli/src/commands/grade-compare.ts index 258b7d67c8..f6a70bbc33 100644 --- a/packages/cli/src/commands/grade-compare.ts +++ b/packages/cli/src/commands/grade-compare.ts @@ -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)}`, diff --git a/packages/cli/src/media-use/lib/cube-validate.mjs b/packages/cli/src/media-use/lib/cube-validate.mjs index d8ca4c0654..100ab6315b 100644 --- a/packages/cli/src/media-use/lib/cube-validate.mjs +++ b/packages/cli/src/media-use/lib/cube-validate.mjs @@ -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]; diff --git a/packages/cli/src/media-use/lib/cube-validate.test.mjs b/packages/cli/src/media-use/lib/cube-validate.test.mjs index 2465bfbcde..107a1a3132 100644 --- a/packages/cli/src/media-use/lib/cube-validate.test.mjs +++ b/packages/cli/src/media-use/lib/cube-validate.test.mjs @@ -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 }); diff --git a/packages/cli/src/media-use/resolve.test.mjs b/packages/cli/src/media-use/resolve.test.mjs index 685e1f3842..14557d7746 100644 --- a/packages/cli/src/media-use/resolve.test.mjs +++ b/packages/cli/src/media-use/resolve.test.mjs @@ -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) : [], []); diff --git a/packages/core/src/colorGrading.test.ts b/packages/core/src/colorGrading.test.ts index 08e0435850..11382d7eca 100644 --- a/packages/core/src/colorGrading.test.ts +++ b/packages/core/src/colorGrading.test.ts @@ -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", diff --git a/packages/core/src/colorLuts.test.ts b/packages/core/src/colorLuts.test.ts index 6d1c2f9d06..e24cc08299 100644 --- a/packages/core/src/colorLuts.test.ts +++ b/packages/core/src/colorLuts.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/colorLuts.ts b/packages/core/src/colorLuts.ts index 6f5bd4cf95..6f2ea57c98 100644 --- a/packages/core/src/colorLuts.ts +++ b/packages/core/src/colorLuts.ts @@ -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 }; +} diff --git a/packages/core/src/runtime/colorGrading.test.ts b/packages/core/src/runtime/colorGrading.test.ts index a097bff37f..6d9348ff23 100644 --- a/packages/core/src/runtime/colorGrading.test.ts +++ b/packages/core/src/runtime/colorGrading.test.ts @@ -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); diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts index a3b10e2f31..b86052ff58 100644 --- a/packages/core/src/runtime/colorGrading.ts +++ b/packages/core/src/runtime/colorGrading.ts @@ -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,