mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-03 20:37:30 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) : [], []);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user