mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 12:08:50 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user