feat(studio): add color grading inspector controls

This commit is contained in:
ukimsanov
2026-06-16 13:41:41 -07:00
parent d1162cd1b1
commit 8b92f37635
28 changed files with 1433 additions and 179 deletions
@@ -14,11 +14,19 @@ import { MetricField, Section } from "./propertyPanelPrimitives";
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
import {
ColorGradingSection,
isColorGradingCapableElement,
} from "./propertyPanelColorGradingSection";
import { TextSection, StyleSections } from "./propertyPanelSections";
import { GsapAnimationSection } from "./GsapAnimationSection";
import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
import { KeyframeNavigation } from "./KeyframeNavigation";
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
import {
STUDIO_COLOR_GRADING_ENABLED,
STUDIO_GSAP_PANEL_ENABLED,
STUDIO_KEYFRAMES_ENABLED,
} from "./manualEditingAvailability";
import { usePlayerStore, liveTime } from "../../player";
import { TimingSection } from "./propertyPanelTimingSection";
import { type PropertyPanelProps } from "./propertyPanelHelpers";
@@ -47,6 +55,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onClearSelection,
onSetStyle,
onSetAttribute,
onSetAttributeLive,
onSetHtmlAttribute,
onSetManualOffset,
onSetManualSize,
@@ -355,6 +364,16 @@ export const PropertyPanel = memo(function PropertyPanel({
/>
)}
{STUDIO_COLOR_GRADING_ENABLED && isColorGradingCapableElement(element) && (
<ColorGradingSection
element={element}
assets={assets}
previewIframeRef={previewIframeRef}
onImportAssets={onImportAssets}
onSetAttributeLive={onSetAttributeLive}
/>
)}
<Section title="Layout" icon={<Move size={15} />}>
<div className={RESPONSIVE_GRID}>
<div className="flex items-center gap-1">
@@ -1,4 +1,5 @@
import { type DomEditSelection, findElementForSelection } from "./domEditing";
import { isElementVisibleThroughAncestors } from "./domEditingDom";
export interface OverlayRect {
left: number;
@@ -21,17 +22,7 @@ export type ResolvedElementRef = {
};
export function isElementVisibleForOverlay(el: HTMLElement): boolean {
const win = el.ownerDocument.defaultView;
if (!win) return true;
let current: HTMLElement | null = el;
while (current) {
const computed = win.getComputedStyle(current);
if (computed.display === "none" || computed.visibility === "hidden") return false;
const opacity = Number.parseFloat(computed.opacity);
if (Number.isFinite(opacity) && opacity <= 0.01) return false;
current = current.parentElement;
}
return true;
return isElementVisibleThroughAncestors(el);
}
function readPositiveDimension(value: string | null): number | null {
@@ -57,6 +57,27 @@ export function isTextBearingTag(tagName: string): boolean {
return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
}
const COLOR_GRADING_SOURCE_HIDDEN_ATTR = "data-hf-color-grading-source-hidden";
export function isElementVisibleThroughAncestors(el: HTMLElement): boolean {
const win = el.ownerDocument.defaultView;
if (!win) return true;
let current: HTMLElement | null = el;
while (current) {
const computed = win.getComputedStyle(current);
if (computed.display === "none" || computed.visibility === "hidden") return false;
const opacity = Number.parseFloat(computed.opacity);
if (
Number.isFinite(opacity) &&
opacity <= 0.01 &&
!current.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR)
)
return false;
current = current.parentElement;
}
return true;
}
// ─── Style accessors ──────────────────────────────────────────────────────────
export function getCuratedComputedStyles(el: HTMLElement): Record<string, string> {
@@ -15,6 +15,7 @@ import {
getSelectorIndex,
getSourceFileForElement,
isHtmlElement,
isElementVisibleThroughAncestors,
normalizeTimelineCompositionSource,
querySelectorAllSafely,
} from "./domEditingDom";
@@ -22,17 +23,7 @@ import {
// ─── Visibility ──────────────────────────────────────────────────────────────
export function isElementComputedVisible(el: HTMLElement): boolean {
const win = el.ownerDocument.defaultView;
if (!win) return true;
let current: HTMLElement | null = el;
while (current) {
const computed = win.getComputedStyle(current);
if (computed.display === "none" || computed.visibility === "hidden") return false;
const opacity = Number.parseFloat(computed.opacity);
if (Number.isFinite(opacity) && opacity <= 0.01) return false;
current = current.parentElement;
}
return true;
return isElementVisibleThroughAncestors(el);
}
const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
@@ -29,6 +29,18 @@ describe("manual editing availability", () => {
expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(true);
});
it("keeps color grading off by default", async () => {
const availability = await loadAvailabilityWithEnv({});
expect(availability.STUDIO_COLOR_GRADING_ENABLED).toBe(false);
});
it("enables color grading with an explicit env flag", async () => {
const availability = await loadAvailabilityWithEnv({
VITE_STUDIO_ENABLE_COLOR_GRADING: "1",
});
expect(availability.STUDIO_COLOR_GRADING_ENABLED).toBe(true);
});
it("disables GSAP drag intercept when env var is false", async () => {
const availability = await loadAvailabilityWithEnv({
VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT: "false",
@@ -64,6 +64,12 @@ export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
true,
);
export const STUDIO_COLOR_GRADING_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_ENABLE_COLOR_GRADING", "VITE_STUDIO_COLOR_GRADING_ENABLED"],
false,
);
export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"],
@@ -0,0 +1,493 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
HF_COLOR_GRADING_PRESETS,
normalizeHfColorGrading,
type HfColorGradingAdjustKey,
type NormalizedHfColorGrading,
} from "@hyperframes/core/color-grading";
import { Minus, Plus, RotateCcw } from "../../icons/SystemIcons";
import { LUT_EXT } from "../../utils/mediaTypes";
import { LABEL } from "./propertyPanelHelpers";
const LUT_UPLOAD_DIR = "assets/luts";
const SLIDER_THUMB_SIZE = 10;
const SLIDER_THUMB_RADIUS = SLIDER_THUMB_SIZE / 2;
const SLIDERS: Array<{
key: HfColorGradingAdjustKey;
label: string;
min: number;
max: number;
step: number;
scale: number;
suffix: string;
}> = [
{ key: "exposure", label: "Exposure", min: -200, max: 200, step: 5, scale: 100, suffix: "" },
{ key: "contrast", label: "Contrast", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
{
key: "highlights",
label: "Highlights",
min: -100,
max: 100,
step: 1,
scale: 100,
suffix: "%",
},
{ key: "shadows", label: "Shadows", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
{ key: "whites", label: "Whites", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
{ key: "blacks", label: "Blacks", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
{ key: "temperature", label: "Warmth", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
{ key: "tint", label: "Tint", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
{ key: "saturation", label: "Saturation", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
];
function formatPercent(value: number): string {
return `${Math.round(value)}%`;
}
function formatExposure(value: number): string {
const stops = value / 100;
return `${stops > 0 ? "+" : ""}${stops.toFixed(2)}`;
}
function fileLabel(path: string): string {
return path.split("/").pop() ?? path;
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
function formatNumericInput(value: number, scale: number): string {
const scaled = value / scale;
return scale === 100 ? scaled.toFixed(2) : String(Math.round(scaled));
}
function parseNumericInput(value: string, scale: number): number | null {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return null;
return parsed * scale;
}
function buildSliderTicks(min: number, max: number, neutral: number): number[] {
const span = max - min;
if (span <= 0) return [];
const step = span <= 200 ? 50 : span / 4;
const ticks = new Set<number>([min, max, neutral]);
for (let value = min; value <= max + step / 2; value += step) {
ticks.add(Math.round(value));
}
return Array.from(ticks).sort((a, b) => a - b);
}
function tickPercent(value: number, min: number, max: number): number {
if (max <= min) return 0;
return ((value - min) / (max - min)) * 100;
}
function ColorGradingSliderControl({
label,
value,
min,
max,
step,
neutral = min,
scale = 1,
suffix = "",
displayValue,
disabled,
onCommit,
onReset,
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
neutral?: number;
scale?: number;
suffix?: string;
displayValue: string;
disabled?: boolean;
onCommit: (nextValue: number) => void;
onReset?: () => void;
}) {
const [draft, setDraft] = useState(value);
const [inputDraft, setInputDraft] = useState(() => formatNumericInput(value, scale));
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const valueRef = useRef(value);
const draftRef = useRef(value);
valueRef.current = value;
draftRef.current = draft;
useEffect(() => {
setDraft(value);
draftRef.current = value;
setInputDraft(formatNumericInput(value, scale));
}, [scale, value]);
useEffect(
() => () => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
},
[],
);
const clampDraft = useCallback(
(nextValue: number) => clampNumber(nextValue, min, max),
[max, min],
);
const commitDraft = useCallback(
(nextValue: number) => {
const clamped = clampDraft(nextValue);
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
setDraft(clamped);
draftRef.current = clamped;
setInputDraft(formatNumericInput(clamped, scale));
if (clamped !== valueRef.current) onCommit(clamped);
},
[clampDraft, onCommit, scale],
);
const scheduleCommit = useCallback(
(nextValue: number) => {
const clamped = clampDraft(nextValue);
setDraft(clamped);
draftRef.current = clamped;
setInputDraft(formatNumericInput(clamped, scale));
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
commitTimerRef.current = setTimeout(() => {
if (clamped !== valueRef.current) onCommit(clamped);
}, 40);
},
[clampDraft, onCommit, scale],
);
const commitInputDraft = useCallback(() => {
const parsed = parseNumericInput(inputDraft, scale);
if (parsed === null) {
setInputDraft(formatNumericInput(draft, scale));
return;
}
commitDraft(parsed);
}, [commitDraft, draft, inputDraft, scale]);
const nudge = useCallback(
(direction: -1 | 1) => {
commitDraft(draftRef.current + step * direction);
},
[commitDraft, step],
);
const range = max - min;
const valuePercent = range === 0 ? 0 : ((draft - min) / range) * 100;
const neutralPercent = range === 0 ? 0 : ((neutral - min) / range) * 100;
const fillLeft = Math.min(valuePercent, neutralPercent);
const fillWidth = Math.abs(valuePercent - neutralPercent);
const ticks = buildSliderTicks(min, max, neutral);
return (
<div className="grid min-w-0 gap-1.5 rounded-md bg-panel-input/30 p-2">
<div className="flex min-w-0 items-center gap-1.5">
<span className={`${LABEL} min-w-0 flex-1 truncate`}>{label}</span>
{onReset && (
<button
type="button"
disabled={disabled}
aria-label={`Reset ${label}`}
onClick={(event) => {
event.stopPropagation();
onReset();
}}
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-panel-text-5 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
title={`Reset ${label}`}
>
<RotateCcw size={11} />
</button>
)}
</div>
<div className="relative h-7 min-w-0">
<div
data-color-grading-slider-track="true"
className="pointer-events-none absolute inset-y-0 z-0"
style={{ left: SLIDER_THUMB_RADIUS, right: SLIDER_THUMB_RADIUS }}
>
{ticks.map((tick) => (
<div
key={tick}
data-color-grading-slider-tick="true"
className="absolute top-1/2 h-3 w-px -translate-y-1/2 bg-panel-text-3"
style={{ left: `${tickPercent(tick, min, max)}%` }}
title={String(tick / scale)}
/>
))}
<div className="absolute left-0 right-0 top-1/2 z-10 h-0.5 -translate-y-1/2 rounded-full bg-panel-border" />
<div
className="absolute top-1/2 z-20 h-0.5 -translate-y-1/2 rounded-full bg-studio-accent"
style={{ left: `${fillLeft}%`, width: `${fillWidth}%` }}
/>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={draft}
disabled={disabled}
onChange={(event) => scheduleCommit(Number(event.currentTarget.value))}
onMouseUp={() => commitDraft(draft)}
onTouchEnd={() => commitDraft(draft)}
onBlur={() => commitDraft(draft)}
className="hf-color-grading-range absolute left-0 right-0 top-1/2 z-30 min-w-0 w-full -translate-y-1/2"
title={displayValue}
/>
</div>
<div className="flex min-w-0 items-center justify-end gap-1.5">
<div className="flex flex-shrink-0 items-center rounded-md bg-panel-input px-1.5 py-1">
<input
type="number"
value={inputDraft}
min={min / scale}
max={max / scale}
step={step / scale}
disabled={disabled}
onChange={(event) => setInputDraft(event.currentTarget.value)}
onBlur={commitInputDraft}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
nudge(1);
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
nudge(-1);
}
}}
className="hf-color-grading-number h-5 w-[38px] bg-transparent text-right text-[11px] font-medium tabular-nums text-panel-text-1 outline-none disabled:cursor-not-allowed"
title={displayValue}
/>
{suffix && <span className="ml-0.5 text-[10px] text-panel-text-5">{suffix}</span>}
</div>
<div className="flex flex-shrink-0 overflow-hidden rounded-md bg-panel-input">
<button
type="button"
disabled={disabled}
aria-label={`Decrease ${label}`}
onClick={() => nudge(-1)}
className="flex h-7 w-5 items-center justify-center text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
title={`Decrease ${label}`}
>
<Minus size={11} />
</button>
<button
type="button"
disabled={disabled}
aria-label={`Increase ${label}`}
onClick={() => nudge(1)}
className="flex h-7 w-5 items-center justify-center border-l border-panel-border text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
title={`Increase ${label}`}
>
<Plus size={11} />
</button>
</div>
</div>
</div>
);
}
export function ColorGradingControls({
grading,
assets,
defaultColorGrading,
onImportAssets,
onCommitColorGrading,
}: {
grading: NormalizedHfColorGrading;
assets: string[];
defaultColorGrading: NormalizedHfColorGrading;
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
onCommitColorGrading: (nextGrading: NormalizedHfColorGrading) => void;
}) {
const lutInputRef = useRef<HTMLInputElement>(null);
const lutAssets = useMemo(
() => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)),
[assets],
);
const selectedLut = grading.lut?.src ?? "";
const selectedProjectLut = selectedLut ? fileLabel(selectedLut) : null;
const applyPreset = (preset: string) => {
const next = normalizeHfColorGrading({ preset, intensity: 1 }) ?? defaultColorGrading;
onCommitColorGrading(next);
};
const applyLut = (src: string | null, intensity = 1) => {
onCommitColorGrading({
...grading,
intensity: 1,
lut: src ? { src, intensity } : null,
});
};
const updateLutIntensity = (value: number) => {
if (!grading.lut) return;
applyLut(grading.lut.src, value / 100);
};
const importLuts = async (files: FileList | null) => {
if (!files?.length || !onImportAssets) return;
const uploaded = await onImportAssets(files, LUT_UPLOAD_DIR);
const firstLut = uploaded.find((asset) => LUT_EXT.test(asset));
if (firstLut) applyLut(firstLut, 1);
};
return (
<div className="space-y-4">
<label className="grid min-w-0 gap-1.5">
<span className={LABEL}>Preset</span>
<select
value={String(grading.preset ?? "neutral")}
onChange={(event) => applyPreset(event.target.value)}
className="w-full min-w-0 rounded-md bg-panel-input px-3 py-2 text-[11px] font-medium text-panel-text-1 outline-none"
>
{HF_COLOR_GRADING_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</select>
</label>
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>LUT Filter</span>
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_28px] gap-2">
<select
value={selectedLut}
onChange={(event) => {
const nextSrc = event.target.value;
applyLut(
nextSrc || null,
nextSrc && grading.lut?.src === nextSrc ? grading.lut.intensity : 1,
);
}}
className="w-full min-w-0 rounded-md bg-panel-input px-3 py-2 text-[11px] font-medium text-panel-text-1 outline-none"
title="Uploaded .cube LUT filter"
>
<option value="">None</option>
{lutAssets.length > 0 && (
<optgroup label="Uploaded LUTs">
{lutAssets.map((asset) => (
<option key={asset} value={asset}>
{fileLabel(asset)}
</option>
))}
</optgroup>
)}
</select>
<button
type="button"
disabled={!onImportAssets}
onClick={(event) => {
event.stopPropagation();
lutInputRef.current?.click();
}}
className="flex h-8 w-8 items-center justify-center rounded-md bg-panel-input text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
title="Import .cube LUT"
aria-label="Import .cube LUT"
>
<Plus size={13} />
</button>
<input
ref={lutInputRef}
type="file"
accept=".cube"
multiple
className="hidden"
onChange={(event) => {
void importLuts(event.currentTarget.files);
event.currentTarget.value = "";
}}
/>
</div>
{grading.lut && (
<div className="grid gap-2">
{selectedProjectLut && (
<div className="flex min-w-0 items-start gap-2 text-[10px] leading-4 text-panel-text-3">
<span className="mt-[5px] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-studio-accent" />
<span className="min-w-0">
<span className="font-medium text-panel-text-2">Uploaded LUT</span>
{` · ${selectedProjectLut}`}
</span>
</div>
)}
<ColorGradingSliderControl
label="LUT Strength"
value={Math.round((grading.lut.intensity ?? 1) * 100)}
min={0}
max={100}
step={1}
neutral={0}
suffix="%"
displayValue={formatPercent((grading.lut.intensity ?? 1) * 100)}
onCommit={updateLutIntensity}
onReset={() => updateLutIntensity(100)}
/>
</div>
)}
</div>
<div className="grid min-w-0 grid-cols-2 gap-3">
{SLIDERS.map((slider) => {
const value = grading.adjust[slider.key] * slider.scale;
const isExposure = slider.key === "exposure";
return (
<div
key={slider.key}
className={
SLIDERS.length % 2 === 1 && slider.key === "saturation" ? "col-span-2" : ""
}
>
<ColorGradingSliderControl
label={slider.label}
value={Math.round(value)}
min={slider.min}
max={slider.max}
step={slider.step}
neutral={0}
scale={isExposure ? 100 : 1}
suffix={isExposure ? "" : slider.suffix}
displayValue={isExposure ? formatExposure(value) : formatPercent(value)}
onCommit={(next) => {
onCommitColorGrading({
...grading,
intensity: 1,
adjust: {
...grading.adjust,
[slider.key]: next / slider.scale,
},
});
}}
onReset={() => {
onCommitColorGrading({
...grading,
intensity: 1,
adjust: {
...grading.adjust,
[slider.key]: 0,
},
});
}}
/>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,395 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type RefObject,
} from "react";
import {
HF_COLOR_GRADING_ATTR,
HF_COLOR_GRADING_COLOR_SPACE,
isHfColorGradingActive,
normalizeHfColorGrading,
serializeHfColorGrading,
type HfColorGradingAdjustKey,
type HfColorGradingTarget,
type NormalizedHfColorGrading,
} from "@hyperframes/core/color-grading";
import { Compare, Palette, RotateCcw } from "../../icons/SystemIcons";
import type { DomEditSelection } from "./domEditing";
import { ColorGradingControls } from "./propertyPanelColorGradingControls";
import { Section } from "./propertyPanelPrimitives";
const DEFAULT_ADJUST: Record<HfColorGradingAdjustKey, number> = {
exposure: 0,
contrast: 0,
highlights: 0,
shadows: 0,
whites: 0,
blacks: 0,
temperature: 0,
tint: 0,
saturation: 0,
};
const DEFAULT_COLOR_GRADING: NormalizedHfColorGrading = {
enabled: true,
preset: "neutral",
intensity: 1,
adjust: DEFAULT_ADJUST,
lut: null,
colorSpace: HF_COLOR_GRADING_COLOR_SPACE,
};
interface ColorGradingCompareState {
enabled: boolean;
}
const DEFAULT_COMPARE: ColorGradingCompareState = {
enabled: false,
};
const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, "");
type RuntimeColorGradingStatusState = "missing" | "inactive" | "pending" | "active" | "unavailable";
interface RuntimeColorGradingStatus {
state: RuntimeColorGradingStatusState;
message: string;
}
type RuntimeColorGradingWindow = Window & {
__hf?: {
colorGrading?: {
getStatus?: (
target: HfColorGradingTarget | string | null | undefined,
) => RuntimeColorGradingStatus;
};
};
};
export function isColorGradingCapableElement(element: DomEditSelection): boolean {
return element.tagName === "video" || element.tagName === "img";
}
function readColorGradingFromElement(element: DomEditSelection): NormalizedHfColorGrading {
const grading =
normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ??
DEFAULT_COLOR_GRADING;
return { ...grading, intensity: 1 };
}
function toBridgeColorGrading(grading: NormalizedHfColorGrading): unknown {
if (!isHfColorGradingActive(grading)) return null;
return {
preset: grading.preset,
intensity: grading.intensity,
adjust: grading.adjust,
lut: grading.lut,
colorSpace: grading.colorSpace,
};
}
function readRuntimeColorGradingStatus(
iframe: HTMLIFrameElement | null | undefined,
target: HfColorGradingTarget,
): RuntimeColorGradingStatus {
try {
const win = iframe?.contentWindow as RuntimeColorGradingWindow | null | undefined;
const status = win?.__hf?.colorGrading?.getStatus?.(target);
return status ?? { state: "pending", message: "Waiting for runtime" };
} catch {
return { state: "unavailable", message: "Preview unavailable" };
}
}
function StatusPill({ status }: { status: RuntimeColorGradingStatus }) {
const dotClass =
status.state === "active"
? "bg-emerald-400"
: status.state === "pending"
? "bg-amber-300"
: status.state === "unavailable"
? "bg-red-400"
: "bg-panel-text-5";
return (
<div className="flex min-w-0 items-center gap-1.5 rounded bg-panel-input px-2 py-1 text-[10px] font-medium text-panel-text-3">
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${dotClass}`} />
<span className="truncate">{status.message}</span>
</div>
);
}
function HoldBeforeButton({
active,
disabled,
onHoldChange,
}: {
active: boolean;
disabled: boolean;
onHoldChange: (holding: boolean) => void;
}) {
const startHold = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (disabled) return;
event.preventDefault();
event.stopPropagation();
onHoldChange(true);
const release = () => {
onHoldChange(false);
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
window.removeEventListener("mouseup", release);
window.removeEventListener("blur", release);
};
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
window.addEventListener("mouseup", release);
window.addEventListener("blur", release);
};
const stopHold = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (disabled) return;
event.preventDefault();
event.stopPropagation();
onHoldChange(false);
};
return (
<button
type="button"
disabled={disabled}
aria-pressed={active}
aria-label="Hold to show original"
onPointerDown={startHold}
onPointerUp={stopHold}
onPointerCancel={stopHold}
onBlur={() => {
if (active) onHoldChange(false);
}}
onKeyDown={(event) => {
if (disabled || (event.key !== " " && event.key !== "Enter")) return;
event.preventDefault();
if (!active) onHoldChange(true);
}}
onKeyUp={(event) => {
if (disabled || (event.key !== " " && event.key !== "Enter")) return;
event.preventDefault();
onHoldChange(false);
}}
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded transition-colors ${
active
? "bg-studio-accent text-black"
: "text-panel-text-4 hover:bg-panel-hover hover:text-panel-text-1"
} disabled:cursor-not-allowed disabled:opacity-40`}
title="Hold to show original"
>
<Compare size={13} />
</button>
);
}
export function ColorGradingSection({
element,
assets,
previewIframeRef,
onImportAssets,
onSetAttributeLive,
}: {
element: DomEditSelection;
assets: string[];
previewIframeRef?: RefObject<HTMLIFrameElement | null>;
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
}) {
const [grading, setGrading] = useState(() => readColorGradingFromElement(element));
const [compare, setCompare] = useState<ColorGradingCompareState>(DEFAULT_COMPARE);
const [runtimeStatus, setRuntimeStatus] = useState<RuntimeColorGradingStatus>(() => ({
state: "pending",
message: "Waiting for runtime",
}));
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingPersistValueRef = useRef<string | null | undefined>(undefined);
const onSetAttributeLiveRef = useRef(onSetAttributeLive);
const compareRef = useRef(compare);
onSetAttributeLiveRef.current = onSetAttributeLive;
compareRef.current = compare;
const target = useMemo(
(): HfColorGradingTarget => ({
id: element.id ?? null,
hfId: element.hfId ?? null,
selector: element.selector ?? null,
selectorIndex: element.selectorIndex ?? null,
}),
[element.hfId, element.id, element.selector, element.selectorIndex],
);
const targetKey = useMemo(
() =>
[
target.id ?? "",
target.hfId ?? "",
target.selector ?? "",
String(target.selectorIndex ?? ""),
].join("|"),
[target],
);
const colorGradingAttribute = element.dataAttributes[COLOR_GRADING_DATA_KEY] ?? "";
const refreshRuntimeStatus = useCallback(() => {
setRuntimeStatus(readRuntimeColorGradingStatus(previewIframeRef?.current, target));
}, [previewIframeRef, target]);
useEffect(() => {
setGrading(normalizeHfColorGrading(colorGradingAttribute) ?? DEFAULT_COLOR_GRADING);
refreshRuntimeStatus();
}, [element, colorGradingAttribute, refreshRuntimeStatus]);
useEffect(() => {
setCompare(DEFAULT_COMPARE);
}, [targetKey]);
useEffect(() => {
const iframe = previewIframeRef?.current;
if (!iframe) return;
const refresh = () => {
window.setTimeout(refreshRuntimeStatus, 50);
};
iframe.addEventListener("load", refresh);
const timer = window.setTimeout(refreshRuntimeStatus, 80);
return () => {
iframe.removeEventListener("load", refresh);
window.clearTimeout(timer);
};
}, [previewIframeRef, refreshRuntimeStatus]);
useEffect(() => {
return () => {
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
if (pendingPersistValueRef.current !== undefined) {
void onSetAttributeLiveRef.current(COLOR_GRADING_DATA_KEY, pendingPersistValueRef.current);
pendingPersistValueRef.current = undefined;
}
};
}, []);
const postColorGrading = useCallback(
(nextGrading: NormalizedHfColorGrading) => {
previewIframeRef?.current?.contentWindow?.postMessage(
{
source: "hf-parent",
type: "control",
action: "set-color-grading",
target,
grading: toBridgeColorGrading(nextGrading),
},
"*",
);
},
[previewIframeRef, target],
);
const postCompare = useCallback(
(nextCompare: ColorGradingCompareState) => {
previewIframeRef?.current?.contentWindow?.postMessage(
{
source: "hf-parent",
type: "control",
action: "set-color-grading-compare",
target,
compare: {
enabled: nextCompare.enabled,
position: 1,
lineWidth: 0,
},
},
"*",
);
},
[previewIframeRef, target],
);
useEffect(
() => () => {
postCompare({ ...DEFAULT_COMPARE, enabled: false });
},
[postCompare],
);
const commitColorGrading = (nextGrading: NormalizedHfColorGrading) => {
setGrading(nextGrading);
setRuntimeStatus({ state: "pending", message: "Updating shader" });
postColorGrading(nextGrading);
const active = isHfColorGradingActive(nextGrading);
if (compareRef.current.enabled) {
postCompare({
...compareRef.current,
enabled: active,
});
if (!active) setCompare(DEFAULT_COMPARE);
}
window.setTimeout(refreshRuntimeStatus, 50);
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
pendingPersistValueRef.current = isHfColorGradingActive(nextGrading)
? serializeHfColorGrading(nextGrading)
: null;
persistTimerRef.current = setTimeout(() => {
const value = pendingPersistValueRef.current;
pendingPersistValueRef.current = undefined;
void onSetAttributeLive(COLOR_GRADING_DATA_KEY, value ?? null);
}, 350);
};
const resetColorGrading = () => {
commitColorGrading(DEFAULT_COLOR_GRADING);
};
const commitCompare = useCallback(
(nextCompare: ColorGradingCompareState) => {
const active = isHfColorGradingActive(grading);
const normalized = {
enabled: nextCompare.enabled && active,
};
setCompare(normalized);
if (normalized.enabled) postColorGrading(grading);
postCompare(normalized);
window.setTimeout(refreshRuntimeStatus, 50);
},
[grading, postColorGrading, postCompare, refreshRuntimeStatus],
);
return (
<Section
title="Color Grading"
icon={<Palette size={15} />}
accessory={
<div className="flex min-w-0 items-center gap-1.5">
<HoldBeforeButton
active={compare.enabled}
disabled={!isHfColorGradingActive(grading)}
onHoldChange={(holding) => commitCompare({ enabled: holding })}
/>
<StatusPill status={runtimeStatus} />
<button
type="button"
onClick={(event) => {
event.stopPropagation();
resetColorGrading();
}}
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1"
title="Reset grading"
>
<RotateCcw size={12} />
</button>
</div>
}
>
<ColorGradingControls
grading={grading}
assets={assets}
defaultColorGrading={DEFAULT_COLOR_GRADING}
onImportAssets={onImportAssets}
onCommitColorGrading={commitColorGrading}
/>
</Section>
);
}
@@ -15,6 +15,7 @@ export interface PropertyPanelProps {
onClearSelection: () => void;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
@@ -24,7 +25,7 @@ export interface PropertyPanelProps {
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
onRemoveTextField: (fieldKey: string) => void;
onAskAgent: () => void;
onImportAssets?: (files: FileList) => Promise<string[]>;
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
fontAssets?: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
@@ -348,46 +348,41 @@ export function Section({
defaultCollapsed?: boolean;
}) {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const collapseIcon = collapsed ? (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className="flex-shrink-0 text-panel-text-5"
>
<path d="M6 2.5v7M2.5 6h7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
) : (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className="flex-shrink-0 text-panel-text-5"
>
<path d="M2 3l3 4 3-4z" />
</svg>
);
return (
<section className="min-w-0 border-t border-panel-border">
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="flex w-full items-center justify-between gap-2 px-4 py-2.5"
>
<h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
<div className="flex items-center gap-2">
{accessory}
{collapsed && (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className="flex-shrink-0 text-panel-text-5"
>
<path
d="M6 2.5v7M2.5 6h7"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
)}
{!collapsed && (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className="flex-shrink-0 text-panel-text-5"
>
<path d="M2 3l3 4 3-4z" />
</svg>
)}
</div>
</button>
<div className="flex w-full items-center gap-2 px-4 py-2.5">
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left"
>
<h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
{collapseIcon}
</button>
{accessory && <div className="flex flex-shrink-0 items-center">{accessory}</div>}
</div>
{!collapsed && <div className="px-4 pb-3">{children}</div>}
</section>
);