mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 20:18:35 +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.
|
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?
|
### 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:
|
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",
|
"import": "./src/compiler/index.ts",
|
||||||
"types": "./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": "./dist/hyperframe.runtime.iife.js",
|
||||||
"./runtime/clipTree": {
|
"./runtime/clipTree": {
|
||||||
"import": "./src/runtime/clipTree.ts",
|
"import": "./src/runtime/clipTree.ts",
|
||||||
@@ -129,6 +137,14 @@
|
|||||||
"import": "./dist/compiler/index.js",
|
"import": "./dist/compiler/index.js",
|
||||||
"types": "./dist/compiler/index.d.ts"
|
"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": "./dist/hyperframe.runtime.iife.js",
|
||||||
"./runtime/lottie-readiness": {
|
"./runtime/lottie-readiness": {
|
||||||
"import": "./dist/lottieReadiness.js",
|
"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;
|
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
|
// Mirror the repo convention (preview.test.ts): skip symlink cases on
|
||||||
// non-symlink-privileged Windows runners rather than crash the suite.
|
// non-symlink-privileged Windows runners rather than crash the suite.
|
||||||
function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
|
function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
|
||||||
@@ -921,6 +944,26 @@ describe("bundleToSingleHtml", () => {
|
|||||||
expect(bundled).toContain("margin: 0");
|
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 () => {
|
it("resolves nested CSS @import chains", async () => {
|
||||||
const dir = makeTempProject({
|
const dir = makeTempProject({
|
||||||
"index.html": `<!doctype html>
|
"index.html": `<!doctype html>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
|
|||||||
import { readDeclaredDefaults } from "../runtime/getVariables";
|
import { readDeclaredDefaults } from "../runtime/getVariables";
|
||||||
import { inlineSubCompositions } from "./inlineSubCompositions";
|
import { inlineSubCompositions } from "./inlineSubCompositions";
|
||||||
import { isSafePath, resolveWithinProject } from "../safePath.js";
|
import { isSafePath, resolveWithinProject } from "../safePath.js";
|
||||||
|
import { HF_COLOR_GRADING_ATTR } from "../colorGrading";
|
||||||
|
|
||||||
const DEFAULT_RUNTIME_SCRIPT_URL = "";
|
const DEFAULT_RUNTIME_SCRIPT_URL = "";
|
||||||
|
|
||||||
@@ -201,6 +202,7 @@ const INLINE_MIME: Record<string, string> = {
|
|||||||
".svg": "image/svg+xml",
|
".svg": "image/svg+xml",
|
||||||
".json": "application/json",
|
".json": "application/json",
|
||||||
".txt": "text/plain",
|
".txt": "text/plain",
|
||||||
|
".cube": "text/plain",
|
||||||
".xml": "application/xml",
|
".xml": "application/xml",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -219,6 +221,33 @@ function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): stri
|
|||||||
return appendSuffixToUrl(dataUrl, suffix);
|
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 {
|
function rewriteSrcsetWithInlinedAssets(srcsetValue: string, projectDir: string): string {
|
||||||
if (!srcsetValue) return srcsetValue;
|
if (!srcsetValue) return srcsetValue;
|
||||||
return srcsetValue
|
return srcsetValue
|
||||||
@@ -947,6 +976,15 @@ export async function bundleToSingleHtml(
|
|||||||
rewriteCssUrlsWithInlinedAssets(el.getAttribute("style") || "", projectDir),
|
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();
|
return document.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,26 @@ export {
|
|||||||
export { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "./compiler/assetPaths";
|
export { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "./compiler/assetPaths";
|
||||||
export { decodeUrlPathVariants } from "./utils/urlPath";
|
export { decodeUrlPathVariants } from "./utils/urlPath";
|
||||||
export { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "./media/gif";
|
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
|
// Inline scripts
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const MIME_TYPES: Record<string, string> = {
|
|||||||
".otf": "font/otf",
|
".otf": "font/otf",
|
||||||
".txt": "text/plain",
|
".txt": "text/plain",
|
||||||
".md": "text/markdown",
|
".md": "text/markdown",
|
||||||
|
".cube": "text/plain; charset=utf-8",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getMimeType(path: string): string {
|
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.
|
- 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.
|
- 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.
|
- 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)
|
### Two JSON Shapes (Easy to Confuse)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user