From 8b92f3763563fb6cb9dc869beb57164c60e119a8 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Tue, 16 Jun 2026 09:34:42 -0700 Subject: [PATCH 1/3] feat(studio): add color grading inspector controls --- packages/studio/src/App.tsx | 1 + .../src/components/StudioRightPanel.tsx | 236 ++++++--- .../src/components/editor/PropertyPanel.tsx | 21 +- .../editor/domEditOverlayGeometry.ts | 13 +- .../src/components/editor/domEditingDom.ts | 21 + .../components/editor/domEditingElement.ts | 13 +- .../editor/manualEditingAvailability.test.ts | 12 + .../editor/manualEditingAvailability.ts | 6 + .../propertyPanelColorGradingControls.tsx | 493 ++++++++++++++++++ .../propertyPanelColorGradingSection.tsx | 395 ++++++++++++++ .../components/editor/propertyPanelHelpers.ts | 3 +- .../editor/propertyPanelPrimitives.tsx | 69 ++- .../studio/src/contexts/DomEditContext.tsx | 4 + .../src/contexts/PanelLayoutContext.tsx | 6 + .../studio/src/hooks/useDomEditCommits.ts | 7 + .../studio/src/hooks/useDomEditSession.ts | 4 +- .../studio/src/hooks/useDomEditTextCommits.ts | 108 +++- packages/studio/src/hooks/useDomEditWiring.ts | 1 + packages/studio/src/hooks/useDomSelection.ts | 12 +- packages/studio/src/hooks/usePanelLayout.ts | 27 +- .../studio/src/hooks/useStudioContextValue.ts | 11 +- packages/studio/src/icons/SystemIcons.tsx | 4 + .../studio/src/player/lib/timelineDOM.test.ts | 36 ++ packages/studio/src/player/lib/timelineDOM.ts | 2 + .../src/player/lib/timelineElementHelpers.ts | 19 +- packages/studio/src/styles/studio.css | 81 +++ packages/studio/src/utils/mediaTypes.ts | 1 + packages/studio/src/utils/studioHelpers.ts | 6 + 28 files changed, 1433 insertions(+), 179 deletions(-) create mode 100644 packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 66411d087e..fd0bd2f7c0 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -409,6 +409,7 @@ export function StudioApp() { shouldShowSelectedDomBounds, } = useInspectorState( panelLayout.rightPanelTab, + panelLayout.rightInspectorPanes, panelLayout.rightCollapsed, isPlaying, gestureState === "recording", diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index a9165d25bf..30720a9c8d 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -1,3 +1,4 @@ +import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { Tooltip } from "./ui"; import { PropertyPanel } from "./editor/PropertyPanel"; import { LayersPanel } from "./editor/LayersPanel"; @@ -14,6 +15,9 @@ import { useFileManagerContext } from "../contexts/FileManagerContext"; import { useDomEditContext } from "../contexts/DomEditContext"; import { usePlayerStore } from "../player"; +const MIN_INSPECTOR_SPLIT_PERCENT = 20; +const MAX_INSPECTOR_SPLIT_PERCENT = 75; + export interface StudioRightPanelProps { designPanelActive: boolean; activeBlockParams?: { @@ -41,6 +45,8 @@ export function StudioRightPanel({ rightWidth, rightPanelTab, setRightPanelTab, + rightInspectorPanes, + toggleRightInspectorPane, handlePanelResizeStart, handlePanelResizeMove, handlePanelResizeEnd, @@ -63,6 +69,7 @@ export function StudioRightPanel({ clearDomSelection, handleDomStyleCommit, handleDomAttributeCommit, + handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, handleDomPathOffsetCommit, handleDomBoxSizeCommit, @@ -96,7 +103,130 @@ export function StudioRightPanel({ const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } = useFileManagerContext(); + const [layersPanePercent, setLayersPanePercent] = useState(40); + const splitContainerRef = useRef(null); + const splitDragRef = useRef<{ + startY: number; + startPercent: number; + height: number; + } | null>(null); + const renderJobs = renderQueue.jobs as RenderJob[]; + const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers"; + const designPaneOpen = inspectorTabActive && rightInspectorPanes.design && designPanelActive; + const layersPaneOpen = + inspectorTabActive && rightInspectorPanes.layers && STUDIO_INSPECTOR_PANELS_ENABLED; + + const handleInspectorPaneButtonClick = (pane: "design" | "layers") => { + if (!inspectorTabActive) { + setRightPanelTab(pane); + return; + } + toggleRightInspectorPane(pane); + }; + + const handleInspectorSplitResizeStart = useCallback( + (event: ReactPointerEvent) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + const height = splitContainerRef.current?.getBoundingClientRect().height ?? 0; + splitDragRef.current = { + startY: event.clientY, + startPercent: layersPanePercent, + height, + }; + }, + [layersPanePercent], + ); + + const handleInspectorSplitResizeMove = useCallback((event: ReactPointerEvent) => { + const drag = splitDragRef.current; + if (!drag || drag.height <= 0) return; + const deltaPercent = ((event.clientY - drag.startY) / drag.height) * 100; + const next = Math.min( + MAX_INSPECTOR_SPLIT_PERCENT, + Math.max(MIN_INSPECTOR_SPLIT_PERCENT, drag.startPercent + deltaPercent), + ); + setLayersPanePercent(next); + }, []); + + const handleInspectorSplitResizeEnd = useCallback(() => { + splitDragRef.current = null; + }, []); + + const propertyPanel = ( + 1 ? null : domEditSelection} + multiSelectCount={domEditGroupSelections.length} + copiedAgentPrompt={copiedAgentPrompt} + onClearSelection={clearDomSelection} + onSetStyle={handleDomStyleCommit} + onSetAttribute={handleDomAttributeCommit} + onSetAttributeLive={handleDomAttributeLiveCommit} + onSetHtmlAttribute={handleDomHtmlAttributeCommit} + onSetManualOffset={handleDomPathOffsetCommit} + onSetManualSize={handleDomBoxSizeCommit} + onSetManualRotation={handleDomRotationCommit} + onSetText={handleDomTextCommit} + onSetTextFieldStyle={handleDomTextFieldStyleCommit} + onAddTextField={handleDomAddTextField} + onRemoveTextField={handleDomRemoveTextField} + onAskAgent={handleAskAgent} + onImportAssets={handleImportFiles} + fontAssets={fontAssets} + onImportFonts={handleImportFonts} + previewIframeRef={previewIframeRef} + gsapAnimations={selectedGsapAnimations} + gsapMultipleTimelines={gsapMultipleTimelines} + gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern} + onUpdateGsapProperty={handleGsapUpdateProperty} + onUpdateGsapMeta={handleGsapUpdateMeta} + onDeleteGsapAnimation={handleGsapDeleteAnimation} + onAddGsapProperty={handleGsapAddProperty} + onRemoveGsapProperty={handleGsapRemoveProperty} + onUpdateGsapFromProperty={handleGsapUpdateFromProperty} + onAddGsapFromProperty={handleGsapAddFromProperty} + onRemoveGsapFromProperty={handleGsapRemoveFromProperty} + onAddGsapAnimation={handleGsapAddAnimation} + onCommitAnimatedProperty={commitAnimatedProperty} + onAddKeyframe={handleGsapAddKeyframe} + onRemoveKeyframe={handleGsapRemoveKeyframe} + onConvertToKeyframes={handleGsapConvertToKeyframes} + onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)} + onSetArcPath={handleSetArcPath} + onUpdateArcSegment={handleUpdateArcSegment} + onUnroll={handleUnroll} + recordingState={recordingState} + recordingDuration={recordingDuration} + onToggleRecording={onToggleRecording} + /> + ); + + const renderQueuePanel = ( + { + await waitForPendingDomEditSaves(); + const composition = + activeCompPath && activeCompPath !== "index.html" ? activeCompPath : undefined; + await renderQueue.startRender({ + fps, + quality, + format, + resolution, + composition, + }); + }} + compositionDimensions={compositionDimensions} + isRendering={renderQueue.isRendering} + /> + ); return ( <> @@ -123,9 +253,9 @@ export function StudioRightPanel({ + )} + + +
+
+ {ticks.map((tick) => ( +
+ ))} +
+
+
+ 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} + /> +
+ +
+
+ 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 && {suffix}} +
+
+ + +
+
+
+ ); +} + +export function ColorGradingControls({ + grading, + assets, + defaultColorGrading, + onImportAssets, + onCommitColorGrading, +}: { + grading: NormalizedHfColorGrading; + assets: string[]; + defaultColorGrading: NormalizedHfColorGrading; + onImportAssets?: (files: FileList, dir?: string) => Promise; + onCommitColorGrading: (nextGrading: NormalizedHfColorGrading) => void; +}) { + const lutInputRef = useRef(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 ( +
+ + +
+ LUT Filter +
+ + + { + void importLuts(event.currentTarget.files); + event.currentTarget.value = ""; + }} + /> +
+ {grading.lut && ( +
+ {selectedProjectLut && ( +
+ + + Uploaded LUT + {` · ${selectedProjectLut}`} + +
+ )} + updateLutIntensity(100)} + /> +
+ )} +
+ +
+ {SLIDERS.map((slider) => { + const value = grading.adjust[slider.key] * slider.scale; + const isExposure = slider.key === "exposure"; + return ( +
+ { + onCommitColorGrading({ + ...grading, + intensity: 1, + adjust: { + ...grading.adjust, + [slider.key]: next / slider.scale, + }, + }); + }} + onReset={() => { + onCommitColorGrading({ + ...grading, + intensity: 1, + adjust: { + ...grading.adjust, + [slider.key]: 0, + }, + }); + }} + /> +
+ ); + })} +
+
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx new file mode 100644 index 0000000000..0896be5987 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx @@ -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 = { + 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 ( +
+ + {status.message} +
+ ); +} + +function HoldBeforeButton({ + active, + disabled, + onHoldChange, +}: { + active: boolean; + disabled: boolean; + onHoldChange: (holding: boolean) => void; +}) { + const startHold = (event: ReactPointerEvent) => { + 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) => { + if (disabled) return; + event.preventDefault(); + event.stopPropagation(); + onHoldChange(false); + }; + + return ( + + ); +} + +export function ColorGradingSection({ + element, + assets, + previewIframeRef, + onImportAssets, + onSetAttributeLive, +}: { + element: DomEditSelection; + assets: string[]; + previewIframeRef?: RefObject; + onImportAssets?: (files: FileList, dir?: string) => Promise; + onSetAttributeLive: (attr: string, value: string | null) => void | Promise; +}) { + const [grading, setGrading] = useState(() => readColorGradingFromElement(element)); + const [compare, setCompare] = useState(DEFAULT_COMPARE); + const [runtimeStatus, setRuntimeStatus] = useState(() => ({ + state: "pending", + message: "Waiting for runtime", + })); + const persistTimerRef = useRef | null>(null); + const pendingPersistValueRef = useRef(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 ( +
} + accessory={ +
+ commitCompare({ enabled: holding })} + /> + + +
+ } + > + +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index dc547a62b3..8a2bed2821 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -15,6 +15,7 @@ export interface PropertyPanelProps { onClearSelection: () => void; onSetStyle: (prop: string, value: string) => void | Promise; onSetAttribute: (attr: string, value: string) => void | Promise; + onSetAttributeLive: (attr: string, value: string | null) => void | Promise; onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise; 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 | null; onRemoveTextField: (fieldKey: string) => void; onAskAgent: () => void; - onImportAssets?: (files: FileList) => Promise; + onImportAssets?: (files: FileList, dir?: string) => Promise; fontAssets?: ImportedFontAsset[]; onImportFonts?: (files: FileList | File[]) => Promise; previewIframeRef?: React.RefObject; diff --git a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx index c12173585b..554365c21a 100644 --- a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx @@ -348,46 +348,41 @@ export function Section({ defaultCollapsed?: boolean; }) { const [collapsed, setCollapsed] = useState(defaultCollapsed); + const collapseIcon = collapsed ? ( + + + + ) : ( + + + + ); return (
- +
+ + {accessory &&
{accessory}
} +
{!collapsed &&
{children}
}
); diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 2d910268d5..21e60be486 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -14,6 +14,7 @@ export interface DomEditActionsValue extends Pick< | "clearDomSelection" | "handleDomStyleCommit" | "handleDomAttributeCommit" + | "handleDomAttributeLiveCommit" | "handleDomHtmlAttributeCommit" | "handleDomPathOffsetCommit" | "handleDomGroupPathOffsetCommit" @@ -115,6 +116,7 @@ export function DomEditProvider({ clearDomSelection, handleDomStyleCommit, handleDomAttributeCommit, + handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, @@ -189,6 +191,7 @@ export function DomEditProvider({ clearDomSelection, handleDomStyleCommit, handleDomAttributeCommit, + handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, @@ -245,6 +248,7 @@ export function DomEditProvider({ clearDomSelection, handleDomStyleCommit, handleDomAttributeCommit, + handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, diff --git a/packages/studio/src/contexts/PanelLayoutContext.tsx b/packages/studio/src/contexts/PanelLayoutContext.tsx index 583724510d..b6d1605f26 100644 --- a/packages/studio/src/contexts/PanelLayoutContext.tsx +++ b/packages/studio/src/contexts/PanelLayoutContext.tsx @@ -22,6 +22,8 @@ export function PanelLayoutProvider({ setRightCollapsed, rightPanelTab, setRightPanelTab, + rightInspectorPanes, + toggleRightInspectorPane, toggleLeftSidebar, handlePanelResizeStart, handlePanelResizeMove, @@ -43,6 +45,8 @@ export function PanelLayoutProvider({ setRightCollapsed, rightPanelTab, setRightPanelTab, + rightInspectorPanes, + toggleRightInspectorPane, toggleLeftSidebar, handlePanelResizeStart, handlePanelResizeMove, @@ -58,6 +62,8 @@ export function PanelLayoutProvider({ setRightCollapsed, rightPanelTab, setRightPanelTab, + rightInspectorPanes, + toggleRightInspectorPane, toggleLeftSidebar, handlePanelResizeStart, handlePanelResizeMove, diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 55460128eb..7e97930b03 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -16,6 +16,9 @@ import { useDomGeometryCommits } from "./useDomGeometryCommits"; import { useElementLifecycleOps } from "./useElementLifecycleOps"; import { formatFieldsSuffix } from "./gsapScriptCommitHelpers"; +// Re-export so existing consumers keep their import path +export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits"; + // ── Helpers ── function formatUnsafeFieldList(fields: Array<{ path: string }>): string { @@ -42,6 +45,8 @@ interface RecordEditInput { files: Record; } +export type { PersistDomEditOperations } from "./domEditCommitTypes"; + export interface UseDomEditCommitsParams { activeCompPath: string | null; previewIframeRef: React.MutableRefObject; @@ -238,6 +243,7 @@ export function useDomEditCommits({ const { handleDomStyleCommit, handleDomAttributeCommit, + handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, handleDomTextCommit, commitDomTextFields, @@ -297,6 +303,7 @@ export function useDomEditCommits({ resolveImportedFontAsset, handleDomStyleCommit, handleDomAttributeCommit, + handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, handleDomTextCommit, commitDomTextFields, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index d7a4046889..7b85e4c302 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -203,6 +203,7 @@ export function useDomEditSession({ resolveImportedFontAsset, handleDomStyleCommit, handleDomAttributeCommit, + handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, handleDomTextCommit, handleDomTextFieldStyleCommit, @@ -265,8 +266,6 @@ export function useDomEditSession({ handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, } = useDomEditWiring({ - // Pre-existing prop-drilling clone (same param set forwarded to - // useDomEditWiring); surfaced by this PR's adjacent edits, not introduced. // fallow-ignore-next-line code-duplication projectId, activeCompPath, @@ -374,6 +373,7 @@ export function useDomEditSession({ clearDomSelection, handleDomStyleCommit, handleDomAttributeCommit, + handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit, handleDomGroupPathOffsetCommit, diff --git a/packages/studio/src/hooks/useDomEditTextCommits.ts b/packages/studio/src/hooks/useDomEditTextCommits.ts index b02f70cc74..f6849a813a 100644 --- a/packages/studio/src/hooks/useDomEditTextCommits.ts +++ b/packages/studio/src/hooks/useDomEditTextCommits.ts @@ -43,6 +43,33 @@ export interface UseDomEditTextCommitsParams { resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null; } +function applyPreviewAttribute( + doc: Document | null | undefined, + selection: DomEditSelection, + activeCompPath: string | null, + attr: string, + value: string | null, + options: { prefixData?: boolean; removeFalse?: boolean } = {}, +): void { + if (!doc) return; + const el = findElementForSelection(doc, selection, activeCompPath); + if (!el) return; + const fullAttr = options.prefixData && !attr.startsWith("data-") ? `data-${attr}` : attr; + if (value === null || value === "" || (options.removeFalse && value === "false")) { + el.removeAttribute(fullAttr); + } else { + el.setAttribute(fullAttr, value); + } +} + +interface DataAttributeCommitOptions { + label: string; + coalescePrefix: string; + skipRefresh: boolean; + warningMessage: string; + refreshAfter?: boolean; +} + // ── Hook ── export function useDomEditTextCommits({ @@ -114,29 +141,33 @@ export function useDomEditTextCommits({ ], ); - const handleDomAttributeCommit = useCallback( - async (attr: string, value: string) => { + const commitDataAttribute = useCallback( + async (attr: string, value: string | null, options: DataAttributeCommitOptions) => { if (!domEditSelection) return; const iframe = previewIframeRef.current; - const doc = iframe?.contentDocument; - if (doc) { - const el = findElementForSelection(doc, domEditSelection, activeCompPath); - if (el) el.setAttribute(`data-${attr}`, value); - } + applyPreviewAttribute( + iframe?.contentDocument, + domEditSelection, + activeCompPath, + attr, + value, + { + prefixData: true, + }, + ); const op: PatchOperation = { type: "attribute", property: attr, value }; try { await persistDomEditOperations(domEditSelection, [op], { - label: `Edit ${attr.replace(/-/g, " ")}`, - coalesceKey: `attr:${attr}:${getDomEditTargetKey(domEditSelection)}`, - skipRefresh: false, + label: options.label, + coalesceKey: `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`, + skipRefresh: options.skipRefresh, }); } catch (err) { - console.warn( - "[Studio] Attribute persist failed:", - err instanceof Error ? err.message : err, - ); + console.warn(options.warningMessage, err instanceof Error ? err.message : err); + } + if (options.refreshAfter) { + refreshDomEditSelectionFromPreview(domEditSelection); } - refreshDomEditSelectionFromPreview(domEditSelection); }, [ activeCompPath, @@ -147,21 +178,45 @@ export function useDomEditTextCommits({ ], ); + const handleDomAttributeCommit = useCallback( + async (attr: string, value: string) => { + await commitDataAttribute(attr, value, { + label: `Edit ${attr.replace(/-/g, " ")}`, + coalescePrefix: "attr", + skipRefresh: false, + warningMessage: "[Studio] Attribute persist failed:", + refreshAfter: true, + }); + }, + [commitDataAttribute], + ); + + const handleDomAttributeLiveCommit = useCallback( + async (attr: string, value: string | null) => { + await commitDataAttribute(attr, value, { + label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`, + coalescePrefix: "attr-live", + skipRefresh: true, + warningMessage: "[Studio] Live attribute persist failed:", + }); + }, + [commitDataAttribute], + ); + const handleDomHtmlAttributeCommit = useCallback( async (attr: string, value: string | null) => { if (!domEditSelection) return; const iframe = previewIframeRef.current; - const doc = iframe?.contentDocument; - if (doc) { - const el = findElementForSelection(doc, domEditSelection, activeCompPath); - if (el) { - if (value === null || value === "" || value === "false") { - el.removeAttribute(attr); - } else { - el.setAttribute(attr, value); - } - } - } + applyPreviewAttribute( + iframe?.contentDocument, + domEditSelection, + activeCompPath, + attr, + value, + { + removeFalse: true, + }, + ); const op: PatchOperation = { type: "html-attribute", property: attr, value }; try { await persistDomEditOperations(domEditSelection, [op], { @@ -395,6 +450,7 @@ export function useDomEditTextCommits({ return { handleDomStyleCommit, handleDomAttributeCommit, + handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit, handleDomTextCommit, commitDomTextFields, diff --git a/packages/studio/src/hooks/useDomEditWiring.ts b/packages/studio/src/hooks/useDomEditWiring.ts index 6c085184a0..0cb96262b4 100644 --- a/packages/studio/src/hooks/useDomEditWiring.ts +++ b/packages/studio/src/hooks/useDomEditWiring.ts @@ -98,6 +98,7 @@ export interface UseDomEditWiringParams { // fallow-ignore-next-line complexity export function useDomEditWiring({ + // fallow-ignore-next-line code-duplication projectId, activeCompPath, domEditSelection, diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index e6db3e33ac..c6b7b78ba4 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -176,9 +176,7 @@ export function useDomSelection({ if (nextSelection) { if (options?.revealPanel !== false) { setRightCollapsed(false); - if (rightPanelTab !== "layers") { - setRightPanelTab("design"); - } + setRightPanelTab("design"); } const nextSelectedTimelineId = findMatchingTimelineElementId( nextSelection, @@ -190,13 +188,7 @@ export function useDomSelection({ setSelectedTimelineElementId(null); }, - [ - setSelectedTimelineElementId, - timelineElements, - setRightCollapsed, - setRightPanelTab, - rightPanelTab, - ], + [setSelectedTimelineElementId, timelineElements, setRightCollapsed, setRightPanelTab], ); const clearDomSelection = useCallback(() => { diff --git a/packages/studio/src/hooks/usePanelLayout.ts b/packages/studio/src/hooks/usePanelLayout.ts index ed9852991b..505c15e499 100644 --- a/packages/studio/src/hooks/usePanelLayout.ts +++ b/packages/studio/src/hooks/usePanelLayout.ts @@ -1,5 +1,9 @@ import { useState, useCallback, useRef } from "react"; -import type { RightPanelTab } from "../utils/studioHelpers"; +import type { + RightInspectorPane, + RightInspectorPanes, + RightPanelTab, +} from "../utils/studioHelpers"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences"; import { trackStudioEvent } from "../utils/studioTelemetry"; @@ -8,6 +12,11 @@ export interface InitialPanelLayoutState { rightPanelTab?: RightPanelTab | null; } +function getInitialRightInspectorPanes(tab?: RightPanelTab | null): RightInspectorPanes { + if (tab === "layers") return { layers: true, design: false }; + return { layers: false, design: true }; +} + export function usePanelLayout(initialState?: InitialPanelLayoutState) { const [leftWidth, setLeftWidth] = useState(240); const [rightWidth, setRightWidth] = useState(400); @@ -18,6 +27,9 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) { const [rightPanelTab, setRightPanelTab] = useState( initialState?.rightPanelTab ?? "renders", ); + const [rightInspectorPanes, setRightInspectorPanes] = useState(() => + getInitialRightInspectorPanes(initialState?.rightPanelTab), + ); const panelDragRef = useRef<{ side: "left" | "right"; startX: number; @@ -67,12 +79,23 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) { const trackedSetRightPanelTab = useCallback( (tab: RightPanelTab) => { + if (tab === "design" || tab === "layers") { + setRightInspectorPanes((panes) => ({ ...panes, [tab]: true })); + } setRightPanelTab(tab); trackStudioEvent("tab_switch", { panel: "right_panel", tab }); }, [setRightPanelTab], ); + const toggleRightInspectorPane = useCallback((pane: RightInspectorPane) => { + setRightInspectorPanes((panes) => { + const next = { ...panes, [pane]: !panes[pane] }; + if (!next.design && !next.layers) return panes; + return next; + }); + }, []); + return { leftWidth, setLeftWidth, @@ -83,6 +106,8 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) { setRightCollapsed, rightPanelTab, setRightPanelTab: trackedSetRightPanelTab, + rightInspectorPanes, + toggleRightInspectorPane, toggleLeftSidebar, handlePanelResizeStart, handlePanelResizeMove, diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index 9f56084bea..aa360586c8 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo, useRef, useState, type DragEvent } from "react"; import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability"; import type { StudioContextValue } from "../contexts/StudioContext"; +import type { RightInspectorPanes } from "../utils/studioHelpers"; interface StudioContextInput { projectId: string; @@ -70,14 +71,18 @@ export interface InspectorState { export function useInspectorState( rightPanelTab: string, + rightInspectorPanes: RightInspectorPanes, rightCollapsed: boolean, isPlaying: boolean, isGestureRecording?: boolean, ): InspectorState { // fallow-ignore-next-line complexity return useMemo(() => { - const layersPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && rightPanelTab === "layers"; - const designPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && rightPanelTab === "design"; + const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers"; + const layersPanelActive = + STUDIO_INSPECTOR_PANELS_ENABLED && inspectorTabActive && rightInspectorPanes.layers; + const designPanelActive = + STUDIO_INSPECTOR_PANELS_ENABLED && inspectorTabActive && rightInspectorPanes.design; const inspectorPanelActive = layersPanelActive || designPanelActive; return { layersPanelActive, @@ -88,7 +93,7 @@ export function useInspectorState( shouldShowSelectedDomBounds: inspectorPanelActive && !rightCollapsed && !isPlaying && !isGestureRecording, }; - }, [rightPanelTab, rightCollapsed, isPlaying, isGestureRecording]); + }, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]); } // fallow-ignore-next-line complexity diff --git a/packages/studio/src/icons/SystemIcons.tsx b/packages/studio/src/icons/SystemIcons.tsx index 3e99f23652..b29f127ec3 100644 --- a/packages/studio/src/icons/SystemIcons.tsx +++ b/packages/studio/src/icons/SystemIcons.tsx @@ -8,8 +8,10 @@ import { ArrowsOutCardinal, MusicNote, Palette as PhPalette, + Minus as PhMinus, Plus as PhPlus, Square as PhSquare, + SquareSplitVertical as PhSquareSplitVertical, TextT, X as PhX, Lightning, @@ -43,8 +45,10 @@ export const MessageSquare = makeIcon(ChatCenteredText); export const Move = makeIcon(ArrowsOutCardinal); export const Music = makeIcon(MusicNote); export const Palette = makeIcon(PhPalette); +export const Minus = makeIcon(PhMinus); export const Plus = makeIcon(PhPlus); export const Square = makeIcon(PhSquare); +export const Compare = makeIcon(PhSquareSplitVertical); export const Type = makeIcon(TextT); export const X = makeIcon(PhX); export const Zap = makeIcon(Lightning); diff --git a/packages/studio/src/player/lib/timelineDOM.test.ts b/packages/studio/src/player/lib/timelineDOM.test.ts index 69fdfdfc3d..c4a4b4403b 100644 --- a/packages/studio/src/player/lib/timelineDOM.test.ts +++ b/packages/studio/src/player/lib/timelineDOM.test.ts @@ -36,6 +36,25 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => { expect(plain).toBeDefined(); expect(plain?.hfId).toBeUndefined(); }); + + it("ignores runtime-owned color grading canvases with timing attributes", () => { + const doc = makeDoc(` +
+ + +
+ `); + + const elements = parseTimelineFromDOM(doc, 10); + + expect(elements.map((el) => el.tag)).toEqual(["img"]); + }); }); describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => { @@ -52,4 +71,21 @@ describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => { expect(layer).toBeDefined(); expect(layer?.hfId).toBe("hf-xyz789"); }); + + it("ignores runtime-owned color grading canvases as implicit layers", () => { + const doc = makeDoc(` +
+ + +
+ `); + + const layers = createImplicitTimelineLayersFromDOM(doc, 5); + + expect(layers).toEqual([]); + }); }); diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index a7054ec4be..cddd3e4e11 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -22,6 +22,7 @@ import { buildTimelineElementKey, buildTimelineElementIdentity, getTimelineElementIdentity, + isTimelineIgnoredElement, } from "./timelineElementHelpers"; // Re-export helpers that were previously public from this module so that @@ -230,6 +231,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel nodes.forEach((node) => { if (node === rootComp) return; + if (isTimelineIgnoredElement(node)) return; const el = node as HTMLElement; const startStr = el.getAttribute("data-start"); if (startStr == null) return; diff --git a/packages/studio/src/player/lib/timelineElementHelpers.ts b/packages/studio/src/player/lib/timelineElementHelpers.ts index 7882c4c4d8..df3e9898bf 100644 --- a/packages/studio/src/player/lib/timelineElementHelpers.ts +++ b/packages/studio/src/player/lib/timelineElementHelpers.ts @@ -23,6 +23,19 @@ function readDurationAttribute(el: Element | null | undefined): number { return isFinitePositive(duration) ? duration : 0; } +export function isTimelineIgnoredElement(el: Element): boolean { + return Boolean( + el.closest( + [ + "[data-hyperframes-ignore]", + "[data-hyperframes-picker-ignore]", + "[data-hf-ignore]", + "[data-hf-color-grading-canvas]", + ].join(","), + ), + ); +} + export function readTimelineDurationFromDocument(doc: Document | null | undefined): number { if (!doc) return 0; const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]")); @@ -30,6 +43,7 @@ export function readTimelineDurationFromDocument(doc: Document | null | undefine let maxEnd = 0; for (const node of Array.from(doc.querySelectorAll("[data-start]"))) { + if (isTimelineIgnoredElement(node)) continue; const start = Number.parseFloat(node.getAttribute("data-start") ?? ""); const duration = readDurationAttribute(node); if (!Number.isFinite(start) || start < 0 || duration <= 0) continue; @@ -241,7 +255,9 @@ export function getTimelineElementIdentity(element: TimelineElement): string { function getTimelineDomNodes(doc: Document): Element[] { const rootComp = doc.querySelector("[data-composition-id]"); - return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp); + return Array.from(doc.querySelectorAll("[data-start]")).filter( + (node) => node !== rootComp && !isTimelineIgnoredElement(node), + ); } function numbersNearlyEqual(a: number, b: number): boolean { @@ -295,6 +311,7 @@ export function findTimelineDomNodeForClip( export function isImplicitTimelineLayerCandidate(root: Element, el: Element): el is HTMLElement { if (!isHtmlElement(el)) return false; + if (isTimelineIgnoredElement(el)) return false; if (el.parentElement !== root) return false; const tagName = el.tagName.toLowerCase(); if (IMPLICIT_TIMELINE_LAYER_SKIP_TAGS.has(tagName)) return false; diff --git a/packages/studio/src/styles/studio.css b/packages/studio/src/styles/studio.css index 25e6dbb2f2..5848799d85 100644 --- a/packages/studio/src/styles/studio.css +++ b/packages/studio/src/styles/studio.css @@ -20,6 +20,87 @@ body { overflow: hidden; } +.hf-color-grading-number::-webkit-outer-spin-button, +.hf-color-grading-number::-webkit-inner-spin-button { + margin: 0; + appearance: none; +} + +.hf-color-grading-number { + -moz-appearance: textfield; + appearance: textfield; +} + +.hf-color-grading-range { + height: 1.5rem; + cursor: default; + -webkit-appearance: none; + appearance: none; + background: transparent; +} + +.hf-color-grading-range:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.hf-color-grading-range::-webkit-slider-runnable-track { + height: 1.25rem; + border: 0; + background: transparent; +} + +.hf-color-grading-range::-webkit-slider-thumb { + width: 0.625rem; + height: 1rem; + margin-top: 0.125rem; + border: 0; + border-radius: 999px; + -webkit-appearance: none; + appearance: none; + background: #ffffff; + box-shadow: + 0 0 0 2px #0c0c0e, + 0 1px 4px rgba(0, 0, 0, 0.55); + cursor: default; +} + +.hf-color-grading-range::-moz-range-track { + height: 1.25rem; + border: 0; + background: transparent; +} + +.hf-color-grading-range::-moz-range-thumb { + width: 0.625rem; + height: 1rem; + border: 0; + border-radius: 999px; + background: #ffffff; + box-shadow: + 0 0 0 2px #0c0c0e, + 0 1px 4px rgba(0, 0, 0, 0.55); + cursor: default; +} + +.hf-color-grading-range:focus-visible { + outline: none; +} + +.hf-color-grading-range:focus-visible::-webkit-slider-thumb { + box-shadow: + 0 0 0 2px #0c0c0e, + 0 0 0 4px rgba(60, 230, 172, 0.22), + 0 1px 4px rgba(0, 0, 0, 0.55); +} + +.hf-color-grading-range:focus-visible::-moz-range-thumb { + box-shadow: + 0 0 0 2px #0c0c0e, + 0 0 0 4px rgba(60, 230, 172, 0.22), + 0 1px 4px rgba(0, 0, 0, 0.55); +} + #root { width: 100vw; /* diff --git a/packages/studio/src/utils/mediaTypes.ts b/packages/studio/src/utils/mediaTypes.ts index 844fe88ed6..2bd97cf869 100644 --- a/packages/studio/src/utils/mediaTypes.ts +++ b/packages/studio/src/utils/mediaTypes.ts @@ -2,6 +2,7 @@ export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i; export const VIDEO_EXT = /\.(mp4|webm|mov)$/i; export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i; export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i; +export const LUT_EXT = /\.cube$/i; export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i; export function isMediaFile(path: string): boolean { diff --git a/packages/studio/src/utils/studioHelpers.ts b/packages/studio/src/utils/studioHelpers.ts index 0f5b4db4ad..2dbaf77d96 100644 --- a/packages/studio/src/utils/studioHelpers.ts +++ b/packages/studio/src/utils/studioHelpers.ts @@ -14,6 +14,12 @@ export interface AppToast { } export type RightPanelTab = "layers" | "design" | "renders" | "block-params"; +export type RightInspectorPane = "layers" | "design"; + +export interface RightInspectorPanes { + layers: boolean; + design: boolean; +} export interface AgentModalAnchorPoint { x: number; From 3661d51e6d771e6b00020d003afc6b5a6a5468e6 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Tue, 16 Jun 2026 13:05:34 -0700 Subject: [PATCH 2/3] refactor(studio): simplify color grading inspector --- .../src/components/editor/PropertyPanel.tsx | 6 + .../propertyPanelColorGradingControls.tsx | 43 ++---- .../propertyPanelColorGradingSection.tsx | 139 +++++++----------- 3 files changed, 71 insertions(+), 117 deletions(-) diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 07a1e531c8..3dd95784ff 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -366,6 +366,12 @@ export const PropertyPanel = memo(function PropertyPanel({ {STUDIO_COLOR_GRADING_ENABLED && isColorGradingCapableElement(element) && ( 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)); @@ -70,17 +57,6 @@ function parseNumericInput(value: string, scale: number): number | 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([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; @@ -186,7 +162,7 @@ function ColorGradingSliderControl({ 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); + const ticks = Array.from(new Set([min, neutral, max])).sort((a, b) => a - b); return (
@@ -237,6 +213,7 @@ function ColorGradingSliderControl({ step={step} value={draft} disabled={disabled} + aria-label={label} onChange={(event) => scheduleCommit(Number(event.currentTarget.value))} onMouseUp={() => commitDraft(draft)} onTouchEnd={() => commitDraft(draft)} @@ -323,7 +300,7 @@ export function ColorGradingControls({ [assets], ); const selectedLut = grading.lut?.src ?? ""; - const selectedProjectLut = selectedLut ? fileLabel(selectedLut) : null; + const selectedProjectLut = selectedLut ? (selectedLut.split("/").pop() ?? selectedLut) : null; const applyPreset = (preset: string) => { const next = normalizeHfColorGrading({ preset, intensity: 1 }) ?? defaultColorGrading; @@ -384,7 +361,7 @@ export function ColorGradingControls({ {lutAssets.map((asset) => ( ))} @@ -434,7 +411,7 @@ export function ColorGradingControls({ step={1} neutral={0} suffix="%" - displayValue={formatPercent((grading.lut.intensity ?? 1) * 100)} + displayValue={`${Math.round((grading.lut.intensity ?? 1) * 100)}%`} onCommit={updateLutIntensity} onReset={() => updateLutIntensity(100)} /> @@ -443,14 +420,14 @@ export function ColorGradingControls({
- {SLIDERS.map((slider) => { + {SLIDERS.map((slider, index) => { const value = grading.adjust[slider.key] * slider.scale; const isExposure = slider.key === "exposure"; return (
0 ? "+" : ""}${(value / 100).toFixed(2)}` + : `${Math.round(value)}%` + } onCommit={(next) => { onCommitColorGrading({ ...grading, diff --git a/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx index 0896be5987..02e7f8fab4 100644 --- a/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx @@ -43,14 +43,6 @@ const DEFAULT_COLOR_GRADING: NormalizedHfColorGrading = { 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"; @@ -60,16 +52,6 @@ interface RuntimeColorGradingStatus { 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"; } @@ -83,13 +65,8 @@ function readColorGradingFromElement(element: DomEditSelection): NormalizedHfCol 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, - }; + const { enabled: _enabled, ...bridgeGrading } = grading; + return bridgeGrading; } function readRuntimeColorGradingStatus( @@ -97,7 +74,18 @@ function readRuntimeColorGradingStatus( target: HfColorGradingTarget, ): RuntimeColorGradingStatus { try { - const win = iframe?.contentWindow as RuntimeColorGradingWindow | null | undefined; + const win = iframe?.contentWindow as + | (Window & { + __hf?: { + colorGrading?: { + getStatus?: ( + target: HfColorGradingTarget | string | null | undefined, + ) => RuntimeColorGradingStatus; + }; + }; + }) + | null + | undefined; const status = win?.__hf?.colorGrading?.getStatus?.(target); return status ?? { state: "pending", message: "Waiting for runtime" }; } catch { @@ -140,12 +128,10 @@ function HoldBeforeButton({ 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) => { @@ -203,7 +189,7 @@ export function ColorGradingSection({ onSetAttributeLive: (attr: string, value: string | null) => void | Promise; }) { const [grading, setGrading] = useState(() => readColorGradingFromElement(element)); - const [compare, setCompare] = useState(DEFAULT_COMPARE); + const [compareEnabled, setCompareEnabled] = useState(false); const [runtimeStatus, setRuntimeStatus] = useState(() => ({ state: "pending", message: "Waiting for runtime", @@ -211,9 +197,9 @@ export function ColorGradingSection({ const persistTimerRef = useRef | null>(null); const pendingPersistValueRef = useRef(undefined); const onSetAttributeLiveRef = useRef(onSetAttributeLive); - const compareRef = useRef(compare); + const compareEnabledRef = useRef(compareEnabled); onSetAttributeLiveRef.current = onSetAttributeLive; - compareRef.current = compare; + compareEnabledRef.current = compareEnabled; const target = useMemo( (): HfColorGradingTarget => ({ id: element.id ?? null, @@ -223,30 +209,14 @@ export function ColorGradingSection({ }), [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]); + }, [refreshRuntimeStatus]); useEffect(() => { const iframe = previewIframeRef?.current; @@ -289,7 +259,7 @@ export function ColorGradingSection({ ); const postCompare = useCallback( - (nextCompare: ColorGradingCompareState) => { + (enabled: boolean) => { previewIframeRef?.current?.contentWindow?.postMessage( { source: "hf-parent", @@ -297,7 +267,7 @@ export function ColorGradingSection({ action: "set-color-grading-compare", target, compare: { - enabled: nextCompare.enabled, + enabled, position: 1, lineWidth: 0, }, @@ -310,48 +280,45 @@ export function ColorGradingSection({ useEffect( () => () => { - postCompare({ ...DEFAULT_COMPARE, enabled: false }); + postCompare(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 commitColorGrading = useCallback( + (nextGrading: NormalizedHfColorGrading) => { + setGrading(nextGrading); + setRuntimeStatus({ state: "pending", message: "Updating shader" }); + postColorGrading(nextGrading); + const active = isHfColorGradingActive(nextGrading); + if (compareEnabledRef.current) { + postCompare(active); + if (!active) setCompareEnabled(false); + } + 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); + }, + [onSetAttributeLive, postColorGrading, postCompare, refreshRuntimeStatus], + ); - const resetColorGrading = () => { + const resetColorGrading = useCallback(() => { commitColorGrading(DEFAULT_COLOR_GRADING); - }; + }, [commitColorGrading]); const commitCompare = useCallback( - (nextCompare: ColorGradingCompareState) => { - const active = isHfColorGradingActive(grading); - const normalized = { - enabled: nextCompare.enabled && active, - }; - setCompare(normalized); - if (normalized.enabled) postColorGrading(grading); - postCompare(normalized); + (enabled: boolean) => { + const nextEnabled = enabled && isHfColorGradingActive(grading); + setCompareEnabled(nextEnabled); + if (nextEnabled) postColorGrading(grading); + postCompare(nextEnabled); window.setTimeout(refreshRuntimeStatus, 50); }, [grading, postColorGrading, postCompare, refreshRuntimeStatus], @@ -364,9 +331,9 @@ export function ColorGradingSection({ accessory={
commitCompare({ enabled: holding })} + onHoldChange={commitCompare} />