Merge pull request #1513 from heygen-com/feat/color-grading-core

feat(core): add color grading schema and lut parsing
This commit is contained in:
Ular Kimsanov
2026-06-16 15:10:11 -07:00
committed by GitHub
11 changed files with 894 additions and 0 deletions
+39
View File
@@ -78,6 +78,45 @@ The same pattern covers the three media element types:
Pass assets as URL references your composition resolves at render time; don't inline base64. URL-shaped assets travel cleanly through both the local renderer and the Lambda surface — see [Templates on Lambda](/deploy/templates-on-lambda#working-with-large-variables) for the 256 KiB execution-input cap on distributed renders.
### Parameterizing media color grading
Media color grading can also read exact variable references inside
`data-color-grading`. Use `$name` or `${name}` as the entire value for a field;
the runtime resolves it from the current composition's variables before applying
the shader grading:
```html compositions/hero.html
<html data-composition-variables='[
{"id":"gradingPreset","type":"enum","label":"Color grading preset","default":"warm-clean",
"options":[{"value":"warm-clean","label":"Warm Clean"},{"value":"cool-clean","label":"Cool Clean"}]},
{"id":"gradingIntensity","type":"number","label":"Color grading intensity","default":0.75,"min":0,"max":1,"step":0.05},
{"id":"gradingExposure","type":"number","label":"Exposure","default":0,"min":-2,"max":2,"step":0.05}
]'>
<body>
<div data-composition-id="hero" data-width="1920" data-height="1080">
<video
id="hero-video"
src="assets/hero.mp4"
data-start="0"
data-track-index="0"
muted
playsinline
data-color-grading='{
"preset":"$gradingPreset",
"intensity":"$gradingIntensity",
"adjust":{"exposure":"${gradingExposure}"},
"colorSpace":"rec709"
}'
></video>
</div>
</body>
</html>
```
When the same composition is embedded multiple times, each host's
`data-variable-values` can produce different grading without copying or rewriting
the media element's `data-color-grading` JSON.
### Swapping media: do you need to vary duration too?
A common follow-up: if a variable swaps a `<video>` to a different clip, does `data-duration` need to change too? Usually no. `data-duration` is optional on `<video>` and `<audio>` — leave it off and the renderer ffprobes the source and uses its natural length:
+16
View File
@@ -34,6 +34,14 @@
"import": "./src/compiler/index.ts",
"types": "./src/compiler/index.ts"
},
"./color-grading": {
"import": "./src/colorGrading.ts",
"types": "./src/colorGrading.ts"
},
"./color-luts": {
"import": "./src/colorLuts.ts",
"types": "./src/colorLuts.ts"
},
"./runtime": "./dist/hyperframe.runtime.iife.js",
"./runtime/clipTree": {
"import": "./src/runtime/clipTree.ts",
@@ -129,6 +137,14 @@
"import": "./dist/compiler/index.js",
"types": "./dist/compiler/index.d.ts"
},
"./color-grading": {
"import": "./dist/colorGrading.js",
"types": "./dist/colorGrading.d.ts"
},
"./color-luts": {
"import": "./dist/colorLuts.js",
"types": "./dist/colorLuts.d.ts"
},
"./runtime": "./dist/hyperframe.runtime.iife.js",
"./runtime/lottie-readiness": {
"import": "./dist/lottieReadiness.js",
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
HF_COLOR_GRADING_COLOR_SPACE,
isHfColorGradingActive,
normalizeHfColorGrading,
normalizeHfColorGradingWithVariables,
serializeHfColorGrading,
} from "./colorGrading";
describe("color grading", () => {
it("parses preset shorthand", () => {
const grading = normalizeHfColorGrading("warm-clean");
expect(grading?.preset).toBe("warm-clean");
expect(grading?.colorSpace).toBe(HF_COLOR_GRADING_COLOR_SPACE);
expect(grading?.adjust.temperature).toBeGreaterThan(0);
expect(isHfColorGradingActive(grading)).toBe(true);
});
it("merges manual adjustments over preset values", () => {
const grading = normalizeHfColorGrading({
preset: "warm-clean",
intensity: 0.5,
adjust: { temperature: -0.25, contrast: 0.2 },
});
expect(grading?.intensity).toBe(0.5);
expect(grading?.adjust.temperature).toBe(-0.25);
expect(grading?.adjust.contrast).toBe(0.2);
expect(grading?.adjust.saturation).toBeGreaterThan(0);
});
it("clamps values to supported shader ranges", () => {
const grading = normalizeHfColorGrading({
intensity: 2,
adjust: { exposure: 10, contrast: -5, saturation: 3 },
lut: { src: "looks/test.cube", intensity: 3 },
});
expect(grading?.intensity).toBe(1);
expect(grading?.adjust.exposure).toBe(2);
expect(grading?.adjust.contrast).toBe(-1);
expect(grading?.adjust.saturation).toBe(1);
expect(grading?.lut?.intensity).toBe(1);
});
it("returns null for disabled or invalid grading", () => {
expect(normalizeHfColorGrading({ enabled: false, preset: "warm-clean" })).toBeNull();
expect(normalizeHfColorGrading("{nope")).toBeNull();
expect(normalizeHfColorGrading("")).toBeNull();
});
it("serializes normalized grading for data-color-grading", () => {
const grading = normalizeHfColorGrading({ adjust: { exposure: 0.25 } });
const serialized = serializeHfColorGrading(grading);
expect(serialized).toContain('"exposure":0.25');
expect(normalizeHfColorGrading(serialized)?.adjust.exposure).toBe(0.25);
});
it("treats zero global intensity as inactive even with LUT data", () => {
const grading = normalizeHfColorGrading({
intensity: 0,
adjust: { exposure: 0.5 },
lut: { src: "assets/luts/test.cube", intensity: 1 },
});
expect(isHfColorGradingActive(grading)).toBe(false);
});
it("resolves exact variable references inside color grading JSON", () => {
const grading = normalizeHfColorGradingWithVariables(
JSON.stringify({
preset: "$preset",
intensity: "$gradingIntensity",
adjust: {
exposure: "${exposure}",
saturation: "$saturation",
},
lut: {
src: "$lutSrc",
intensity: "$lutIntensity",
},
}),
{
preset: "warm-clean",
gradingIntensity: 0.6,
exposure: 0.25,
saturation: -0.2,
lutSrc: "assets/luts/warm.cube",
lutIntensity: 0.4,
},
);
expect(grading?.preset).toBe("warm-clean");
expect(grading?.intensity).toBe(0.6);
expect(grading?.adjust.exposure).toBe(0.25);
expect(grading?.adjust.saturation).toBe(-0.2);
expect(grading?.lut).toEqual({ src: "assets/luts/warm.cube", intensity: 0.4 });
});
it("supports a whole grading supplied by one variable", () => {
const grading = normalizeHfColorGradingWithVariables("$colorGrade", {
colorGrade: {
adjust: { contrast: 0.2 },
lut: { src: "assets/luts/natural-boost.cube", intensity: 0.75 },
},
});
expect(grading?.adjust.contrast).toBe(0.2);
expect(grading?.lut).toEqual({ src: "assets/luts/natural-boost.cube", intensity: 0.75 });
});
});
+326
View File
@@ -0,0 +1,326 @@
export const HF_COLOR_GRADING_ATTR = "data-color-grading";
export const HF_COLOR_GRADING_COLOR_SPACE = "rec709";
export type HfColorGradingPresetId =
| "neutral"
| "warm-clean"
| "cool-clean"
| "soft-boost"
| "bright-pop"
| "deep-contrast";
export type HfColorGradingAdjustKey =
| "exposure"
| "contrast"
| "highlights"
| "shadows"
| "whites"
| "blacks"
| "temperature"
| "tint"
| "saturation";
export type HfColorGradingAdjust = Partial<Record<HfColorGradingAdjustKey, number>>;
export interface HfColorGradingLutRef {
src: string;
intensity?: number;
}
export interface HfColorGrading {
enabled?: boolean;
preset?: HfColorGradingPresetId | string | null;
intensity?: number;
adjust?: HfColorGradingAdjust;
lut?: HfColorGradingLutRef | string | null;
colorSpace?: typeof HF_COLOR_GRADING_COLOR_SPACE | string;
}
export interface NormalizedHfColorGrading {
enabled: boolean;
preset: HfColorGradingPresetId | string | null;
intensity: number;
adjust: Record<HfColorGradingAdjustKey, number>;
lut: HfColorGradingLutRef | null;
colorSpace: typeof HF_COLOR_GRADING_COLOR_SPACE | string;
}
export interface HfColorGradingTarget {
id?: string | null;
hfId?: string | null;
selector?: string | null;
selectorIndex?: number | null;
}
export interface HfColorGradingPreset {
id: HfColorGradingPresetId;
label: string;
adjust: Record<HfColorGradingAdjustKey, number>;
}
export type HfColorGradingVariableMap = Record<string, unknown>;
const ADJUST_ZERO: Record<HfColorGradingAdjustKey, number> = {
exposure: 0,
contrast: 0,
highlights: 0,
shadows: 0,
whites: 0,
blacks: 0,
temperature: 0,
tint: 0,
saturation: 0,
};
export const HF_COLOR_GRADING_ADJUST_KEYS: readonly HfColorGradingAdjustKey[] = [
"exposure",
"contrast",
"highlights",
"shadows",
"whites",
"blacks",
"temperature",
"tint",
"saturation",
];
export const HF_COLOR_GRADING_PRESETS: readonly HfColorGradingPreset[] = [
{
id: "neutral",
label: "Neutral",
adjust: { ...ADJUST_ZERO },
},
{
id: "warm-clean",
label: "Warm Clean",
adjust: {
...ADJUST_ZERO,
exposure: 0.05,
contrast: 0.08,
highlights: -0.08,
shadows: 0.08,
temperature: 0.16,
saturation: 0.06,
},
},
{
id: "cool-clean",
label: "Cool Clean",
adjust: {
...ADJUST_ZERO,
contrast: 0.06,
highlights: -0.06,
shadows: 0.06,
temperature: -0.12,
tint: 0.04,
saturation: 0.04,
},
},
{
id: "soft-boost",
label: "Soft Boost",
adjust: {
...ADJUST_ZERO,
exposure: 0.06,
contrast: -0.04,
highlights: -0.14,
shadows: 0.16,
saturation: 0.1,
},
},
{
id: "bright-pop",
label: "Bright Pop",
adjust: {
...ADJUST_ZERO,
exposure: 0.12,
contrast: 0.12,
whites: 0.08,
blacks: -0.04,
saturation: 0.14,
},
},
{
id: "deep-contrast",
label: "Deep Contrast",
adjust: {
...ADJUST_ZERO,
exposure: -0.03,
contrast: 0.2,
highlights: -0.08,
shadows: -0.08,
blacks: -0.12,
saturation: 0.06,
},
},
];
const PRESETS_BY_ID = new Map<string, HfColorGradingPreset>(
HF_COLOR_GRADING_PRESETS.map((preset) => [preset.id, preset]),
);
const VARIABLE_REF_RE = /^\$(?:\{([A-Za-z0-9_.:-]+)\}|([A-Za-z0-9_.:-]+))$/;
const ADJUST_LIMITS: Record<HfColorGradingAdjustKey, { min: number; max: number }> = {
exposure: { min: -2, max: 2 },
contrast: { min: -1, max: 1 },
highlights: { min: -1, max: 1 },
shadows: { min: -1, max: 1 },
whites: { min: -1, max: 1 },
blacks: { min: -1, max: 1 },
temperature: { min: -1, max: 1 },
tint: { min: -1, max: 1 },
saturation: { min: -1, max: 1 },
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(max, Math.max(min, value));
}
function clampUnit(value: unknown, fallback: number): number {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(1, Math.max(0, parsed));
}
function readAdjustValue(value: unknown, key: HfColorGradingAdjustKey): number {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(parsed)) return 0;
const limit = ADJUST_LIMITS[key];
return clamp(parsed, limit.min, limit.max);
}
function normalizePresetId(value: unknown): HfColorGradingPresetId | string | null {
if (value == null) return null;
const preset = String(value).trim();
return preset ? preset : null;
}
function normalizeLut(value: unknown): HfColorGradingLutRef | null {
if (value == null) return null;
if (typeof value === "string") {
const src = value.trim();
return src ? { src, intensity: 1 } : null;
}
if (!isRecord(value)) return null;
const rawSrc = value.src;
if (typeof rawSrc !== "string" || rawSrc.trim() === "") return null;
return {
src: rawSrc.trim(),
intensity: clampUnit(value.intensity, 1),
};
}
function readColorGradingObject(raw: unknown): Record<string, unknown> | null {
if (typeof raw === "string") {
const trimmed = raw.trim();
if (!trimmed) return null;
if (trimmed.startsWith("{")) {
try {
const parsed: unknown = JSON.parse(trimmed);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
return { preset: trimmed, intensity: 1 };
}
return isRecord(raw) ? raw : null;
}
function resolveStringVariableRef(value: string, variables: HfColorGradingVariableMap): unknown {
const match = value.trim().match(VARIABLE_REF_RE);
if (!match) return value;
const key = match[1] ?? match[2] ?? "";
return key && Object.hasOwn(variables, key) ? variables[key] : value;
}
export function resolveHfColorGradingVariables(
raw: unknown,
variables: HfColorGradingVariableMap,
): unknown {
if (typeof raw === "string") {
const direct = resolveStringVariableRef(raw, variables);
if (direct !== raw) return direct;
const trimmed = raw.trim();
if (!trimmed.startsWith("{")) return raw;
try {
return resolveHfColorGradingVariables(JSON.parse(trimmed) as unknown, variables);
} catch {
return raw;
}
}
if (!isRecord(raw)) return raw;
const resolved: Record<string, unknown> = {};
for (const [key, value] of Object.entries(raw)) {
resolved[key] = resolveHfColorGradingVariables(value, variables);
}
return resolved;
}
function getHfColorGradingPreset(id: string | null | undefined): HfColorGradingPreset | null {
if (!id) return null;
return PRESETS_BY_ID.get(id) ?? null;
}
export function normalizeHfColorGrading(raw: unknown): NormalizedHfColorGrading | null {
const grading = readColorGradingObject(raw);
if (!grading) return null;
if (grading.enabled === false) return null;
const presetId = normalizePresetId(grading.preset);
const preset = getHfColorGradingPreset(presetId);
const presetAdjust = preset?.adjust ?? ADJUST_ZERO;
const rawAdjust = isRecord(grading.adjust) ? grading.adjust : {};
const adjust = HF_COLOR_GRADING_ADJUST_KEYS.reduce<Record<HfColorGradingAdjustKey, number>>(
(result, key) => {
result[key] = readAdjustValue(rawAdjust[key] ?? presetAdjust[key], key);
return result;
},
{ ...ADJUST_ZERO },
);
return {
enabled: true,
preset: presetId,
intensity: clampUnit(grading.intensity, 1),
adjust,
lut: normalizeLut(grading.lut),
colorSpace:
typeof grading.colorSpace === "string" && grading.colorSpace.trim()
? grading.colorSpace.trim()
: HF_COLOR_GRADING_COLOR_SPACE,
};
}
export function normalizeHfColorGradingWithVariables(
raw: unknown,
variables: HfColorGradingVariableMap,
): NormalizedHfColorGrading | null {
return normalizeHfColorGrading(resolveHfColorGradingVariables(raw, variables));
}
export function serializeHfColorGrading(
grading: NormalizedHfColorGrading | HfColorGrading | null,
): string {
const normalized = normalizeHfColorGrading(grading);
if (!normalized) return "";
const { enabled: _enabled, ...serializable } = normalized;
return JSON.stringify(serializable);
}
export function isHfColorGradingActive(
grading: NormalizedHfColorGrading | null,
): grading is NormalizedHfColorGrading {
if (!grading?.enabled) return false;
if (grading.intensity === 0) return false;
if (grading.lut && grading.lut.intensity !== 0) return true;
return HF_COLOR_GRADING_ADJUST_KEYS.some((key) => Math.abs(grading.adjust[key]) > 0.0001);
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { CubeLutParseError, packCubeLutToRgba8, parseCubeLut } from "./colorLuts";
const IDENTITY_2 = `
# comment
TITLE "Identity 2"
DOMAIN_MIN 0 0 0
DOMAIN_MAX 1 1 1
LUT_3D_SIZE 2
0 0 0
1 0 0
0 1 0
1 1 0
0 0 1
1 0 1
0 1 1
1 1 1
`;
describe("cube LUT parsing", () => {
it("parses a 3D cube LUT with title, domain, and red-fastest data order", () => {
const lut = parseCubeLut(IDENTITY_2);
expect(lut.title).toBe("Identity 2");
expect(lut.size).toBe(2);
expect(lut.domainMin).toEqual([0, 0, 0]);
expect(lut.domainMax).toEqual([1, 1, 1]);
expect(Array.from(lut.data.slice(0, 12))).toEqual([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0]);
});
it("packs 3D LUT data into a WebGL1-friendly 2D RGBA texture", () => {
const packed = packCubeLutToRgba8(parseCubeLut(IDENTITY_2));
expect(packed.width).toBe(4);
expect(packed.height).toBe(2);
expect(Array.from(packed.data.slice(0, 16))).toEqual([
0, 0, 0, 255, 255, 0, 0, 255, 0, 0, 255, 255, 255, 0, 255, 255,
]);
});
it("rejects unsupported 1D cube LUTs", () => {
expect(() =>
parseCubeLut(`
LUT_1D_SIZE 2
0 0 0
1 1 1
`),
).toThrow("1D cube LUTs are not supported yet");
});
it("rejects mixed 1D and 3D cube LUTs", () => {
expect(() =>
parseCubeLut(`
LUT_1D_SIZE 2
LUT_3D_SIZE 2
0 0 0
1 0 0
0 1 0
1 1 0
0 0 1
1 0 1
0 1 1
1 1 1
`),
).toThrow("Mixed 1D and 3D cube LUTs are not supported yet");
});
it("rejects row count mismatches and oversize LUTs", () => {
expect(() =>
parseCubeLut(`
LUT_3D_SIZE 2
0 0 0
`),
).toThrow("Expected 8 LUT rows");
expect(() => parseCubeLut("LUT_3D_SIZE 65", { maxSize: 64 })).toThrow(
"LUT_3D_SIZE 65 exceeds max 64",
);
});
it("reports invalid numbers as cube parse errors", () => {
expect(() =>
parseCubeLut(`
LUT_3D_SIZE 2
nope 0 0
`),
).toThrow(CubeLutParseError);
});
});
+213
View File
@@ -0,0 +1,213 @@
export type CubeLutVec3 = readonly [number, number, number];
export interface CubeLut3D {
title: string | null;
size: number;
domainMin: CubeLutVec3;
domainMax: CubeLutVec3;
data: Float32Array;
}
export interface PackedCubeLut2D {
width: number;
height: number;
data: Uint8Array;
}
export interface ParseCubeLutOptions {
maxSize?: number;
}
export class CubeLutParseError extends Error {
readonly lineNumber: number | null;
constructor(message: string, lineNumber: number | null = null) {
super(lineNumber == null ? message : `${message} at line ${lineNumber}`);
this.name = "CubeLutParseError";
this.lineNumber = lineNumber;
}
}
const DEFAULT_DOMAIN_MIN: CubeLutVec3 = [0, 0, 0];
const DEFAULT_DOMAIN_MAX: CubeLutVec3 = [1, 1, 1];
const DEFAULT_MAX_SIZE = 64;
function stripComment(line: string): string {
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') inQuote = !inQuote;
if (char === "#" && !inQuote) return line.slice(0, i);
}
return line;
}
function parseFiniteNumber(value: string, lineNumber: number): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new CubeLutParseError(`Invalid number "${value}"`, lineNumber);
}
return parsed;
}
function parseVec3(parts: string[], keyword: string, lineNumber: number): CubeLutVec3 {
if (parts.length !== 3) {
throw new CubeLutParseError(`${keyword} expects three numbers`, lineNumber);
}
return [
parseFiniteNumber(parts[0]!, lineNumber),
parseFiniteNumber(parts[1]!, lineNumber),
parseFiniteNumber(parts[2]!, lineNumber),
];
}
function parseSize(value: string | undefined, keyword: string, lineNumber: number): number {
if (!value) throw new CubeLutParseError(`${keyword} expects a size`, lineNumber);
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 2) {
throw new CubeLutParseError(`${keyword} must be an integer greater than 1`, lineNumber);
}
return parsed;
}
function validateDomain(domainMin: CubeLutVec3, domainMax: CubeLutVec3): void {
if (
domainMax[0] <= domainMin[0] ||
domainMax[1] <= domainMin[1] ||
domainMax[2] <= domainMin[2]
) {
throw new CubeLutParseError("DOMAIN_MAX values must be greater than DOMAIN_MIN values");
}
}
function parseTitle(line: string): string | null {
const quoted = /^TITLE\s+"([^"]*)"\s*$/i.exec(line);
if (quoted) return quoted[1] ?? null;
const bare = /^TITLE\s+(.+)\s*$/i.exec(line);
return bare ? (bare[1] ?? "").trim() || null : null;
}
function isNumericDataLine(token: string): boolean {
return /^[+-]?(?:\d|\.\d)/.test(token);
}
// fallow-ignore-next-line complexity
export function parseCubeLut(input: string, options: ParseCubeLutOptions = {}): CubeLut3D {
const maxSize = options.maxSize ?? DEFAULT_MAX_SIZE;
let title: string | null = null;
let domainMin: CubeLutVec3 = DEFAULT_DOMAIN_MIN;
let domainMax: CubeLutVec3 = DEFAULT_DOMAIN_MAX;
let lut1dSize: number | null = null;
let lut3dSize: number | null = null;
const rows: number[] = [];
const lines = input.replace(/^\uFEFF/, "").split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const lineNumber = i + 1;
const line = stripComment(lines[i] ?? "").trim();
if (!line) continue;
const parts = line.split(/\s+/);
const keyword = (parts[0] ?? "").toUpperCase();
const rest = parts.slice(1);
if (keyword === "TITLE") {
title = parseTitle(line);
continue;
}
if (keyword === "DOMAIN_MIN") {
domainMin = parseVec3(rest, keyword, lineNumber);
continue;
}
if (keyword === "DOMAIN_MAX") {
domainMax = parseVec3(rest, keyword, lineNumber);
continue;
}
if (keyword === "LUT_1D_SIZE") {
lut1dSize = parseSize(rest[0], keyword, lineNumber);
continue;
}
if (keyword === "LUT_3D_SIZE") {
lut3dSize = parseSize(rest[0], keyword, lineNumber);
if (lut3dSize > maxSize) {
throw new CubeLutParseError(`LUT_3D_SIZE ${lut3dSize} exceeds max ${maxSize}`, lineNumber);
}
continue;
}
if (!isNumericDataLine(keyword)) {
if (keyword.startsWith("LUT_")) {
throw new CubeLutParseError(`Unsupported cube keyword ${keyword}`, lineNumber);
}
continue;
}
if (!lut3dSize) {
if (lut1dSize) {
throw new CubeLutParseError("1D cube LUTs are not supported yet", lineNumber);
}
throw new CubeLutParseError("LUT data appears before LUT_3D_SIZE", lineNumber);
}
if (parts.length !== 3) {
throw new CubeLutParseError("LUT data rows must contain three numbers", lineNumber);
}
rows.push(
parseFiniteNumber(parts[0]!, lineNumber),
parseFiniteNumber(parts[1]!, lineNumber),
parseFiniteNumber(parts[2]!, lineNumber),
);
}
if (lut1dSize && lut3dSize) {
throw new CubeLutParseError("Mixed 1D and 3D cube LUTs are not supported yet");
}
if (!lut3dSize) {
if (lut1dSize) throw new CubeLutParseError("1D cube LUTs are not supported yet");
throw new CubeLutParseError("Missing LUT_3D_SIZE");
}
validateDomain(domainMin, domainMax);
const expectedRows = lut3dSize * lut3dSize * lut3dSize;
if (rows.length !== expectedRows * 3) {
throw new CubeLutParseError(
`Expected ${expectedRows} LUT rows for size ${lut3dSize}, found ${rows.length / 3}`,
);
}
return {
title,
size: lut3dSize,
domainMin,
domainMax,
data: new Float32Array(rows),
};
}
function clampUnit(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(1, Math.max(0, value));
}
function toByte(value: number): number {
return Math.round(clampUnit(value) * 255);
}
export function packCubeLutToRgba8(lut: CubeLut3D): PackedCubeLut2D {
const size = lut.size;
const width = size * size;
const height = size;
const packed = new Uint8Array(width * height * 4);
for (let b = 0; b < size; b++) {
for (let g = 0; g < size; g++) {
for (let r = 0; r < size; r++) {
const lutIndex = ((b * size + g) * size + r) * 3;
const pixelIndex = (g * width + b * size + r) * 4;
packed[pixelIndex] = toByte(lut.data[lutIndex] ?? 0);
packed[pixelIndex + 1] = toByte(lut.data[lutIndex + 1] ?? 0);
packed[pixelIndex + 2] = toByte(lut.data[lutIndex + 2] ?? 0);
packed[pixelIndex + 3] = 255;
}
}
}
return { width, height, data: packed };
}
@@ -17,6 +17,29 @@ function makeTempProject(files: Record<string, string>): string {
return dir;
}
function makeColorGradingProject(lutSrc: string, files: Record<string, string> = {}): string {
return makeTempProject({
"index.html": `<!doctype html>
<html><body>
<div data-composition-id="root" data-width="320" data-height="180">
<video
id="clip"
src="clip.mp4"
data-color-grading='{"lut":{"src":"${lutSrc}","intensity":0.5}}'></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines.root = {}</script>
</body></html>`,
...files,
});
}
function readBundledColorGradingLutSrc(bundled: string): string | undefined {
const { document } = parseHTML(bundled);
const rawLook = document.getElementById("clip")?.getAttribute("data-color-grading") ?? "";
const parsed = JSON.parse(rawLook) as { lut?: { src?: string } };
return parsed.lut?.src;
}
// Mirror the repo convention (preview.test.ts): skip symlink cases on
// non-symlink-privileged Windows runners rather than crash the suite.
function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
@@ -921,6 +944,26 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain("margin: 0");
});
it("inlines cube LUT files referenced from data-color-grading", async () => {
const dir = makeColorGradingProject("assets/luts/identity.cube", {
"assets/luts/identity.cube": `LUT_3D_SIZE 2
0 0 0
1 0 0
0 1 0
1 1 0
0 0 1
1 0 1
0 1 1
1 1 1`,
});
const bundled = await bundleToSingleHtml(dir);
const lutSrc = readBundledColorGradingLutSrc(bundled);
expect(lutSrc).toMatch(/^data:text\/plain;base64,/);
expect(lutSrc).not.toContain("assets/luts/identity.cube");
});
it("resolves nested CSS @import chains", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
+38
View File
@@ -19,6 +19,7 @@ import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
import { readDeclaredDefaults } from "../runtime/getVariables";
import { inlineSubCompositions } from "./inlineSubCompositions";
import { isSafePath, resolveWithinProject } from "../safePath.js";
import { HF_COLOR_GRADING_ATTR } from "../colorGrading";
const DEFAULT_RUNTIME_SCRIPT_URL = "";
@@ -201,6 +202,7 @@ const INLINE_MIME: Record<string, string> = {
".svg": "image/svg+xml",
".json": "application/json",
".txt": "text/plain",
".cube": "text/plain",
".xml": "application/xml",
};
@@ -219,6 +221,33 @@ function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): stri
return appendSuffixToUrl(dataUrl, suffix);
}
// fallow-ignore-next-line complexity
function rewriteColorGradingLutWithInlinedAssets(value: string, projectDir: string): string {
if (!value.trim().startsWith("{")) return value;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return value;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return value;
const lut = Reflect.get(parsed, "lut");
if (typeof lut === "string") {
const inlined = maybeInlineRelativeAssetUrl(lut, projectDir);
if (!inlined) return value;
Reflect.set(parsed, "lut", inlined);
return JSON.stringify(parsed);
}
if (typeof lut !== "object" || lut === null || Array.isArray(lut)) return value;
const lutSrc = Reflect.get(lut, "src");
if (typeof lutSrc !== "string") return value;
const inlined = maybeInlineRelativeAssetUrl(lutSrc, projectDir);
if (!inlined) return value;
Reflect.set(lut, "src", inlined);
return JSON.stringify(parsed);
}
function rewriteSrcsetWithInlinedAssets(srcsetValue: string, projectDir: string): string {
if (!srcsetValue) return srcsetValue;
return srcsetValue
@@ -947,6 +976,15 @@ export async function bundleToSingleHtml(
rewriteCssUrlsWithInlinedAssets(el.getAttribute("style") || "", projectDir),
);
}
for (const el of [...document.querySelectorAll(`[${HF_COLOR_GRADING_ATTR}]`)]) {
const value = el.getAttribute(HF_COLOR_GRADING_ATTR);
if (value) {
el.setAttribute(
HF_COLOR_GRADING_ATTR,
rewriteColorGradingLutWithInlinedAssets(value, projectDir),
);
}
}
return document.toString();
}
+20
View File
@@ -132,6 +132,26 @@ export {
export { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "./compiler/assetPaths";
export { decodeUrlPathVariants } from "./utils/urlPath";
export { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "./media/gif";
export {
HF_COLOR_GRADING_ATTR,
HF_COLOR_GRADING_ADJUST_KEYS,
HF_COLOR_GRADING_COLOR_SPACE,
HF_COLOR_GRADING_PRESETS,
isHfColorGradingActive,
normalizeHfColorGrading,
normalizeHfColorGradingWithVariables,
resolveHfColorGradingVariables,
serializeHfColorGrading,
type HfColorGrading,
type HfColorGradingAdjust,
type HfColorGradingAdjustKey,
type HfColorGradingLutRef,
type HfColorGradingPreset,
type HfColorGradingPresetId,
type HfColorGradingTarget,
type HfColorGradingVariableMap,
type NormalizedHfColorGrading,
} from "./colorGrading";
// Inline scripts
export {
@@ -27,6 +27,7 @@ export const MIME_TYPES: Record<string, string> = {
".otf": "font/otf",
".txt": "text/plain",
".md": "text/markdown",
".cube": "text/plain; charset=utf-8",
};
export function getMimeType(path: string): string {
@@ -36,6 +36,7 @@ document.documentElement.style.setProperty("--accent", accent);
- Use `npx hyperframes render --variables '{"title":"Q4 Report"}'` or `--variables-file` for render-time overrides.
- Add `--strict-variables` in CI: turns undeclared keys, type mismatches, and enum values not in `options` into errors instead of warnings.
- Read values once during init, not on every animation tick — variables don't change mid-render.
- Media color grading can use exact variable references inside `data-color-grading` JSON. Use `$gradingPreset` or `${gradingIntensity}` as the whole field value; the runtime resolves it from the current composition's variables before applying the shader grading.
### Two JSON Shapes (Easy to Confuse)