feat(core): add media color grading contract

This commit is contained in:
ukimsanov
2026-07-06 02:36:17 -07:00
parent 3a717fa719
commit 7c14438496
8 changed files with 480 additions and 116 deletions
+75 -2
View File
@@ -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 });
});
+278 -77
View File
@@ -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)
);
}
+29
View File
@@ -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(`
+15 -2
View File
@@ -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;
+37 -1
View File
@@ -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>
+31 -9
View File
@@ -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),
);
}
}
}
+7
View File
@@ -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,