diff --git a/docs/concepts/variables.mdx b/docs/concepts/variables.mdx
index 042a56c62d..c217aaef1c 100644
--- a/docs/concepts/variables.mdx
+++ b/docs/concepts/variables.mdx
@@ -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
+
+
+
+
+
+
+
+```
+
+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 `` to a different clip, does `data-duration` need to change too? Usually no. `data-duration` is optional on `` and `` — leave it off and the renderer ffprobes the source and uses its natural length:
diff --git a/packages/core/package.json b/packages/core/package.json
index f7377675ef..3da3bc27f7 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -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",
diff --git a/packages/core/src/colorGrading.test.ts b/packages/core/src/colorGrading.test.ts
new file mode 100644
index 0000000000..42ac1a6b75
--- /dev/null
+++ b/packages/core/src/colorGrading.test.ts
@@ -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 });
+ });
+});
diff --git a/packages/core/src/colorGrading.ts b/packages/core/src/colorGrading.ts
new file mode 100644
index 0000000000..b923f19de0
--- /dev/null
+++ b/packages/core/src/colorGrading.ts
@@ -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>;
+
+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;
+ 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;
+}
+
+export type HfColorGradingVariableMap = Record;
+
+const ADJUST_ZERO: Record = {
+ 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(
+ 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 = {
+ 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 {
+ 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 | 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 = {};
+ 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>(
+ (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);
+}
diff --git a/packages/core/src/colorLuts.test.ts b/packages/core/src/colorLuts.test.ts
new file mode 100644
index 0000000000..67fa5a257d
--- /dev/null
+++ b/packages/core/src/colorLuts.test.ts
@@ -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);
+ });
+});
diff --git a/packages/core/src/colorLuts.ts b/packages/core/src/colorLuts.ts
new file mode 100644
index 0000000000..f63c0c3bc1
--- /dev/null
+++ b/packages/core/src/colorLuts.ts
@@ -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 };
+}
diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts
index 235b8a61ae..d41dfbd1c4 100644
--- a/packages/core/src/compiler/htmlBundler.test.ts
+++ b/packages/core/src/compiler/htmlBundler.test.ts
@@ -17,6 +17,29 @@ function makeTempProject(files: Record): string {
return dir;
}
+function makeColorGradingProject(lutSrc: string, files: Record = {}): string {
+ return makeTempProject({
+ "index.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": `
diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index 739ac392d3..538f0632ff 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -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 = {
".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();
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 5cdd54805a..8f38c7e052 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -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 {
diff --git a/packages/core/src/studio-api/helpers/mime.ts b/packages/core/src/studio-api/helpers/mime.ts
index b89d5595ce..a8b59beca8 100644
--- a/packages/core/src/studio-api/helpers/mime.ts
+++ b/packages/core/src/studio-api/helpers/mime.ts
@@ -27,6 +27,7 @@ export const MIME_TYPES: Record = {
".otf": "font/otf",
".txt": "text/plain",
".md": "text/markdown",
+ ".cube": "text/plain; charset=utf-8",
};
export function getMimeType(path: string): string {
diff --git a/skills/hyperframes-core/references/variables-and-media.md b/skills/hyperframes-core/references/variables-and-media.md
index 93aff1b127..d1105bb41d 100644
--- a/skills/hyperframes-core/references/variables-and-media.md
+++ b/skills/hyperframes-core/references/variables-and-media.md
@@ -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)