From 70213c5a8526b12c8b26f01b8288d78bb9edc917 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Thu, 23 Jul 2026 21:14:49 -0700 Subject: [PATCH] feat(core): define media treatment capabilities --- docs/concepts/variables.mdx | 4 +- docs/guides/color-grading.mdx | 2 +- packages/core/src/colorGrading.test.ts | 458 +++++++++++- packages/core/src/colorGrading.ts | 673 ++++++++++++++---- packages/core/src/index.ts | 12 + packages/core/src/runtime/bridge.test.ts | 2 +- packages/parsers/package-subpaths.json | 6 + packages/parsers/package.json | 10 + .../parsers/src/colorGradingContract.test.ts | 63 ++ packages/parsers/src/colorGradingContract.ts | 331 +++++++++ packages/parsers/tsup.config.ts | 1 + .../editor/colorGradingScopePatch.test.ts | 6 +- ...pertyPanelFlatColorGradingSection.test.tsx | 4 +- .../propertyPanelFlatPrimitives.test.tsx | 10 +- .../editor/useColorGradingController.test.ts | 56 +- skills-manifest.json | 2 +- .../scripts/lib/lut-preset-provider.mjs | 49 +- .../scripts/lib/lut-preset-provider.test.mjs | 19 +- 18 files changed, 1486 insertions(+), 222 deletions(-) create mode 100644 packages/parsers/src/colorGradingContract.test.ts create mode 100644 packages/parsers/src/colorGradingContract.ts diff --git a/docs/concepts/variables.mdx b/docs/concepts/variables.mdx index 790020395f..407fb76099 100644 --- a/docs/concepts/variables.mdx +++ b/docs/concepts/variables.mdx @@ -87,8 +87,8 @@ the shader grading, finishing details, blur/pixelate effects, and optional LUT: ```html compositions/hero.html { @@ -106,7 +487,7 @@ describe("color grading", () => { }); it("treats finishing details as active grading", () => { - const grading = normalizeHfColorGrading({ details: { vignette: 0.2 } }); + const grading = normalizeHfColorGrading({ intensity: 0, details: { vignette: 0.2 } }); expect(isHfColorGradingActive(grading)).toBe(true); }); @@ -117,8 +498,36 @@ describe("color grading", () => { expect(isHfColorGradingActive(grading)).toBe(false); }); + it("does not activate grading for halftone size alone", () => { + const grading = normalizeHfColorGrading({ effects: { halftoneSize: 0.8 } }); + expect(isHfColorGradingActive(grading)).toBe(false); + }); + + it("does not activate grading for two-ink screen size alone", () => { + const grading = normalizeHfColorGrading({ effects: { twoInkPrintSize: 0.8 } }); + expect(isHfColorGradingActive(grading)).toBe(false); + }); + + it("does not activate grading for ASCII/dither options without an effect amount", () => { + const grading = normalizeHfColorGrading({ + effects: { asciiSize: 0.8, asciiInvert: 1, ditherSize: 0.7 }, + palette: ["#111111", "#eeeeee"], + }); + expect(isHfColorGradingActive(grading)).toBe(false); + }); + + it("rejects malformed or out-of-range effect palettes", () => { + expect(normalizeHfColorGrading({ palette: ["#000000"] })?.palette).toBeNull(); + expect(normalizeHfColorGrading({ palette: ["#000000", "red"] })?.palette).toBeNull(); + expect( + normalizeHfColorGrading({ + palette: ["#000000", "#111111", "#222222", "#333333", "#444444", "#555555", "#666666"], + })?.palette, + ).toBeNull(); + }); + it("treats media effects as active grading", () => { - const grading = normalizeHfColorGrading({ effects: { blur: 0.2 } }); + const grading = normalizeHfColorGrading({ intensity: 0, effects: { blur: 0.2 } }); expect(isHfColorGradingActive(grading)).toBe(true); }); @@ -137,13 +546,14 @@ describe("color grading", () => { grainSize: "$grainSize", }, effects: { pixelate: "$pixelate" }, + palette: "$palette", lut: { src: "$lutSrc", intensity: "$lutIntensity", }, }), { - preset: "warm-clean", + preset: "warm-daylight", gradingIntensity: 0.6, exposure: 0.25, vibrance: 0.3, @@ -151,12 +561,13 @@ describe("color grading", () => { vignette: 0.15, grainSize: 0.4, pixelate: 0.1, + palette: ["#080717", "#aafae0"], lutSrc: "assets/luts/warm.cube", lutIntensity: 0.4, }, ); - expect(grading?.preset).toBe("warm-clean"); + expect(grading?.preset).toBe("warm-daylight"); expect(grading?.intensity).toBe(0.6); expect(grading?.adjust.exposure).toBe(0.25); expect(grading?.adjust.vibrance).toBe(0.3); @@ -164,6 +575,7 @@ describe("color grading", () => { expect(grading?.details.vignette).toBe(0.15); expect(grading?.details.grainSize).toBe(0.4); expect(grading?.effects.pixelate).toBe(0.1); + expect(grading?.palette).toEqual(["#080717", "#aafae0"]); expect(grading?.lut).toEqual({ src: "assets/luts/warm.cube", intensity: 0.4 }); }); diff --git a/packages/core/src/colorGrading.ts b/packages/core/src/colorGrading.ts index d5411e20a3..6798359fdf 100644 --- a/packages/core/src/colorGrading.ts +++ b/packages/core/src/colorGrading.ts @@ -1,3 +1,13 @@ +import { DEFAULT_MAX_CUBE_LUT_SIZE } from "./colorLuts"; +import { + COLOR_GRADING_ADJUST_KEYS, + COLOR_GRADING_COLOR_SPACE, + COLOR_GRADING_DETAIL_KEYS, + COLOR_GRADING_EFFECT_KEYS, + COLOR_GRADING_LUT_KEYS, + COLOR_GRADING_TOP_LEVEL_KEYS, +} from "@hyperframes/parsers/color-grading-contract"; + export const HF_COLOR_GRADING_ATTR = "data-color-grading"; // Runtime <-> studio contract attributes. The runtime grading engine writes @@ -14,12 +24,10 @@ export const COLOR_GRADING_AUTHORED_OPACITY_ATTR = "data-hf-authored-opacity"; export const HF_COLOR_GRADING_CANVAS_ID_PREFIX = "__hf_color_grading_"; -export const HF_COLOR_GRADING_COLOR_SPACE = "rec709"; +export const HF_COLOR_GRADING_COLOR_SPACE = COLOR_GRADING_COLOR_SPACE; export type HfColorGradingPresetId = | "neutral" - | "natural-lift" - | "fresh-pop" | "warm-daylight" | "clean-studio" | "skin-soft" @@ -29,38 +37,115 @@ export type HfColorGradingPresetId = | "vintage-wash" | "mono-clean" | "mono-fade" - | "warm-clean" - | "cool-clean" | "soft-boost" | "bright-pop" - | "deep-contrast"; - -export type HfColorGradingAdjustKey = - | "exposure" - | "contrast" - | "highlights" - | "shadows" - | "whites" - | "blacks" - | "temperature" - | "tint" - | "vibrance" - | "saturation"; + | "deep-contrast" + | "creator-camcorder" + | "vhs-playback" + | "home-movie-8mm" + | "editorial-halftone" + | "two-ink-print"; + +export type HfColorGradingAdjustKey = (typeof COLOR_GRADING_ADJUST_KEYS)[number]; + +const ADJUST_ZERO = { + exposure: 0, + contrast: 0, + highlights: 0, + shadows: 0, + whites: 0, + blacks: 0, + temperature: 0, + tint: 0, + vibrance: 0, + saturation: 0, +} satisfies Record; export type HfColorGradingAdjust = Partial>; -export type HfColorGradingDetailKey = - | "vignette" - | "vignetteMidpoint" - | "vignetteRoundness" - | "vignetteFeather" - | "grain" - | "grainSize" - | "grainRoughness"; +// Sub-controls use useful identity defaults rather than raw mathematical zeroes. +export type HfColorGradingDetailKey = (typeof COLOR_GRADING_DETAIL_KEYS)[number]; + +const DETAIL_DEFAULTS = { + vignette: 0, + vignetteMidpoint: 0.5, + vignetteRoundness: 0, + vignetteFeather: 0.65, + grain: 0, + grainSize: 0.25, + grainRoughness: 0.5, +} satisfies Record; export type HfColorGradingDetails = Partial>; -export type HfColorGradingEffectKey = "blur" | "pixelate"; +export type HfColorGradingEffectKey = (typeof COLOR_GRADING_EFFECT_KEYS)[number]; + +const EFFECT_DEFAULTS = { + blur: 0, + pixelate: 0, + chromaBleed: 0, + tapeDamage: 0, + tapeTracking: 0, + tapeNoise: 1, + tapeSpeed: 0.5, + filmArtifacts: 0, + halftone: 0, + halftoneSize: 0, + twoInkPrint: 0, + twoInkPrintSize: 0, + ascii: 0, + asciiSize: 5 / 76, + asciiInvert: 0, + asciiStyle: 0, + asciiColor: 1, + asciiRotation: 0, + dither: 0, + ditherSize: 0, + bloom: 0, + bloomRadius: 8, + monoScreen: 0, + monoScreenSize: 0, + monoScreenAngle: 0, + monoScreenSpread: 0, + monoScreenShape: 0, + monoScreenInvert: 0, + scanlines: 0, + scanlineCount: 0, + scanlineSoftness: 0, + chromaticAberration: 0, + chromaticAngle: 0, + crtCurvature: 0, + digitalGlitch: 0, + digitalGlitchColorSplit: 0, + digitalGlitchLineTear: 0, + digitalGlitchPixelate: 0, + digitalGlitchBlockAmount: 0, + digitalGlitchBlockDisplacement: 0, + digitalGlitchBlockOpacity: 0, + digitalGlitchSpeed: 0, + engraving: 0, + engravingSpacing: 7 / 17, + engravingMinThickness: 0.2, + engravingMaxThickness: 3.2 / 7, + engravingAngle: 0.25, + engravingContrast: 7 / 15, + engravingSharpness: 0.59, + engravingWave: 0.2, + engravingWaveFrequency: 2 / 9, + crosshatch: 0, + crosshatchSpacing: 7 / 25, + crosshatchThickness: 0.25, + crosshatchAngle: 0.25, + crosshatchContrast: 1 / 3, + crosshatchEdges: 0.5, + crosshatchLineWeight: 0, + crosshatchWave: 0.33, + crosshatchWaveFrequency: 2 / 9, + kuwahara: 0, + kuwaharaRadius: 1 / 7, + kuwaharaSharpness: 5 / 16, + kuwaharaSaturation: 0.5, +} satisfies Record; export type HfColorGradingEffects = Partial>; @@ -76,10 +161,17 @@ export interface HfColorGrading { adjust?: HfColorGradingAdjust; details?: HfColorGradingDetails; effects?: HfColorGradingEffects; + palette?: readonly string[] | null; lut?: HfColorGradingLutRef | string | null; colorSpace?: typeof HF_COLOR_GRADING_COLOR_SPACE | string; } +export const HF_COLOR_GRADING_TOP_LEVEL_KEYS = + COLOR_GRADING_TOP_LEVEL_KEYS satisfies readonly (keyof HfColorGrading)[]; + +export const HF_COLOR_GRADING_LUT_KEYS = + COLOR_GRADING_LUT_KEYS satisfies readonly (keyof HfColorGradingLutRef)[]; + export interface NormalizedHfColorGrading { enabled: boolean; preset: HfColorGradingPresetId | string | null; @@ -87,6 +179,7 @@ export interface NormalizedHfColorGrading { adjust: Record; details: Record; effects: Record; + palette: readonly string[] | null; lut: HfColorGradingLutRef | null; colorSpace: typeof HF_COLOR_GRADING_COLOR_SPACE | string; } @@ -101,88 +194,145 @@ export interface HfColorGradingTarget { export interface HfColorGradingPreset { id: HfColorGradingPresetId; label: string; + intensity: number; adjust: Record; details: Record; effects: Record; } -export type HfColorGradingVariableMap = Record; - -const ADJUST_ZERO: Record = { - exposure: 0, - contrast: 0, - highlights: 0, - shadows: 0, - whites: 0, - blacks: 0, - temperature: 0, - tint: 0, - vibrance: 0, - saturation: 0, -}; +export const HF_COLOR_GRADING_PALETTES = [ + { id: "noir", label: "Noir", group: "Classic", colors: ["#000000", "#ffffff"] }, + { + id: "ink-paper", + label: "Ink & Paper", + group: "Classic", + colors: ["#1a1a2e", "#f5f5dc"], + }, + { + id: "terminal", + label: "Terminal", + group: "Classic", + colors: ["#001100", "#00ff00"], + }, + { + id: "amber-glow", + label: "Amber Glow", + group: "Classic", + colors: ["#1a0f00", "#ffcc00"], + }, + { + id: "handheld-green", + label: "Handheld Green", + group: "Classic", + colors: ["#0f380f", "#306230", "#8bac0f", "#9bbc0f"], + }, + { + id: "golden-hour", + label: "Golden Hour", + group: "Mood", + colors: ["#1a1205", "#4a3510", "#8b6914", "#d4a017", "#fff8dc"], + }, + { + id: "deep-sea", + label: "Deep Sea", + group: "Mood", + colors: ["#0a1628", "#1a3a5c", "#2d6187", "#5ba4c9", "#a8dce8"], + }, + { + id: "arctic-night", + label: "Arctic Night", + group: "Mood", + colors: ["#0a0a14", "#1a2a4a", "#3a5a8a", "#6a9aca", "#cae8ff"], + }, + { + id: "synthwave", + label: "Synthwave", + group: "Mood", + colors: ["#120458", "#7b2cbf", "#e040fb", "#ff6ec7", "#fff59d"], + }, + { + id: "vaporwave", + label: "Vaporwave", + group: "Mood", + colors: ["#1a0a2e", "#3d1a5c", "#ff71ce", "#01cdfe", "#fffb96"], + }, + { + id: "forest", + label: "Forest", + group: "Mood", + colors: ["#1a2e1a", "#2d4a2d", "#4a7c4a", "#7ab37a", "#c8e6c8"], + }, + { + id: "sepia", + label: "Sepia", + group: "Mono", + colors: ["#1a1610", "#3d3020", "#6b5a40", "#a89070", "#e8dcc8"], + }, + { + id: "blueprint", + label: "Blueprint", + group: "Mono", + colors: ["#001830", "#003060", "#0050a0", "#0080e0", "#e0f0ff"], + }, + { + id: "warm-print", + label: "Warm Print", + group: "HyperFrames", + colors: ["#17121a", "#824c50", "#e09873", "#f7ddb1"], + }, + { + id: "electric-ink", + label: "Electric Ink", + group: "HyperFrames", + colors: ["#080717", "#3c185f", "#7e2278", "#d9339f", "#ff6b66", "#aafae0"], + }, +] as const; -// 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 = { - vignette: 0, - vignetteMidpoint: 0.5, - vignetteRoundness: 0, - vignetteFeather: 0.65, - grain: 0, - grainSize: 0.25, - grainRoughness: 0.5, -}; +export type HfColorGradingVariableMap = Record; -const EFFECT_ZERO: Record = { - blur: 0, - pixelate: 0, +export const HF_COLOR_GRADING_ADJUST_KEYS = + COLOR_GRADING_ADJUST_KEYS satisfies readonly HfColorGradingAdjustKey[]; + +export const HF_COLOR_GRADING_DETAIL_KEYS = + COLOR_GRADING_DETAIL_KEYS satisfies readonly HfColorGradingDetailKey[]; + +export const HF_COLOR_GRADING_EFFECT_KEYS = + COLOR_GRADING_EFFECT_KEYS satisfies readonly HfColorGradingEffectKey[]; + +const VINTAGE_WASH_ADJUST: HfColorGradingAdjust = { + 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, }; -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[]; +const VINTAGE_WASH_DETAILS: HfColorGradingDetails = { vignette: 0.18 }; function preset( id: HfColorGradingPresetId, label: string, adjust: HfColorGradingAdjust = {}, details: HfColorGradingDetails = {}, + effects: HfColorGradingEffects = {}, + intensity = 1, ): HfColorGradingPreset { return { id, label, + intensity, adjust: { ...ADJUST_ZERO, ...adjust }, - details: { ...DETAIL_ZERO, ...details }, - effects: { ...EFFECT_ZERO }, + details: { ...DETAIL_DEFAULTS, ...details }, + effects: { ...EFFECT_DEFAULTS, ...effects }, }; } export const HF_COLOR_GRADING_PRESETS: readonly HfColorGradingPreset[] = [ 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, @@ -247,24 +397,7 @@ export const HF_COLOR_GRADING_PRESETS: readonly HfColorGradingPreset[] = [ 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("vintage-wash", "Vintage Wash", VINTAGE_WASH_ADJUST, VINTAGE_WASH_DETAILS), preset("mono-clean", "Mono Clean", { contrast: 0.12, highlights: -0.04, @@ -286,23 +419,6 @@ export const HF_COLOR_GRADING_PRESETS: readonly HfColorGradingPreset[] = [ 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, @@ -327,6 +443,74 @@ export const HF_COLOR_GRADING_PRESETS: readonly HfColorGradingPreset[] = [ blacks: -0.12, saturation: 0.06, }), + preset( + "creator-camcorder", + "Creator Camcorder", + { + contrast: 0.08, + highlights: -0.05, + shadows: 0.02, + whites: 0.03, + blacks: -0.04, + temperature: -0.03, + tint: -0.015, + vibrance: -0.03, + saturation: -0.06, + }, + { vignette: 0.06, grain: 0.08, grainSize: 0.18, grainRoughness: 0.58 }, + { chromaBleed: 0.55 }, + 0.72, + ), + preset( + "vhs-playback", + "VHS Playback", + { contrast: -0.04, saturation: -0.08 }, + { grain: 0.16, grainSize: 0.12, grainRoughness: 0.72 }, + { + tapeDamage: 0.82, + tapeTracking: 0.85, + tapeNoise: 0.3, + tapeSpeed: 0.5, + chromaBleed: 0.5, + chromaticAberration: 0.18, + scanlines: 0.35, + scanlineCount: 0.17, + scanlineSoftness: 1, + digitalGlitch: 0.32, + digitalGlitchLineTear: 0.08, + digitalGlitchSpeed: 0.5, + }, + ), + preset( + "home-movie-8mm", + "8mm Home Movie", + VINTAGE_WASH_ADJUST, + { + ...VINTAGE_WASH_DETAILS, + vignette: 0.28, + vignetteMidpoint: 0.54, + vignetteFeather: 0.72, + grain: 0.34, + grainSize: 0.18, + grainRoughness: 0.72, + }, + { filmArtifacts: 0.62 }, + 0.72, + ), + preset( + "editorial-halftone", + "Editorial Halftone", + { contrast: 0.04, saturation: 0.04 }, + {}, + { halftone: 0.94, halftoneSize: 0.36 }, + ), + preset( + "two-ink-print", + "Two-Ink Print", + { contrast: 0.08, highlights: -0.06, shadows: 0.04 }, + {}, + { twoInkPrint: 1, twoInkPrintSize: 0.42 }, + ), ]; const PRESETS_BY_ID = new Map( @@ -358,11 +542,222 @@ const DETAIL_LIMITS: Record = { - blur: { min: 0, max: 1 }, - pixelate: { min: 0, max: 1 }, +const UNIT_LIMIT = { min: 0, max: 1 }; +const EFFECT_LIMIT_OVERRIDES: Partial< + Record +> = { + asciiStyle: { min: 0, max: 7 }, + bloom: { min: 0, max: 3 }, + bloomRadius: { min: 1, max: 100 }, + monoScreenShape: { min: 0, max: 4 }, }; +export const HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS = [ + "blur", + "pixelate", + "chromaBleed", + "tapeDamage", + "filmArtifacts", + "halftone", + "twoInkPrint", + "ascii", + "dither", + "bloom", + "monoScreen", + "scanlines", + "chromaticAberration", + "crtCurvature", + "digitalGlitch", + "engraving", + "crosshatch", + "kuwahara", +] as const satisfies readonly HfColorGradingEffectKey[]; + +export type HfColorGradingActiveEffectKey = (typeof HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS)[number]; + +export const HF_COLOR_GRADING_EFFECT_PRESETS = HF_COLOR_GRADING_PRESETS.filter((preset) => + HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS.some((key) => preset.effects[key] > 0.0001), +); + +export const HF_COLOR_GRADING_GRADE_PRESETS = HF_COLOR_GRADING_PRESETS.filter( + (preset) => !HF_COLOR_GRADING_EFFECT_PRESETS.includes(preset), +); + +/** Useful one-click values. Sub-controls not listed here keep their normalized defaults. */ +export const HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS: Readonly< + Record +> = { + blur: { blur: 0.45 }, + pixelate: { pixelate: 0.55 }, + bloom: { bloom: 0.55, bloomRadius: 8 }, + chromaBleed: { chromaBleed: 0.55 }, + tapeDamage: { + tapeDamage: 0.65, + tapeTracking: 0.55, + tapeNoise: 0.25, + tapeSpeed: 0.5, + }, + filmArtifacts: { filmArtifacts: 0.55 }, + scanlines: { scanlines: 0.35, scanlineCount: 0.17, scanlineSoftness: 1 }, + chromaticAberration: { chromaticAberration: 0.15, chromaticAngle: 0 }, + crtCurvature: { crtCurvature: 0.2 }, + digitalGlitch: { + digitalGlitch: 0.55, + digitalGlitchColorSplit: 0.25, + digitalGlitchLineTear: 0.25, + digitalGlitchPixelate: 0.15, + digitalGlitchBlockAmount: 0.5, + digitalGlitchBlockDisplacement: 0.25, + digitalGlitchBlockOpacity: 0, + digitalGlitchSpeed: 0.5, + }, + halftone: { halftone: 0.94, halftoneSize: 0.36 }, + twoInkPrint: { twoInkPrint: 1, twoInkPrintSize: 0.42 }, + ascii: { + ascii: 1, + asciiSize: 5 / 76, + asciiInvert: 0, + asciiStyle: 0, + asciiColor: 1, + asciiRotation: 0, + }, + dither: { dither: 1, ditherSize: 0.5 }, + monoScreen: { + monoScreen: 1, + monoScreenSize: 0.35, + monoScreenAngle: 0.25, + monoScreenSpread: 0.3, + monoScreenShape: 0, + monoScreenInvert: 0, + }, + engraving: { + engraving: 1, + engravingSpacing: 7 / 17, + engravingMinThickness: 0.2, + engravingMaxThickness: 3.2 / 7, + engravingAngle: 0.25, + engravingContrast: 7 / 15, + engravingSharpness: 0.59, + engravingWave: 0.2, + engravingWaveFrequency: 2 / 9, + }, + crosshatch: { + crosshatch: 1, + crosshatchSpacing: 7 / 25, + crosshatchThickness: 0.25, + crosshatchAngle: 0.25, + crosshatchContrast: 1 / 3, + crosshatchEdges: 0.5, + crosshatchLineWeight: 0, + crosshatchWave: 0.33, + crosshatchWaveFrequency: 2 / 9, + }, + kuwahara: { + kuwahara: 1, + kuwaharaRadius: 1 / 7, + kuwaharaSharpness: 5 / 16, + kuwaharaSaturation: 0.5, + }, +}; + +export const HF_COLOR_GRADING_ANIMATABLE_PROPERTIES = [ + { path: "intensity", name: "--hf-color-grading-intensity", min: 0, max: 1 }, + { path: "lut.intensity", name: "--hf-color-grading-lut-intensity", min: 0, max: 1 }, + { path: "adjust.exposure", name: "--hf-color-grading-exposure", min: -2, max: 2 }, + { path: "effects.blur", name: "--hf-color-grading-blur", min: 0, max: 1 }, + { path: "effects.bloom", name: "--hf-color-grading-bloom", min: 0, max: 3 }, + { path: "effects.kuwahara", name: "--hf-color-grading-kuwahara", min: 0, max: 1 }, + { path: "effects.pixelate", name: "--hf-color-grading-pixelate", min: 0, max: 1 }, + { path: "effects.ascii", name: "--hf-color-grading-ascii", min: 0, max: 1 }, + { path: "effects.dither", name: "--hf-color-grading-dither", min: 0, max: 1 }, +] as const; + +export type HfColorGradingAnimatablePath = + (typeof HF_COLOR_GRADING_ANIMATABLE_PROPERTIES)[number]["path"]; + +function effectLimit(key: HfColorGradingEffectKey): { min: number; max: number } { + return EFFECT_LIMIT_OVERRIDES[key] ?? UNIT_LIMIT; +} + +const PALETTE_EFFECT_KEYS = new Set([ + "ascii", + "dither", + "monoScreen", + "engraving", + "crosshatch", +]); + +const MULTIPASS_EFFECT_KEYS = new Set(["blur", "bloom", "kuwahara"]); + +/** Agent-readable view of the canonical grading and effect contracts. */ +export function getHfColorGradingCapabilities() { + return { + version: 1, + targetTags: ["img", "video"], + colorSpace: HF_COLOR_GRADING_COLOR_SPACE, + intensity: { identity: 1, min: 0, max: 1 }, + palette: { minColors: 2, maxColors: 6, colorFormat: "#rrggbb" }, + lut: { + format: "3d-cube", + maxCubeSize: DEFAULT_MAX_CUBE_LUT_SIZE, + intensity: { ...UNIT_LIMIT }, + }, + presets: [...HF_COLOR_GRADING_GRADE_PRESETS, ...HF_COLOR_GRADING_EFFECT_PRESETS].map( + (preset) => ({ + id: preset.id, + label: preset.label, + intensity: preset.intensity, + }), + ), + adjustments: HF_COLOR_GRADING_ADJUST_KEYS.map((key) => ({ + key, + identity: ADJUST_ZERO[key], + ...ADJUST_LIMITS[key], + })), + finishing: HF_COLOR_GRADING_DETAIL_KEYS.map((key) => ({ + key, + identity: DETAIL_DEFAULTS[key], + ...DETAIL_LIMITS[key], + })), + effects: HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS.map((key) => { + const apply = HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS[key]; + return { + key, + apply: { ...apply }, + supportsPalette: PALETTE_EFFECT_KEYS.has(key), + renderLane: MULTIPASS_EFFECT_KEYS.has(key) ? "multipass" : "single-pass", + controls: HF_COLOR_GRADING_EFFECT_KEYS.filter((control) => + Object.hasOwn(apply, control), + ).map((control) => ({ + key: control, + identity: EFFECT_DEFAULTS[control], + recommended: apply[control] ?? EFFECT_DEFAULTS[control], + ...effectLimit(control), + })), + }; + }), + palettes: HF_COLOR_GRADING_PALETTES.map(({ id, label, group, colors }) => ({ + id, + label, + group, + colors, + })), + animatable: HF_COLOR_GRADING_ANIMATABLE_PROPERTIES, + }; +} + +export type HfColorGradingCapabilities = ReturnType; + +const EFFECT_PALETTE_COLOR = /^#[0-9a-f]{6}$/i; + +function normalizePalette(value: unknown): readonly string[] | null { + if (!Array.isArray(value) || value.length < 2 || value.length > 6) return null; + if (!value.every((color) => typeof color === "string" && EFFECT_PALETTE_COLOR.test(color))) { + return null; + } + return value.map((color) => color.toLowerCase()); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -417,7 +812,7 @@ function readColorGradingObject(raw: unknown): Record | null { return null; } } - return { preset: trimmed, intensity: 1 }; + return { preset: trimmed }; } return isRecord(raw) ? raw : null; } @@ -466,8 +861,8 @@ 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 presetDetails = preset?.details ?? DETAIL_DEFAULTS; + const presetEffects = preset?.effects ?? EFFECT_DEFAULTS; const rawAdjust = isRecord(grading.adjust) ? grading.adjust : {}; const rawDetails = isRecord(grading.details) ? grading.details : {}; const rawEffects = isRecord(grading.effects) ? grading.effects : {}; @@ -483,23 +878,27 @@ export function normalizeHfColorGrading(raw: unknown): NormalizedHfColorGrading result[key] = readLimitedValue(rawDetails[key] ?? presetDetails[key], DETAIL_LIMITS[key]); return result; }, - { ...DETAIL_ZERO }, + { ...DETAIL_DEFAULTS }, ); const effects = HF_COLOR_GRADING_EFFECT_KEYS.reduce>( (result, key) => { - result[key] = readLimitedValue(rawEffects[key] ?? presetEffects[key], EFFECT_LIMITS[key]); + result[key] = readLimitedValue( + rawEffects[key] ?? presetEffects[key], + EFFECT_LIMIT_OVERRIDES[key] ?? UNIT_LIMIT, + ); return result; }, - { ...EFFECT_ZERO }, + { ...EFFECT_DEFAULTS }, ); return { enabled: true, preset: presetId, - intensity: clampUnit(grading.intensity, 1), + intensity: clampUnit(grading.intensity, preset?.intensity ?? 1), adjust, details, effects, + palette: normalizePalette(grading.palette), lut: normalizeLut(grading.lut), colorSpace: typeof grading.colorSpace === "string" && grading.colorSpace.trim() @@ -520,20 +919,20 @@ export function serializeHfColorGrading( ): string { const normalized = normalizeHfColorGrading(grading); if (!normalized) return ""; - const { enabled: _enabled, ...serializable } = normalized; - return JSON.stringify(serializable); + const { enabled: _enabled, palette, ...serializable } = normalized; + return JSON.stringify(palette ? { ...serializable, palette } : 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) || + const hasIndependentTreatment = 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) - ); + HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS.some((key) => Math.abs(grading.effects[key]) > 0.0001); + if (hasIndependentTreatment) return true; + if (grading.intensity === 0) return false; + if (grading.lut && grading.lut.intensity !== 0) return true; + return HF_COLOR_GRADING_ADJUST_KEYS.some((key) => Math.abs(grading.adjust[key]) > 0.0001); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d99e932a7a..b91597bb99 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -169,11 +169,20 @@ export { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "./media/gif" export { HF_COLOR_GRADING_ATTR, HF_COLOR_GRADING_ADJUST_KEYS, + HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS, + HF_COLOR_GRADING_ANIMATABLE_PROPERTIES, HF_COLOR_GRADING_CANVAS_ID_PREFIX, HF_COLOR_GRADING_COLOR_SPACE, HF_COLOR_GRADING_DETAIL_KEYS, + HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS, HF_COLOR_GRADING_EFFECT_KEYS, + HF_COLOR_GRADING_EFFECT_PRESETS, + HF_COLOR_GRADING_GRADE_PRESETS, + HF_COLOR_GRADING_LUT_KEYS, + HF_COLOR_GRADING_PALETTES, HF_COLOR_GRADING_PRESETS, + HF_COLOR_GRADING_TOP_LEVEL_KEYS, + getHfColorGradingCapabilities, isHfColorGradingActive, normalizeHfColorGrading, normalizeHfColorGradingWithVariables, @@ -182,6 +191,8 @@ export { type HfColorGrading, type HfColorGradingAdjust, type HfColorGradingAdjustKey, + type HfColorGradingAnimatablePath, + type HfColorGradingActiveEffectKey, type HfColorGradingDetailKey, type HfColorGradingDetails, type HfColorGradingEffectKey, @@ -191,6 +202,7 @@ export { type HfColorGradingPresetId, type HfColorGradingTarget, type HfColorGradingVariableMap, + type HfColorGradingCapabilities, type NormalizedHfColorGrading, } from "./colorGrading"; export { parseCubeLut, CubeLutParseError, type ParseCubeLutOptions } from "./colorLuts"; diff --git a/packages/core/src/runtime/bridge.test.ts b/packages/core/src/runtime/bridge.test.ts index e5d432dbc4..7f54ebfda4 100644 --- a/packages/core/src/runtime/bridge.test.ts +++ b/packages/core/src/runtime/bridge.test.ts @@ -182,7 +182,7 @@ describe("installRuntimeControlBridge", () => { it("dispatches set-color-grading command with target and grading payload", () => { const deps = createMockDeps(); const handler = installRuntimeControlBridge(deps); - const grading = { preset: "warm-clean", intensity: 0.7 }; + const grading = { preset: "warm-daylight", intensity: 0.7 }; const target = { id: "hero-video", selectorIndex: 0 }; handler(makeControlMessage("set-color-grading", { target, grading })); expect(deps.onSetColorGrading).toHaveBeenCalledWith(target, grading); diff --git a/packages/parsers/package-subpaths.json b/packages/parsers/package-subpaths.json index 1c2d78f15d..2354696e7e 100644 --- a/packages/parsers/package-subpaths.json +++ b/packages/parsers/package-subpaths.json @@ -80,6 +80,12 @@ "types": "./dist/compositionContract.d.ts", "environments": ["browser", "bun", "node"] }, + "./color-grading-contract": { + "source": "./src/colorGradingContract.ts", + "runtime": "./dist/colorGradingContract.js", + "types": "./dist/colorGradingContract.d.ts", + "environments": ["browser", "bun", "node"] + }, "./sub-composition-validity": { "source": "./src/subCompositionValidity.ts", "runtime": "./dist/subCompositionValidity.js", diff --git a/packages/parsers/package.json b/packages/parsers/package.json index b5664f51e0..6e002a46be 100644 --- a/packages/parsers/package.json +++ b/packages/parsers/package.json @@ -88,6 +88,12 @@ "import": "./src/compositionContract.ts", "types": "./src/compositionContract.ts" }, + "./color-grading-contract": { + "bun": "./src/colorGradingContract.ts", + "node": "./dist/colorGradingContract.js", + "import": "./src/colorGradingContract.ts", + "types": "./src/colorGradingContract.ts" + }, "./sub-composition-validity": { "bun": "./src/subCompositionValidity.ts", "node": "./dist/subCompositionValidity.js", @@ -159,6 +165,10 @@ "import": "./dist/compositionContract.js", "types": "./dist/compositionContract.d.ts" }, + "./color-grading-contract": { + "import": "./dist/colorGradingContract.js", + "types": "./dist/colorGradingContract.d.ts" + }, "./sub-composition-validity": { "import": "./dist/subCompositionValidity.js", "types": "./dist/subCompositionValidity.d.ts" diff --git a/packages/parsers/src/colorGradingContract.test.ts b/packages/parsers/src/colorGradingContract.test.ts new file mode 100644 index 0000000000..95c84e7676 --- /dev/null +++ b/packages/parsers/src/colorGradingContract.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + COLOR_GRADING_ADJUST_KEYS, + COLOR_GRADING_DETAIL_KEYS, + COLOR_GRADING_EFFECT_KEYS, + COLOR_GRADING_LUT_KEYS, + COLOR_GRADING_TOP_LEVEL_KEYS, + isColorGradingVariableRef, + validateColorGradingContract, +} from "./colorGradingContract"; + +describe("color grading contract", () => { + it("publishes one complete key registry", () => { + expect(COLOR_GRADING_TOP_LEVEL_KEYS).toContain("effects"); + expect(COLOR_GRADING_ADJUST_KEYS).toContain("exposure"); + expect(COLOR_GRADING_DETAIL_KEYS).toContain("grain"); + expect(COLOR_GRADING_EFFECT_KEYS).toContain("kuwahara"); + expect(COLOR_GRADING_LUT_KEYS).toEqual(["src", "intensity"]); + }); + + it("accepts the complete current contract and variable references", () => { + expect( + validateColorGradingContract({ + enabled: "$enabled", + preset: "clean-studio", + intensity: 0.8, + adjust: { exposure: 0.1, contrast: "$contrast" }, + details: { grain: 0.1 }, + effects: { bloom: 1.2, bloomRadius: 24, asciiStyle: 4 }, + palette: ["#112233", "#abcdef"], + lut: { src: "$lutPath", intensity: 0.5 }, + colorSpace: "rec709", + }), + ).toEqual([]); + expect(isColorGradingVariableRef("${grade.amount}")).toBe(true); + }); + + it("rejects unknown fields, invalid ranges, and malformed palettes", () => { + expect( + validateColorGradingContract({ + mystery: true, + adjust: { exposure: 3, mystery: 1 }, + details: { grain: -0.1 }, + effects: { bloom: 4, bloomRadius: 0 }, + palette: ["red"], + lut: {}, + colorSpace: "display-p3", + }), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "grading" }), + expect.objectContaining({ path: "adjust" }), + expect.objectContaining({ path: "adjust.exposure" }), + expect.objectContaining({ path: "details.grain" }), + expect.objectContaining({ path: "effects.bloom" }), + expect.objectContaining({ path: "effects.bloomRadius" }), + expect.objectContaining({ path: "palette" }), + expect.objectContaining({ path: "lut.src" }), + expect.objectContaining({ path: "colorSpace" }), + ]), + ); + }); +}); diff --git a/packages/parsers/src/colorGradingContract.ts b/packages/parsers/src/colorGradingContract.ts new file mode 100644 index 0000000000..95d99f85ef --- /dev/null +++ b/packages/parsers/src/colorGradingContract.ts @@ -0,0 +1,331 @@ +export const COLOR_GRADING_CONTRACT_VERSION = 1; +export const COLOR_GRADING_COLOR_SPACE = "rec709"; + +export const COLOR_GRADING_TOP_LEVEL_KEYS = [ + "enabled", + "preset", + "intensity", + "adjust", + "details", + "effects", + "palette", + "lut", + "colorSpace", +] as const; + +export const COLOR_GRADING_ADJUST_KEYS = [ + "exposure", + "contrast", + "highlights", + "shadows", + "whites", + "blacks", + "temperature", + "tint", + "vibrance", + "saturation", +] as const; + +export const COLOR_GRADING_DETAIL_KEYS = [ + "vignette", + "vignetteMidpoint", + "vignetteRoundness", + "vignetteFeather", + "grain", + "grainSize", + "grainRoughness", +] as const; + +export const COLOR_GRADING_EFFECT_KEYS = [ + "blur", + "pixelate", + "chromaBleed", + "tapeDamage", + "tapeTracking", + "tapeNoise", + "tapeSpeed", + "filmArtifacts", + "halftone", + "halftoneSize", + "twoInkPrint", + "twoInkPrintSize", + "ascii", + "asciiSize", + "asciiInvert", + "asciiStyle", + "asciiColor", + "asciiRotation", + "dither", + "ditherSize", + "bloom", + "bloomRadius", + "monoScreen", + "monoScreenSize", + "monoScreenAngle", + "monoScreenSpread", + "monoScreenShape", + "monoScreenInvert", + "scanlines", + "scanlineCount", + "scanlineSoftness", + "chromaticAberration", + "chromaticAngle", + "crtCurvature", + "digitalGlitch", + "digitalGlitchColorSplit", + "digitalGlitchLineTear", + "digitalGlitchPixelate", + "digitalGlitchBlockAmount", + "digitalGlitchBlockDisplacement", + "digitalGlitchBlockOpacity", + "digitalGlitchSpeed", + "engraving", + "engravingSpacing", + "engravingMinThickness", + "engravingMaxThickness", + "engravingAngle", + "engravingContrast", + "engravingSharpness", + "engravingWave", + "engravingWaveFrequency", + "crosshatch", + "crosshatchSpacing", + "crosshatchThickness", + "crosshatchAngle", + "crosshatchContrast", + "crosshatchEdges", + "crosshatchLineWeight", + "crosshatchWave", + "crosshatchWaveFrequency", + "kuwahara", + "kuwaharaRadius", + "kuwaharaSharpness", + "kuwaharaSaturation", +] as const; + +export const COLOR_GRADING_LUT_KEYS = ["src", "intensity"] as const; + +type NumericLimit = Readonly<{ min: number; max: number }>; + +const UNIT_LIMIT: NumericLimit = { min: 0, max: 1 }; +const SIGNED_UNIT_LIMIT: NumericLimit = { min: -1, max: 1 }; +const EFFECT_LIMIT_OVERRIDES: Readonly> = { + asciiStyle: { min: 0, max: 7 }, + bloom: { min: 0, max: 3 }, + bloomRadius: { min: 1, max: 100 }, + monoScreenShape: { min: 0, max: 4 }, +}; +const VARIABLE_REF = /^\$(?:\{[A-Za-z0-9_.:-]+\}|[A-Za-z0-9_.:-]+)$/; +const PALETTE_COLOR = /^#[0-9a-f]{6}$/i; + +const OBJECT_SECTIONS = [ + ["adjust", COLOR_GRADING_ADJUST_KEYS], + ["details", COLOR_GRADING_DETAIL_KEYS], + ["effects", COLOR_GRADING_EFFECT_KEYS], + ["lut", COLOR_GRADING_LUT_KEYS], +] as const; + +export interface ColorGradingContractIssue { + path: string; + message: string; + hint?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isColorGradingVariableRef(value: unknown): value is string { + return typeof value === "string" && VARIABLE_REF.test(value.trim()); +} + +function validateObject( + value: unknown, + path: string, + keys: readonly string[], + issues: ColorGradingContractIssue[], +): Record | null { + if (isColorGradingVariableRef(value)) return null; + if (!isRecord(value)) { + issues.push({ path, message: "must be an object or variable reference" }); + return null; + } + const allowed = new Set(keys); + const unknown = Object.keys(value).filter((key) => !allowed.has(key)); + if (unknown.length > 0) { + issues.push({ path, message: `has unsupported key(s): ${unknown.join(", ")}` }); + } + return value; +} + +function validateNumericField( + value: Record, + key: string, + path: string, + limit: NumericLimit, + issues: ColorGradingContractIssue[], +): void { + const candidate = value[key]; + if (candidate === undefined || isColorGradingVariableRef(candidate)) return; + if ( + typeof candidate !== "number" || + !Number.isFinite(candidate) || + candidate < limit.min || + candidate > limit.max + ) { + issues.push({ + path: path ? `${path}.${key}` : key, + message: `must be a finite number from ${limit.min} through ${limit.max}`, + }); + } +} + +function validateNumericSection( + value: Record, + path: string, + keys: readonly string[], + limitFor: (key: string) => NumericLimit, + issues: ColorGradingContractIssue[], +): void { + for (const key of keys) validateNumericField(value, key, path, limitFor(key), issues); +} + +function validatePalette(value: unknown, issues: ColorGradingContractIssue[]): void { + if (value === undefined || value === null || isColorGradingVariableRef(value)) return; + if (!Array.isArray(value) || value.length < 2 || value.length > 6) { + issues.push({ path: "palette", message: "must contain 2 to 6 hex colors" }); + return; + } + value.forEach((color, index) => { + if (typeof color !== "string" || !PALETTE_COLOR.test(color)) { + issues.push({ path: `palette[${index}]`, message: "must be a six-digit hex color" }); + } + }); +} + +function validateLut( + value: unknown, + object: Record | null, + issues: ColorGradingContractIssue[], +): void { + if (typeof value === "string") { + if (!value.trim()) issues.push({ path: "lut", message: "must not be empty" }); + return; + } + if (!object) return; + if ( + !isColorGradingVariableRef(object.src) && + (typeof object.src !== "string" || !object.src.trim()) + ) { + issues.push({ + path: "lut.src", + message: "must be a non-empty string or variable reference", + }); + } + validateNumericField(object, "intensity", "lut", UNIT_LIMIT, issues); +} + +function validateEnabled( + grading: Record, + issues: ColorGradingContractIssue[], +): void { + if ( + grading.enabled !== undefined && + !isColorGradingVariableRef(grading.enabled) && + typeof grading.enabled !== "boolean" + ) { + issues.push({ path: "enabled", message: "must be a boolean or variable reference" }); + } +} + +function validatePreset( + grading: Record, + issues: ColorGradingContractIssue[], +): void { + if ( + grading.preset !== undefined && + grading.preset !== null && + !isColorGradingVariableRef(grading.preset) && + (typeof grading.preset !== "string" || !grading.preset.trim()) + ) { + issues.push({ + path: "preset", + message: "must be a non-empty string, null, or variable reference", + }); + } +} + +function validateColorSpace( + grading: Record, + issues: ColorGradingContractIssue[], +): void { + if ( + grading.colorSpace !== undefined && + !isColorGradingVariableRef(grading.colorSpace) && + grading.colorSpace !== COLOR_GRADING_COLOR_SPACE + ) { + issues.push({ + path: "colorSpace", + message: `must be "${COLOR_GRADING_COLOR_SPACE}" or a variable reference`, + }); + } +} + +function validateTopLevel( + grading: Record, + issues: ColorGradingContractIssue[], +): void { + validateEnabled(grading, issues); + validateNumericField(grading, "intensity", "", UNIT_LIMIT, issues); + validatePreset(grading, issues); + validateColorSpace(grading, issues); +} + +function validateSection( + grading: Record, + key: (typeof OBJECT_SECTIONS)[number][0], + keys: readonly string[], + issues: ColorGradingContractIssue[], +): void { + const section = grading[key]; + if (section === undefined || (key === "lut" && section === null)) return; + if (key === "lut" && typeof section === "string") { + validateLut(section, null, issues); + return; + } + const object = validateObject(section, key, keys, issues); + if (!object) return; + if (key === "lut") return validateLut(section, object, issues); + + const limitFor = (control: string): NumericLimit => { + if (key === "adjust" && control === "exposure") return { min: -2, max: 2 }; + if (key === "adjust" || (key === "details" && control === "vignetteRoundness")) { + return SIGNED_UNIT_LIMIT; + } + return key === "effects" ? (EFFECT_LIMIT_OVERRIDES[control] ?? UNIT_LIMIT) : UNIT_LIMIT; + }; + validateNumericSection(object, key, keys, limitFor, issues); +} + +function validateSections( + grading: Record, + issues: ColorGradingContractIssue[], +): void { + for (const [key, keys] of OBJECT_SECTIONS) validateSection(grading, key, keys, issues); +} + +/** Browser-safe structural validation shared by Lint, CLI, and Core consumers. */ +export function validateColorGradingContract(value: unknown): ColorGradingContractIssue[] { + if (typeof value === "string") { + return value.trim() ? [] : [{ path: "grading", message: "is empty" }]; + } + + const issues: ColorGradingContractIssue[] = []; + const grading = validateObject(value, "grading", COLOR_GRADING_TOP_LEVEL_KEYS, issues); + if (!grading) return issues; + + validateTopLevel(grading, issues); + validateSections(grading, issues); + validatePalette(grading.palette, issues); + return issues; +} diff --git a/packages/parsers/tsup.config.ts b/packages/parsers/tsup.config.ts index 43ace519d9..f32100a336 100644 --- a/packages/parsers/tsup.config.ts +++ b/packages/parsers/tsup.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ assets: "src/assets.ts", composition: "src/composition.ts", compositionContract: "src/compositionContract.ts", + colorGradingContract: "src/colorGradingContract.ts", subCompositionValidity: "src/subCompositionValidity.ts", ffBinaries: "src/ffBinaries.ts", assetResolution: "src/assetResolution.ts", diff --git a/packages/studio/src/components/editor/colorGradingScopePatch.test.ts b/packages/studio/src/components/editor/colorGradingScopePatch.test.ts index 96abf3aff8..caedd802ae 100644 --- a/packages/studio/src/components/editor/colorGradingScopePatch.test.ts +++ b/packages/studio/src/components/editor/colorGradingScopePatch.test.ts @@ -5,15 +5,15 @@ describe("patchMediaColorGradingInHtml", () => { it("adds color grading to video and image tags only", () => { const { html, count } = patchMediaColorGradingInHtml( `
`, - `{"preset":"natural-lift"}`, + `{"preset":"warm-daylight"}`, ); expect(count).toBe(2); expect(html).toContain( - `video id="v" data-color-grading="{"preset":"natural-lift"}"`, + `video id="v" data-color-grading="{"preset":"warm-daylight"}"`, ); expect(html).toContain( - `img id="i" data-color-grading="{"preset":"natural-lift"}"`, + `img id="i" data-color-grading="{"preset":"warm-daylight"}"`, ); expect(html).toContain(``); }); diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx index db615065e9..b2030f9c0d 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx @@ -243,11 +243,11 @@ describe("FlatColorGradingSection — Preset + LUT", () => { // needs its own accessible name via the dedicated ariaLabel prop. expect(presetSelect.getAttribute("aria-label")).toBe("Preset"); act(() => { - presetSelect.value = "fresh-pop"; + presetSelect.value = "bright-pop"; presetSelect.dispatchEvent(new Event("change", { bubbles: true })); }); expect(onCommitColorGrading).toHaveBeenCalledTimes(1); - expect(onCommitColorGrading.mock.calls[0][0].preset).toBe("fresh-pop"); + expect(onCommitColorGrading.mock.calls[0][0].preset).toBe("bright-pop"); act(() => root.unmount()); }); diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx index efde147c23..9c6de843a1 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -1159,20 +1159,20 @@ describe("FlatSelectRow — label/value options", () => { const { host, root } = renderInto( , ); const select = host.querySelector("select"); - expect(select?.value).toBe("natural-lift"); + expect(select?.value).toBe("clean-studio"); const options = Array.from(host.querySelectorAll("option")).map((o) => o.textContent); - expect(options).toEqual(["Neutral", "Natural Lift", "Fresh Pop"]); + expect(options).toEqual(["Neutral", "Clean Studio", "Bright Pop"]); act(() => root.unmount()); }); diff --git a/packages/studio/src/components/editor/useColorGradingController.test.ts b/packages/studio/src/components/editor/useColorGradingController.test.ts index 5e74587e6f..4b4d9376d3 100644 --- a/packages/studio/src/components/editor/useColorGradingController.test.ts +++ b/packages/studio/src/components/editor/useColorGradingController.test.ts @@ -7,15 +7,15 @@ import { normalizeHfColorGrading } from "@hyperframes/core/color-grading"; import { useColorGradingController } from "./useColorGradingController"; import type { DomEditSelection } from "./domEditing"; -function freshPopGrading() { - const next = normalizeHfColorGrading({ preset: "fresh-pop", intensity: 1 }); - if (!next) throw new Error("expected fresh-pop preset to normalize"); +function brightPopGrading() { + const next = normalizeHfColorGrading({ preset: "bright-pop", intensity: 1 }); + if (!next) throw new Error("expected bright-pop preset to normalize"); return next; } -function naturalLiftGrading() { - const next = normalizeHfColorGrading({ preset: "natural-lift", intensity: 1 }); - if (!next) throw new Error("expected natural-lift preset to normalize"); +function warmDaylightGrading() { + const next = normalizeHfColorGrading({ preset: "warm-daylight", intensity: 1 }); + if (!next) throw new Error("expected warm-daylight preset to normalize"); return next; } @@ -120,9 +120,9 @@ describe("useColorGradingController", () => { const onSetAttributeLive = vi.fn(); const { root, getState } = renderHook(onSetAttributeLive); act(() => { - getState().commitColorGrading(freshPopGrading()); + getState().commitColorGrading(brightPopGrading()); }); - expect(getState().grading.preset).toBe("fresh-pop"); + expect(getState().grading.preset).toBe("bright-pop"); expect(onSetAttributeLive).not.toHaveBeenCalled(); act(() => { vi.advanceTimersByTime(400); @@ -130,7 +130,7 @@ describe("useColorGradingController", () => { expect(onSetAttributeLive).toHaveBeenCalledTimes(1); const [attr, value] = onSetAttributeLive.mock.calls[0] as [string, string]; expect(attr).toBe("color-grading"); - expect(value).toContain("fresh-pop"); + expect(value).toContain("bright-pop"); act(() => root.unmount()); vi.useRealTimers(); }); @@ -149,9 +149,9 @@ describe("useColorGradingController", () => { ); const { root, getState } = renderHook(onSetAttributeLive); act(() => { - getState().commitColorGrading(freshPopGrading()); + getState().commitColorGrading(brightPopGrading()); }); - expect(getState().grading.preset).toBe("fresh-pop"); + expect(getState().grading.preset).toBe("bright-pop"); act(() => { vi.advanceTimersByTime(400); }); @@ -170,9 +170,9 @@ describe("useColorGradingController", () => { const onSetAttributeLive = vi.fn().mockRejectedValue(new Error("disk full")); const { root, getState } = renderHook(onSetAttributeLive); act(() => { - getState().commitColorGrading(freshPopGrading()); + getState().commitColorGrading(brightPopGrading()); }); - expect(getState().grading.preset).toBe("fresh-pop"); + expect(getState().grading.preset).toBe("bright-pop"); act(() => { vi.advanceTimersByTime(400); }); @@ -182,7 +182,7 @@ describe("useColorGradingController", () => { await Promise.resolve(); }); // Reverted to "neutral" (the last confirmed-good value, from before this - // commit) instead of permanently showing "fresh-pop" as if it had saved. + // commit) instead of permanently showing "bright-pop" as if it had saved. expect(getState().grading.preset).toBe("neutral"); expect(getState().runtimeStatus.state).toBe("unavailable"); act(() => root.unmount()); @@ -206,7 +206,7 @@ describe("useColorGradingController", () => { makeElement({ id: "s1-bg" }), ); act(() => { - getState().commitColorGrading(freshPopGrading()); + getState().commitColorGrading(brightPopGrading()); }); // Let the debounce fire while still on s1-bg — the persist call is now // genuinely in flight (its promise won't settle until resolveA() below). @@ -243,7 +243,7 @@ describe("useColorGradingController", () => { let capturedOnSettledA: ((ok: boolean) => void) | undefined; const onSetAttributeLive = vi .fn() - // Edit A (fresh-pop): captures its onSettled and never resolves until + // Edit A (bright-pop): captures its onSettled and never resolves until // resolveA() is called below — simulates a slow persist. .mockImplementationOnce( (_attr: string, _value: string | null, onSettled?: (ok: boolean) => void) => { @@ -253,7 +253,7 @@ describe("useColorGradingController", () => { }); }, ) - // Edit B (natural-lift): settles immediately and successfully. + // Edit B (warm-daylight): settles immediately and successfully. .mockImplementationOnce( (_attr: string, _value: string | null, onSettled?: (ok: boolean) => void) => { onSettled?.(true); @@ -263,7 +263,7 @@ describe("useColorGradingController", () => { const { root, getState } = renderHook(onSetAttributeLive); act(() => { - getState().commitColorGrading(freshPopGrading()); + getState().commitColorGrading(brightPopGrading()); }); act(() => { vi.advanceTimersByTime(400); @@ -272,13 +272,13 @@ describe("useColorGradingController", () => { // B commits on the SAME element before A's persist has settled. act(() => { - getState().commitColorGrading(naturalLiftGrading()); + getState().commitColorGrading(warmDaylightGrading()); }); act(() => { vi.advanceTimersByTime(400); }); expect(onSetAttributeLive).toHaveBeenCalledTimes(2); // B's persist has already settled (mock resolves sync) - expect(getState().grading.preset).toBe("natural-lift"); + expect(getState().grading.preset).toBe("warm-daylight"); // NOW A's stale persist finally settles as a FAILURE — must not revert // `grading` (which now correctly shows B's newer edit) back to the @@ -292,7 +292,7 @@ describe("useColorGradingController", () => { await Promise.resolve(); await Promise.resolve(); }); - expect(getState().grading.preset).toBe("natural-lift"); + expect(getState().grading.preset).toBe("warm-daylight"); act(() => root.unmount()); vi.useRealTimers(); }); @@ -300,7 +300,7 @@ describe("useColorGradingController", () => { it("resetGrading returns to the neutral preset", () => { const { root, getState } = renderHook(vi.fn()); act(() => { - getState().commitColorGrading(freshPopGrading()); + getState().commitColorGrading(brightPopGrading()); }); act(() => { getState().resetGrading(); @@ -315,9 +315,9 @@ describe("useColorGradingController", () => { makeElement({ id: "s1-bg" }), ); act(() => { - getState().commitColorGrading(freshPopGrading()); + getState().commitColorGrading(brightPopGrading()); }); - expect(getState().grading.preset).toBe("fresh-pop"); + expect(getState().grading.preset).toBe("bright-pop"); // A different element, with no persisted grading of its own — without a // reset, this hook (unlike the legacy component it was extracted from, // which remounts via a `key={selectionIdentityKey}`) would keep showing @@ -337,9 +337,9 @@ describe("useColorGradingController", () => { makeElement({ id: "bg", sourceFile: "index.html" }), ); act(() => { - getState().commitColorGrading(freshPopGrading()); + getState().commitColorGrading(brightPopGrading()); }); - expect(getState().grading.preset).toBe("fresh-pop"); + expect(getState().grading.preset).toBe("bright-pop"); rerenderWithElement(makeElement({ id: "bg", sourceFile: "sub-comp.html" })); expect(getState().grading.preset).toBe("neutral"); act(() => root.unmount()); @@ -353,7 +353,7 @@ describe("useColorGradingController", () => { makeElement({ id: "s1-bg" }), ); act(() => { - getState().commitColorGrading(freshPopGrading()); + getState().commitColorGrading(brightPopGrading()); }); // Switch selection before the 350ms debounce fires — the in-flight edit // must be written immediately (targeting the OUTGOING element's own @@ -366,7 +366,7 @@ describe("useColorGradingController", () => { expect(onSetAttributeLive).toHaveBeenCalledTimes(1); const [attr, value] = onSetAttributeLive.mock.calls[0] as [string, string]; expect(attr).toBe("color-grading"); - expect(value).toContain("fresh-pop"); + expect(value).toContain("bright-pop"); // And it must not ALSO fire again once the (now-cleared) original timer // window would have elapsed. act(() => { diff --git a/skills-manifest.json b/skills-manifest.json index d40131224b..d65353ba66 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "f7f0ad56a0477a09", + "hash": "6b94dc295e2d6ba0", "files": 145 }, "motion-graphics": { diff --git a/skills/media-use/scripts/lib/lut-preset-provider.mjs b/skills/media-use/scripts/lib/lut-preset-provider.mjs index 5807f163f4..3eaac6f68b 100644 --- a/skills/media-use/scripts/lib/lut-preset-provider.mjs +++ b/skills/media-use/scripts/lib/lut-preset-provider.mjs @@ -11,12 +11,10 @@ const LUT_DIR = join(SKILL_DIR, "luts"); const LUT_INDEX = join(LUT_DIR, "index.json"); export const LIBRARY_LUT_OFFLINE_CODE = "MEDIA_USE_LIBRARY_LUT_OFFLINE"; -// Mirrored from packages/core/src/colorGrading.ts HfColorGradingPresetId. -// Keep this list in lockstep with the core runtime contract. -export const CORE_PRESET_IDS = [ +// Presets this released resolver can safely emit at this stack layer. +// Effect-backed treatment presets join after their runtime support lands. +export const RESOLVABLE_PRESET_IDS = [ "neutral", - "natural-lift", - "fresh-pop", "warm-daylight", "clean-studio", "skin-soft", @@ -26,8 +24,6 @@ export const CORE_PRESET_IDS = [ "vintage-wash", "mono-clean", "mono-fade", - "warm-clean", - "cool-clean", "soft-boost", "bright-pop", "deep-contrast", @@ -35,16 +31,25 @@ export const CORE_PRESET_IDS = [ const PRESET_SYNONYMS = { neutral: ["neutral", "identity", "none", "ungraded", "natural base"], - "natural-lift": ["natural lift", "natural light", "gentle lift", "soft natural"], - "fresh-pop": ["fresh pop", "fresh", "bright fresh", "clean colorful"], "warm-daylight": [ "warm daylight", "warm natural light", "golden daylight", "sunlit", "warm sunny", + "warm clean", + "clean warm", + "warm product", + ], + "clean-studio": [ + "clean studio", + "studio clean", + "cool studio", + "product studio", + "cool clean", + "clean cool", + "cool crisp", ], - "clean-studio": ["clean studio", "studio clean", "cool studio", "product studio"], "skin-soft": ["skin soft", "soft skin", "portrait soft", "beauty skin"], "food-pop": ["food pop", "food vibrant", "appetizing", "restaurant color"], "night-lift": ["night lift", "night", "low light lift", "city night"], @@ -52,15 +57,29 @@ const PRESET_SYNONYMS = { "vintage-wash": ["vintage wash", "vintage", "retro wash", "aged film"], "mono-clean": ["mono clean", "black white clean", "monochrome clean"], "mono-fade": ["mono fade", "black white fade", "faded monochrome"], - "warm-clean": ["warm clean", "clean warm", "warm product"], - "cool-clean": ["cool clean", "clean cool", "cool crisp"], - "soft-boost": ["soft boost", "soft bright", "gentle boost"], - "bright-pop": ["bright pop", "bright punchy", "vivid bright"], + "soft-boost": [ + "soft boost", + "soft bright", + "gentle boost", + "natural lift", + "natural light", + "gentle lift", + "soft natural", + ], + "bright-pop": [ + "bright pop", + "bright punchy", + "vivid bright", + "fresh pop", + "fresh", + "bright fresh", + "clean colorful", + ], "deep-contrast": ["deep contrast", "high contrast punchy", "punchy contrast", "bold contrast"], }; function presetCandidates() { - return CORE_PRESET_IDS.map((id) => ({ + return RESOLVABLE_PRESET_IDS.map((id) => ({ kind: "preset", preset: id, synonyms: PRESET_SYNONYMS[id] ?? [], diff --git a/skills/media-use/scripts/lib/lut-preset-provider.test.mjs b/skills/media-use/scripts/lib/lut-preset-provider.test.mjs index 162bf9f600..f63d214d3e 100644 --- a/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +++ b/skills/media-use/scripts/lib/lut-preset-provider.test.mjs @@ -4,8 +4,8 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { test } from "node:test"; import { - CORE_PRESET_IDS, LIBRARY_LUT_OFFLINE_CODE, + RESOLVABLE_PRESET_IDS, freezeLibraryLut, matchColorLook, readBundledLutIndex, @@ -35,6 +35,13 @@ test("high contrast punchy resolves to deep-contrast", () => { assert.equal(matchColorLook("high contrast punchy").preset, "deep-contrast"); }); +test("removed preset phrases resolve to surviving looks", () => { + assert.equal(matchColorLook("natural lift").preset, "soft-boost"); + assert.equal(matchColorLook("fresh pop").preset, "bright-pop"); + assert.equal(matchColorLook("warm clean").preset, "warm-daylight"); + assert.equal(matchColorLook("cool clean").preset, "clean-studio"); +}); + test("library look freezes a validated cube from params offline (--local-only)", async () => { const projectDir = mkdtempSync(join(tmpdir(), "mu-lut-provider-")); try { @@ -53,9 +60,13 @@ test("library look freezes a validated cube from params offline (--local-only)", } }); -test("preset IDs stay in sync with packages/core/src/colorGrading.ts", () => { - assert.deepEqual(CORE_PRESET_IDS, corePresetIdsFromSource()); - for (const id of CORE_PRESET_IDS) { +test("every resolver preset exists in packages/core/src/colorGrading.ts", () => { + const corePresetIds = corePresetIdsFromSource(); + assert.deepEqual( + RESOLVABLE_PRESET_IDS.filter((id) => !corePresetIds.includes(id)), + [], + ); + for (const id of RESOLVABLE_PRESET_IDS) { const match = matchColorLook(id); assert.equal(match.kind, "preset"); assert.equal(match.preset, id);