mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(core): add media color grading contract
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
HF_COLOR_GRADING_COLOR_SPACE,
|
||||
HF_COLOR_GRADING_PRESETS,
|
||||
isHfColorGradingActive,
|
||||
normalizeHfColorGrading,
|
||||
normalizeHfColorGradingWithVariables,
|
||||
@@ -16,6 +17,14 @@ describe("color grading", () => {
|
||||
expect(isHfColorGradingActive(grading)).toBe(true);
|
||||
});
|
||||
|
||||
it("includes consumer-friendly filter presets", () => {
|
||||
expect(HF_COLOR_GRADING_PRESETS.some((preset) => preset.id === "fresh-pop")).toBe(true);
|
||||
expect(normalizeHfColorGrading("mono-clean")?.adjust.saturation).toBe(-1);
|
||||
expect(normalizeHfColorGrading("vintage-wash")?.details.vignette).toBeGreaterThan(0);
|
||||
expect(normalizeHfColorGrading("food-pop")?.adjust.saturation).toBeGreaterThan(0);
|
||||
expect(normalizeHfColorGrading("food-pop")?.adjust.vibrance).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("merges manual adjustments over preset values", () => {
|
||||
const grading = normalizeHfColorGrading({
|
||||
preset: "warm-clean",
|
||||
@@ -31,13 +40,33 @@ describe("color grading", () => {
|
||||
it("clamps values to supported shader ranges", () => {
|
||||
const grading = normalizeHfColorGrading({
|
||||
intensity: 2,
|
||||
adjust: { exposure: 10, contrast: -5, saturation: 3 },
|
||||
adjust: { exposure: 10, contrast: -5, vibrance: 3, saturation: 3 },
|
||||
details: {
|
||||
vignette: 2,
|
||||
vignetteMidpoint: -1,
|
||||
vignetteRoundness: 2,
|
||||
vignetteFeather: 2,
|
||||
grain: -1,
|
||||
grainSize: 2,
|
||||
grainRoughness: -1,
|
||||
},
|
||||
effects: { blur: 2, pixelate: 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.vibrance).toBe(1);
|
||||
expect(grading?.adjust.saturation).toBe(1);
|
||||
expect(grading?.details.vignette).toBe(1);
|
||||
expect(grading?.details.vignetteMidpoint).toBe(0);
|
||||
expect(grading?.details.vignetteRoundness).toBe(1);
|
||||
expect(grading?.details.vignetteFeather).toBe(1);
|
||||
expect(grading?.details.grain).toBe(0);
|
||||
expect(grading?.details.grainSize).toBe(1);
|
||||
expect(grading?.details.grainRoughness).toBe(0);
|
||||
expect(grading?.effects.blur).toBe(1);
|
||||
expect(grading?.effects.pixelate).toBe(1);
|
||||
expect(grading?.lut?.intensity).toBe(1);
|
||||
});
|
||||
|
||||
@@ -48,10 +77,23 @@ describe("color grading", () => {
|
||||
});
|
||||
|
||||
it("serializes normalized grading for data-color-grading", () => {
|
||||
const grading = normalizeHfColorGrading({ adjust: { exposure: 0.25 } });
|
||||
const grading = normalizeHfColorGrading({
|
||||
adjust: { exposure: 0.25 },
|
||||
details: { vignette: 0.3, grain: 0.1 },
|
||||
effects: { blur: 0.2, pixelate: 0.4 },
|
||||
lut: { src: "assets/luts/test.cube", intensity: 0.6 },
|
||||
});
|
||||
const serialized = serializeHfColorGrading(grading);
|
||||
expect(serialized).toContain('"exposure":0.25');
|
||||
expect(serialized).toContain('"vignette":0.3');
|
||||
expect(serialized).toContain('"grain":0.1');
|
||||
expect(serialized).toContain('"blur":0.2');
|
||||
expect(serialized).toContain('"pixelate":0.4');
|
||||
expect(serialized).toContain('"src":"assets/luts/test.cube"');
|
||||
expect(normalizeHfColorGrading(serialized)?.adjust.exposure).toBe(0.25);
|
||||
expect(normalizeHfColorGrading(serialized)?.details.vignette).toBe(0.3);
|
||||
expect(normalizeHfColorGrading(serialized)?.effects.blur).toBe(0.2);
|
||||
expect(normalizeHfColorGrading(serialized)?.lut?.intensity).toBe(0.6);
|
||||
});
|
||||
|
||||
it("treats zero global intensity as inactive even with LUT data", () => {
|
||||
@@ -63,6 +105,23 @@ describe("color grading", () => {
|
||||
expect(isHfColorGradingActive(grading)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats finishing details as active grading", () => {
|
||||
const grading = normalizeHfColorGrading({ details: { vignette: 0.2 } });
|
||||
expect(isHfColorGradingActive(grading)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not activate grading for advanced finishing defaults alone", () => {
|
||||
const grading = normalizeHfColorGrading({
|
||||
details: { vignetteMidpoint: 0.2, grainSize: 0.8 },
|
||||
});
|
||||
expect(isHfColorGradingActive(grading)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats media effects as active grading", () => {
|
||||
const grading = normalizeHfColorGrading({ effects: { blur: 0.2 } });
|
||||
expect(isHfColorGradingActive(grading)).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves exact variable references inside color grading JSON", () => {
|
||||
const grading = normalizeHfColorGradingWithVariables(
|
||||
JSON.stringify({
|
||||
@@ -70,8 +129,14 @@ describe("color grading", () => {
|
||||
intensity: "$gradingIntensity",
|
||||
adjust: {
|
||||
exposure: "${exposure}",
|
||||
vibrance: "$vibrance",
|
||||
saturation: "$saturation",
|
||||
},
|
||||
details: {
|
||||
vignette: "$vignette",
|
||||
grainSize: "$grainSize",
|
||||
},
|
||||
effects: { pixelate: "$pixelate" },
|
||||
lut: {
|
||||
src: "$lutSrc",
|
||||
intensity: "$lutIntensity",
|
||||
@@ -81,7 +146,11 @@ describe("color grading", () => {
|
||||
preset: "warm-clean",
|
||||
gradingIntensity: 0.6,
|
||||
exposure: 0.25,
|
||||
vibrance: 0.3,
|
||||
saturation: -0.2,
|
||||
vignette: 0.15,
|
||||
grainSize: 0.4,
|
||||
pixelate: 0.1,
|
||||
lutSrc: "assets/luts/warm.cube",
|
||||
lutIntensity: 0.4,
|
||||
},
|
||||
@@ -90,7 +159,11 @@ describe("color grading", () => {
|
||||
expect(grading?.preset).toBe("warm-clean");
|
||||
expect(grading?.intensity).toBe(0.6);
|
||||
expect(grading?.adjust.exposure).toBe(0.25);
|
||||
expect(grading?.adjust.vibrance).toBe(0.3);
|
||||
expect(grading?.adjust.saturation).toBe(-0.2);
|
||||
expect(grading?.details.vignette).toBe(0.15);
|
||||
expect(grading?.details.grainSize).toBe(0.4);
|
||||
expect(grading?.effects.pixelate).toBe(0.1);
|
||||
expect(grading?.lut).toEqual({ src: "assets/luts/warm.cube", intensity: 0.4 });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
export const HF_COLOR_GRADING_ATTR = "data-color-grading";
|
||||
|
||||
export const HF_COLOR_GRADING_CANVAS_ID_PREFIX = "__hf_color_grading_";
|
||||
|
||||
export const HF_COLOR_GRADING_COLOR_SPACE = "rec709";
|
||||
|
||||
export type HfColorGradingPresetId =
|
||||
| "neutral"
|
||||
| "natural-lift"
|
||||
| "fresh-pop"
|
||||
| "warm-daylight"
|
||||
| "clean-studio"
|
||||
| "skin-soft"
|
||||
| "food-pop"
|
||||
| "night-lift"
|
||||
| "muted-editorial"
|
||||
| "vintage-wash"
|
||||
| "mono-clean"
|
||||
| "mono-fade"
|
||||
| "warm-clean"
|
||||
| "cool-clean"
|
||||
| "soft-boost"
|
||||
@@ -19,10 +32,26 @@ export type HfColorGradingAdjustKey =
|
||||
| "blacks"
|
||||
| "temperature"
|
||||
| "tint"
|
||||
| "vibrance"
|
||||
| "saturation";
|
||||
|
||||
export type HfColorGradingAdjust = Partial<Record<HfColorGradingAdjustKey, number>>;
|
||||
|
||||
export type HfColorGradingDetailKey =
|
||||
| "vignette"
|
||||
| "vignetteMidpoint"
|
||||
| "vignetteRoundness"
|
||||
| "vignetteFeather"
|
||||
| "grain"
|
||||
| "grainSize"
|
||||
| "grainRoughness";
|
||||
|
||||
export type HfColorGradingDetails = Partial<Record<HfColorGradingDetailKey, number>>;
|
||||
|
||||
export type HfColorGradingEffectKey = "blur" | "pixelate";
|
||||
|
||||
export type HfColorGradingEffects = Partial<Record<HfColorGradingEffectKey, number>>;
|
||||
|
||||
export interface HfColorGradingLutRef {
|
||||
src: string;
|
||||
intensity?: number;
|
||||
@@ -33,6 +62,8 @@ export interface HfColorGrading {
|
||||
preset?: HfColorGradingPresetId | string | null;
|
||||
intensity?: number;
|
||||
adjust?: HfColorGradingAdjust;
|
||||
details?: HfColorGradingDetails;
|
||||
effects?: HfColorGradingEffects;
|
||||
lut?: HfColorGradingLutRef | string | null;
|
||||
colorSpace?: typeof HF_COLOR_GRADING_COLOR_SPACE | string;
|
||||
}
|
||||
@@ -42,6 +73,8 @@ export interface NormalizedHfColorGrading {
|
||||
preset: HfColorGradingPresetId | string | null;
|
||||
intensity: number;
|
||||
adjust: Record<HfColorGradingAdjustKey, number>;
|
||||
details: Record<HfColorGradingDetailKey, number>;
|
||||
effects: Record<HfColorGradingEffectKey, number>;
|
||||
lut: HfColorGradingLutRef | null;
|
||||
colorSpace: typeof HF_COLOR_GRADING_COLOR_SPACE | string;
|
||||
}
|
||||
@@ -57,6 +90,8 @@ export interface HfColorGradingPreset {
|
||||
id: HfColorGradingPresetId;
|
||||
label: string;
|
||||
adjust: Record<HfColorGradingAdjustKey, number>;
|
||||
details: Record<HfColorGradingDetailKey, number>;
|
||||
effects: Record<HfColorGradingEffectKey, number>;
|
||||
}
|
||||
|
||||
export type HfColorGradingVariableMap = Record<string, unknown>;
|
||||
@@ -70,90 +105,216 @@ const ADJUST_ZERO: Record<HfColorGradingAdjustKey, number> = {
|
||||
blacks: 0,
|
||||
temperature: 0,
|
||||
tint: 0,
|
||||
vibrance: 0,
|
||||
saturation: 0,
|
||||
};
|
||||
|
||||
export const HF_COLOR_GRADING_ADJUST_KEYS: readonly HfColorGradingAdjustKey[] = [
|
||||
"exposure",
|
||||
"contrast",
|
||||
"highlights",
|
||||
"shadows",
|
||||
"whites",
|
||||
"blacks",
|
||||
"temperature",
|
||||
"tint",
|
||||
"saturation",
|
||||
];
|
||||
// Detail sub-controls keep identity-state defaults so enabling vignette/grain starts from useful
|
||||
// perceptual settings instead of raw mathematical zeroes.
|
||||
const DETAIL_ZERO: Record<HfColorGradingDetailKey, number> = {
|
||||
vignette: 0,
|
||||
vignetteMidpoint: 0.5,
|
||||
vignetteRoundness: 0,
|
||||
vignetteFeather: 0.65,
|
||||
grain: 0,
|
||||
grainSize: 0.25,
|
||||
grainRoughness: 0.5,
|
||||
};
|
||||
|
||||
const EFFECT_ZERO: Record<HfColorGradingEffectKey, number> = {
|
||||
blur: 0,
|
||||
pixelate: 0,
|
||||
};
|
||||
|
||||
export const HF_COLOR_GRADING_ADJUST_KEYS = Object.keys(
|
||||
ADJUST_ZERO,
|
||||
) as readonly HfColorGradingAdjustKey[];
|
||||
|
||||
export const HF_COLOR_GRADING_DETAIL_KEYS = Object.keys(
|
||||
DETAIL_ZERO,
|
||||
) as readonly HfColorGradingDetailKey[];
|
||||
|
||||
export const HF_COLOR_GRADING_EFFECT_KEYS = Object.keys(
|
||||
EFFECT_ZERO,
|
||||
) as readonly HfColorGradingEffectKey[];
|
||||
|
||||
function preset(
|
||||
id: HfColorGradingPresetId,
|
||||
label: string,
|
||||
adjust: HfColorGradingAdjust = {},
|
||||
details: HfColorGradingDetails = {},
|
||||
): HfColorGradingPreset {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
adjust: { ...ADJUST_ZERO, ...adjust },
|
||||
details: { ...DETAIL_ZERO, ...details },
|
||||
effects: { ...EFFECT_ZERO },
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
preset("neutral", "Neutral"),
|
||||
preset("natural-lift", "Natural Lift", {
|
||||
exposure: 0.04,
|
||||
contrast: 0.06,
|
||||
highlights: -0.06,
|
||||
shadows: 0.08,
|
||||
saturation: 0.05,
|
||||
}),
|
||||
preset("fresh-pop", "Fresh Pop", {
|
||||
exposure: 0.08,
|
||||
contrast: 0.12,
|
||||
whites: 0.06,
|
||||
shadows: 0.04,
|
||||
temperature: -0.02,
|
||||
vibrance: 0.08,
|
||||
saturation: 0.16,
|
||||
}),
|
||||
preset("warm-daylight", "Warm Daylight", {
|
||||
exposure: 0.06,
|
||||
contrast: 0.07,
|
||||
highlights: -0.06,
|
||||
shadows: 0.08,
|
||||
temperature: 0.18,
|
||||
saturation: 0.08,
|
||||
}),
|
||||
preset("clean-studio", "Clean Studio", {
|
||||
contrast: 0.08,
|
||||
highlights: -0.08,
|
||||
shadows: 0.06,
|
||||
temperature: -0.08,
|
||||
tint: 0.03,
|
||||
saturation: 0.04,
|
||||
}),
|
||||
preset("skin-soft", "Skin Soft", {
|
||||
exposure: 0.04,
|
||||
contrast: -0.03,
|
||||
highlights: -0.12,
|
||||
shadows: 0.12,
|
||||
temperature: 0.08,
|
||||
tint: 0.02,
|
||||
saturation: 0.04,
|
||||
}),
|
||||
preset("food-pop", "Food Pop", {
|
||||
exposure: 0.06,
|
||||
contrast: 0.1,
|
||||
shadows: 0.06,
|
||||
temperature: 0.14,
|
||||
vibrance: 0.1,
|
||||
saturation: 0.18,
|
||||
}),
|
||||
preset(
|
||||
"night-lift",
|
||||
"Night Lift",
|
||||
{
|
||||
exposure: 0.08,
|
||||
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,
|
||||
highlights: -0.18,
|
||||
shadows: 0.2,
|
||||
blacks: -0.08,
|
||||
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,
|
||||
{
|
||||
vignette: 0.12,
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
),
|
||||
preset(
|
||||
"muted-editorial",
|
||||
"Muted Editorial",
|
||||
{
|
||||
exposure: -0.02,
|
||||
contrast: 0.08,
|
||||
highlights: -0.08,
|
||||
shadows: -0.08,
|
||||
blacks: -0.12,
|
||||
saturation: 0.06,
|
||||
shadows: 0.06,
|
||||
blacks: -0.05,
|
||||
temperature: -0.03,
|
||||
saturation: -0.12,
|
||||
},
|
||||
},
|
||||
{
|
||||
vignette: 0.1,
|
||||
},
|
||||
),
|
||||
preset(
|
||||
"vintage-wash",
|
||||
"Vintage Wash",
|
||||
{
|
||||
exposure: 0.03,
|
||||
contrast: -0.12,
|
||||
highlights: -0.1,
|
||||
shadows: 0.16,
|
||||
whites: -0.04,
|
||||
blacks: 0.08,
|
||||
temperature: 0.13,
|
||||
vibrance: -0.08,
|
||||
saturation: -0.08,
|
||||
},
|
||||
{
|
||||
vignette: 0.18,
|
||||
},
|
||||
),
|
||||
preset("mono-clean", "Mono Clean", {
|
||||
contrast: 0.12,
|
||||
highlights: -0.04,
|
||||
shadows: 0.04,
|
||||
blacks: -0.08,
|
||||
saturation: -1,
|
||||
}),
|
||||
preset(
|
||||
"mono-fade",
|
||||
"Mono Fade",
|
||||
{
|
||||
contrast: -0.04,
|
||||
highlights: -0.06,
|
||||
shadows: 0.1,
|
||||
blacks: 0.12,
|
||||
saturation: -1,
|
||||
},
|
||||
{
|
||||
vignette: 0.08,
|
||||
},
|
||||
),
|
||||
preset("warm-clean", "Warm Clean", {
|
||||
exposure: 0.05,
|
||||
contrast: 0.08,
|
||||
highlights: -0.08,
|
||||
shadows: 0.08,
|
||||
temperature: 0.16,
|
||||
vibrance: 0.04,
|
||||
saturation: 0.06,
|
||||
}),
|
||||
preset("cool-clean", "Cool Clean", {
|
||||
contrast: 0.06,
|
||||
highlights: -0.06,
|
||||
shadows: 0.06,
|
||||
temperature: -0.12,
|
||||
tint: 0.04,
|
||||
saturation: 0.04,
|
||||
}),
|
||||
preset("soft-boost", "Soft Boost", {
|
||||
exposure: 0.06,
|
||||
contrast: -0.04,
|
||||
highlights: -0.14,
|
||||
shadows: 0.16,
|
||||
vibrance: 0.08,
|
||||
saturation: 0.1,
|
||||
}),
|
||||
preset("bright-pop", "Bright Pop", {
|
||||
exposure: 0.12,
|
||||
contrast: 0.12,
|
||||
whites: 0.08,
|
||||
blacks: -0.04,
|
||||
vibrance: 0.08,
|
||||
saturation: 0.14,
|
||||
}),
|
||||
preset("deep-contrast", "Deep Contrast", {
|
||||
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>(
|
||||
@@ -171,9 +332,25 @@ const ADJUST_LIMITS: Record<HfColorGradingAdjustKey, { min: number; max: number
|
||||
blacks: { min: -1, max: 1 },
|
||||
temperature: { min: -1, max: 1 },
|
||||
tint: { min: -1, max: 1 },
|
||||
vibrance: { min: -1, max: 1 },
|
||||
saturation: { min: -1, max: 1 },
|
||||
};
|
||||
|
||||
const DETAIL_LIMITS: Record<HfColorGradingDetailKey, { min: number; max: number }> = {
|
||||
vignette: { min: 0, max: 1 },
|
||||
vignetteMidpoint: { min: 0, max: 1 },
|
||||
vignetteRoundness: { min: -1, max: 1 },
|
||||
vignetteFeather: { min: 0, max: 1 },
|
||||
grain: { min: 0, max: 1 },
|
||||
grainSize: { min: 0, max: 1 },
|
||||
grainRoughness: { min: 0, max: 1 },
|
||||
};
|
||||
|
||||
const EFFECT_LIMITS: Record<HfColorGradingEffectKey, { min: number; max: number }> = {
|
||||
blur: { min: 0, max: 1 },
|
||||
pixelate: { min: 0, max: 1 },
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -189,10 +366,9 @@ function clampUnit(value: unknown, fallback: number): number {
|
||||
return Math.min(1, Math.max(0, parsed));
|
||||
}
|
||||
|
||||
function readAdjustValue(value: unknown, key: HfColorGradingAdjustKey): number {
|
||||
function readLimitedValue(value: unknown, limit: { min: number; max: number }): 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);
|
||||
}
|
||||
|
||||
@@ -278,20 +454,40 @@ export function normalizeHfColorGrading(raw: unknown): NormalizedHfColorGrading
|
||||
const presetId = normalizePresetId(grading.preset);
|
||||
const preset = getHfColorGradingPreset(presetId);
|
||||
const presetAdjust = preset?.adjust ?? ADJUST_ZERO;
|
||||
const presetDetails = preset?.details ?? DETAIL_ZERO;
|
||||
const presetEffects = preset?.effects ?? EFFECT_ZERO;
|
||||
const rawAdjust = isRecord(grading.adjust) ? grading.adjust : {};
|
||||
const rawDetails = isRecord(grading.details) ? grading.details : {};
|
||||
const rawEffects = isRecord(grading.effects) ? grading.effects : {};
|
||||
const adjust = HF_COLOR_GRADING_ADJUST_KEYS.reduce<Record<HfColorGradingAdjustKey, number>>(
|
||||
(result, key) => {
|
||||
result[key] = readAdjustValue(rawAdjust[key] ?? presetAdjust[key], key);
|
||||
result[key] = readLimitedValue(rawAdjust[key] ?? presetAdjust[key], ADJUST_LIMITS[key]);
|
||||
return result;
|
||||
},
|
||||
{ ...ADJUST_ZERO },
|
||||
);
|
||||
const details = HF_COLOR_GRADING_DETAIL_KEYS.reduce<Record<HfColorGradingDetailKey, number>>(
|
||||
(result, key) => {
|
||||
result[key] = readLimitedValue(rawDetails[key] ?? presetDetails[key], DETAIL_LIMITS[key]);
|
||||
return result;
|
||||
},
|
||||
{ ...DETAIL_ZERO },
|
||||
);
|
||||
const effects = HF_COLOR_GRADING_EFFECT_KEYS.reduce<Record<HfColorGradingEffectKey, number>>(
|
||||
(result, key) => {
|
||||
result[key] = readLimitedValue(rawEffects[key] ?? presetEffects[key], EFFECT_LIMITS[key]);
|
||||
return result;
|
||||
},
|
||||
{ ...EFFECT_ZERO },
|
||||
);
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
preset: presetId,
|
||||
intensity: clampUnit(grading.intensity, 1),
|
||||
adjust,
|
||||
details,
|
||||
effects,
|
||||
lut: normalizeLut(grading.lut),
|
||||
colorSpace:
|
||||
typeof grading.colorSpace === "string" && grading.colorSpace.trim()
|
||||
@@ -322,5 +518,10 @@ export function isHfColorGradingActive(
|
||||
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);
|
||||
return (
|
||||
HF_COLOR_GRADING_ADJUST_KEYS.some((key) => Math.abs(grading.adjust[key]) > 0.0001) ||
|
||||
Math.abs(grading.details.vignette) > 0.0001 ||
|
||||
Math.abs(grading.details.grain) > 0.0001 ||
|
||||
HF_COLOR_GRADING_EFFECT_KEYS.some((key) => Math.abs(grading.effects[key]) > 0.0001)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,35 @@ describe("cube LUT parsing", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts DaVinci/IRIDAS-style 3D input ranges", () => {
|
||||
const lut = parseCubeLut(`
|
||||
TITLE "Resolve Range"
|
||||
LUT_3D_SIZE 2
|
||||
LUT_3D_INPUT_RANGE 0 1
|
||||
0 0 0
|
||||
1 0 0
|
||||
0 1 0
|
||||
1 1 0
|
||||
0 0 1
|
||||
1 0 1
|
||||
0 1 1
|
||||
1 1 1
|
||||
`);
|
||||
|
||||
expect(lut.title).toBe("Resolve Range");
|
||||
expect(lut.domainMin).toEqual([0, 0, 0]);
|
||||
expect(lut.domainMax).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it("rejects inverted 3D input ranges at the range line", () => {
|
||||
expect(() =>
|
||||
parseCubeLut(`
|
||||
LUT_3D_SIZE 2
|
||||
LUT_3D_INPUT_RANGE 1 0
|
||||
`),
|
||||
).toThrow("LUT_3D_INPUT_RANGE max must exceed min");
|
||||
});
|
||||
|
||||
it("rejects unsupported 1D cube LUTs", () => {
|
||||
expect(() =>
|
||||
parseCubeLut(`
|
||||
|
||||
@@ -30,7 +30,7 @@ export class CubeLutParseError extends Error {
|
||||
|
||||
const DEFAULT_DOMAIN_MIN: CubeLutVec3 = [0, 0, 0];
|
||||
const DEFAULT_DOMAIN_MAX: CubeLutVec3 = [1, 1, 1];
|
||||
const DEFAULT_MAX_SIZE = 64;
|
||||
export const DEFAULT_MAX_CUBE_LUT_SIZE = 64;
|
||||
|
||||
function stripComment(line: string): string {
|
||||
let inQuote = false;
|
||||
@@ -93,7 +93,7 @@ function isNumericDataLine(token: string): boolean {
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function parseCubeLut(input: string, options: ParseCubeLutOptions = {}): CubeLut3D {
|
||||
const maxSize = options.maxSize ?? DEFAULT_MAX_SIZE;
|
||||
const maxSize = options.maxSize ?? DEFAULT_MAX_CUBE_LUT_SIZE;
|
||||
let title: string | null = null;
|
||||
let domainMin: CubeLutVec3 = DEFAULT_DOMAIN_MIN;
|
||||
let domainMax: CubeLutVec3 = DEFAULT_DOMAIN_MAX;
|
||||
@@ -122,6 +122,19 @@ export function parseCubeLut(input: string, options: ParseCubeLutOptions = {}):
|
||||
domainMax = parseVec3(rest, keyword, lineNumber);
|
||||
continue;
|
||||
}
|
||||
if (keyword === "LUT_3D_INPUT_RANGE") {
|
||||
if (rest.length !== 2) {
|
||||
throw new CubeLutParseError(`${keyword} expects two numbers`, lineNumber);
|
||||
}
|
||||
const min = parseFiniteNumber(rest[0]!, lineNumber);
|
||||
const max = parseFiniteNumber(rest[1]!, lineNumber);
|
||||
if (max <= min) {
|
||||
throw new CubeLutParseError("LUT_3D_INPUT_RANGE max must exceed min", lineNumber);
|
||||
}
|
||||
domainMin = [min, min, min];
|
||||
domainMax = [max, max, max];
|
||||
continue;
|
||||
}
|
||||
if (keyword === "LUT_1D_SIZE") {
|
||||
lut1dSize = parseSize(rest[0], keyword, lineNumber);
|
||||
continue;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { bundleToSingleHtml } from "./htmlBundler";
|
||||
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
|
||||
|
||||
@@ -964,6 +964,42 @@ describe("bundleToSingleHtml", () => {
|
||||
expect(lutSrc).not.toContain("assets/luts/identity.cube");
|
||||
});
|
||||
|
||||
it("can keep data-color-grading LUT paths external for studio preview", 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, { inlineColorGradingLuts: false });
|
||||
const lutSrc = readBundledColorGradingLutSrc(bundled);
|
||||
|
||||
expect(lutSrc).toBe("assets/luts/identity.cube");
|
||||
});
|
||||
|
||||
it("warns when a render bundle cannot inline a referenced color grading LUT", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const dir = makeColorGradingProject("assets/luts/missing.cube");
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
const lutSrc = readBundledColorGradingLutSrc(bundled);
|
||||
|
||||
expect(lutSrc).toBe("assets/luts/missing.cube");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not inline color grading LUT "assets/luts/missing.cube"'),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves nested CSS @import chains", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
|
||||
@@ -222,6 +222,14 @@ function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): stri
|
||||
return appendSuffixToUrl(dataUrl, suffix);
|
||||
}
|
||||
|
||||
function warnColorGradingLutNotInlined(lutSrc: string): void {
|
||||
const trimmed = lutSrc.trim();
|
||||
if (!isRelativeUrl(trimmed)) return;
|
||||
console.warn(
|
||||
`[HyperFrames] Could not inline color grading LUT "${trimmed}". The rendered bundle may not be self-contained.`,
|
||||
);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function rewriteColorGradingLutWithInlinedAssets(value: string, projectDir: string): string {
|
||||
if (!value.trim().startsWith("{")) return value;
|
||||
@@ -236,7 +244,10 @@ function rewriteColorGradingLutWithInlinedAssets(value: string, projectDir: stri
|
||||
const lut = Reflect.get(parsed, "lut");
|
||||
if (typeof lut === "string") {
|
||||
const inlined = maybeInlineRelativeAssetUrl(lut, projectDir);
|
||||
if (!inlined) return value;
|
||||
if (!inlined) {
|
||||
warnColorGradingLutNotInlined(lut);
|
||||
return value;
|
||||
}
|
||||
Reflect.set(parsed, "lut", inlined);
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
@@ -244,7 +255,10 @@ function rewriteColorGradingLutWithInlinedAssets(value: string, projectDir: stri
|
||||
const lutSrc = Reflect.get(lut, "src");
|
||||
if (typeof lutSrc !== "string") return value;
|
||||
const inlined = maybeInlineRelativeAssetUrl(lutSrc, projectDir);
|
||||
if (!inlined) return value;
|
||||
if (!inlined) {
|
||||
warnColorGradingLutNotInlined(lutSrc);
|
||||
return value;
|
||||
}
|
||||
Reflect.set(lut, "src", inlined);
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
@@ -613,6 +627,12 @@ export interface BundleOptions {
|
||||
* modes and emits `<script ... src="<URL>">` directly.
|
||||
*/
|
||||
runtime?: "inline" | "placeholder";
|
||||
/**
|
||||
* Inline .cube LUTs referenced from data-color-grading. Default: true for
|
||||
* self-contained renders/exports. Studio preview disables this so the editor
|
||||
* keeps showing project asset paths instead of giant data URLs.
|
||||
*/
|
||||
inlineColorGradingLuts?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -980,13 +1000,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),
|
||||
);
|
||||
if (options?.inlineColorGradingLuts !== false) {
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -164,7 +164,10 @@ export { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "./media/gif"
|
||||
export {
|
||||
HF_COLOR_GRADING_ATTR,
|
||||
HF_COLOR_GRADING_ADJUST_KEYS,
|
||||
HF_COLOR_GRADING_CANVAS_ID_PREFIX,
|
||||
HF_COLOR_GRADING_COLOR_SPACE,
|
||||
HF_COLOR_GRADING_DETAIL_KEYS,
|
||||
HF_COLOR_GRADING_EFFECT_KEYS,
|
||||
HF_COLOR_GRADING_PRESETS,
|
||||
isHfColorGradingActive,
|
||||
normalizeHfColorGrading,
|
||||
@@ -174,6 +177,10 @@ export {
|
||||
type HfColorGrading,
|
||||
type HfColorGradingAdjust,
|
||||
type HfColorGradingAdjustKey,
|
||||
type HfColorGradingDetailKey,
|
||||
type HfColorGradingDetails,
|
||||
type HfColorGradingEffectKey,
|
||||
type HfColorGradingEffects,
|
||||
type HfColorGradingLutRef,
|
||||
type HfColorGradingPreset,
|
||||
type HfColorGradingPresetId,
|
||||
|
||||
Reference in New Issue
Block a user