mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 20:18:35 +00:00
feat(studio): add color grading inspector controls
This commit is contained in:
@@ -409,6 +409,7 @@ export function StudioApp() {
|
||||
shouldShowSelectedDomBounds,
|
||||
} = useInspectorState(
|
||||
panelLayout.rightPanelTab,
|
||||
panelLayout.rightInspectorPanes,
|
||||
panelLayout.rightCollapsed,
|
||||
isPlaying,
|
||||
gestureState === "recording",
|
||||
|
||||
@@ -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<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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 = (
|
||||
<PropertyPanel
|
||||
projectId={projectId}
|
||||
projectDir={projectDir}
|
||||
assets={assets}
|
||||
element={domEditGroupSelections.length > 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 = (
|
||||
<RenderQueue
|
||||
jobs={renderJobs}
|
||||
projectId={projectId}
|
||||
onDelete={renderQueue.deleteRender}
|
||||
onClearCompleted={renderQueue.clearCompleted}
|
||||
onStartRender={async (format, quality, resolution, fps) => {
|
||||
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({
|
||||
<Tooltip label="Element styles and properties" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelTab("design")}
|
||||
onClick={() => handleInspectorPaneButtonClick("design")}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
||||
rightPanelTab === "design"
|
||||
designPaneOpen
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
}`}
|
||||
@@ -136,9 +266,9 @@ export function StudioRightPanel({
|
||||
<Tooltip label="Composition layer stack" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelTab("layers")}
|
||||
onClick={() => handleInspectorPaneButtonClick("layers")}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
||||
rightPanelTab === "layers"
|
||||
layersPaneOpen
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
}`}
|
||||
@@ -171,79 +301,35 @@ export function StudioRightPanel({
|
||||
compositionPath={activeBlockParams.compositionPath}
|
||||
onClose={onCloseBlockParams ?? (() => {})}
|
||||
/>
|
||||
) : rightPanelTab === "layers" ? (
|
||||
) : layersPaneOpen && designPaneOpen ? (
|
||||
<div ref={splitContainerRef} className="flex h-full min-h-0 flex-col">
|
||||
<div
|
||||
className="min-h-[120px] overflow-hidden"
|
||||
style={{ flexBasis: `${layersPanePercent}%`, flexShrink: 0 }}
|
||||
>
|
||||
<LayersPanel />
|
||||
</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize Layers and Design panes"
|
||||
aria-orientation="horizontal"
|
||||
className="group flex h-2 flex-shrink-0 cursor-row-resize items-center justify-center border-y border-neutral-800 bg-neutral-900"
|
||||
style={{ touchAction: "none" }}
|
||||
onPointerDown={handleInspectorSplitResizeStart}
|
||||
onPointerMove={handleInspectorSplitResizeMove}
|
||||
onPointerUp={handleInspectorSplitResizeEnd}
|
||||
onPointerCancel={handleInspectorSplitResizeEnd}
|
||||
>
|
||||
<div className="h-px w-10 rounded-full bg-white/12 transition-colors group-hover:bg-white/24 group-active:bg-studio-accent/70" />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">{propertyPanel}</div>
|
||||
</div>
|
||||
) : layersPaneOpen ? (
|
||||
<LayersPanel />
|
||||
) : designPanelActive ? (
|
||||
<PropertyPanel
|
||||
projectId={projectId}
|
||||
projectDir={projectDir}
|
||||
assets={assets}
|
||||
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
||||
multiSelectCount={domEditGroupSelections.length}
|
||||
copiedAgentPrompt={copiedAgentPrompt}
|
||||
onClearSelection={clearDomSelection}
|
||||
onSetStyle={handleDomStyleCommit}
|
||||
onSetAttribute={handleDomAttributeCommit}
|
||||
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}
|
||||
/>
|
||||
) : designPaneOpen ? (
|
||||
propertyPanel
|
||||
) : (
|
||||
<RenderQueue
|
||||
jobs={renderJobs}
|
||||
projectId={projectId}
|
||||
onDelete={renderQueue.deleteRender}
|
||||
onClearCompleted={renderQueue.clearCompleted}
|
||||
onStartRender={async (format, quality, resolution, fps) => {
|
||||
await waitForPendingDomEditSaves();
|
||||
const composition =
|
||||
activeCompPath && activeCompPath !== "index.html"
|
||||
? activeCompPath
|
||||
: undefined;
|
||||
await renderQueue.startRender({
|
||||
fps,
|
||||
quality,
|
||||
format,
|
||||
resolution,
|
||||
composition,
|
||||
});
|
||||
}}
|
||||
compositionDimensions={compositionDimensions}
|
||||
isRendering={renderQueue.isRendering}
|
||||
/>
|
||||
renderQueuePanel
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
export type { PersistDomEditOperations } from "./domEditCommitTypes";
|
||||
|
||||
export interface UseDomEditCommitsParams {
|
||||
activeCompPath: string | null;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface UseDomEditWiringParams {
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function useDomEditWiring({
|
||||
// fallow-ignore-next-line code-duplication
|
||||
projectId,
|
||||
activeCompPath,
|
||||
domEditSelection,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<RightPanelTab>(
|
||||
initialState?.rightPanelTab ?? "renders",
|
||||
);
|
||||
const [rightInspectorPanes, setRightInspectorPanes] = useState<RightInspectorPanes>(() =>
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(`
|
||||
<div data-composition-id="root">
|
||||
<img id="photo" class="clip" data-start="0" data-duration="5" />
|
||||
<canvas
|
||||
class="__hf_color_grading_canvas__"
|
||||
data-hf-color-grading-canvas="true"
|
||||
data-hyperframes-ignore
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
></canvas>
|
||||
</div>
|
||||
`);
|
||||
|
||||
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(`
|
||||
<div data-composition-id="root" data-duration="5">
|
||||
<img id="photo" class="clip" data-start="0" data-duration="5" />
|
||||
<canvas
|
||||
class="__hf_color_grading_canvas__"
|
||||
data-hf-color-grading-canvas="true"
|
||||
data-hyperframes-ignore
|
||||
></canvas>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const layers = createImplicitTimelineLayersFromDOM(doc, 5);
|
||||
|
||||
expect(layers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
/*
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user