mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
fix(studio): split color grading inspector files
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
HF_COLOR_GRADING_PRESETS,
|
||||
normalizeHfColorGrading,
|
||||
@@ -7,21 +7,12 @@ import {
|
||||
type HfColorGradingEffectKey,
|
||||
type NormalizedHfColorGrading,
|
||||
} from "@hyperframes/core/color-grading";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Minus,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
X,
|
||||
} from "../../icons/SystemIcons";
|
||||
import { ChevronDown, ChevronRight, Plus, X } from "../../icons/SystemIcons";
|
||||
import { LUT_EXT } from "../../utils/mediaTypes";
|
||||
import { LABEL } from "./propertyPanelHelpers";
|
||||
import { ColorGradingSliderControl } from "./propertyPanelColorGradingSlider";
|
||||
|
||||
const LUT_UPLOAD_DIR = "assets/luts";
|
||||
const SLIDER_THUMB_SIZE = 10;
|
||||
const SLIDER_THUMB_RADIUS = SLIDER_THUMB_SIZE / 2;
|
||||
|
||||
const ADJUST_SLIDERS: Array<{
|
||||
key: HfColorGradingAdjustKey;
|
||||
@@ -131,6 +122,13 @@ const DETAIL_SLIDERS: Array<{
|
||||
},
|
||||
];
|
||||
|
||||
type DetailSlider = (typeof DETAIL_SLIDERS)[number];
|
||||
type SliderSettings = {
|
||||
active?: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const EFFECT_SLIDERS: Array<{
|
||||
key: HfColorGradingEffectKey;
|
||||
label: string;
|
||||
@@ -157,275 +155,6 @@ const GRAIN_TUNE_SLIDERS = DETAIL_SLIDERS.filter(
|
||||
(slider) => slider.key === "grainSize" || slider.key === "grainRoughness",
|
||||
);
|
||||
|
||||
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 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,
|
||||
settings,
|
||||
}: {
|
||||
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;
|
||||
settings?: {
|
||||
active?: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}) {
|
||||
const [draftState, setDraftState] = useState<{ value: number; source: number } | null>(null);
|
||||
const [inputDraft, setInputDraft] = useState<{ value: string; source: number } | null>(null);
|
||||
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clampDraft = useCallback(
|
||||
(nextValue: number) => clampNumber(nextValue, min, max),
|
||||
[max, min],
|
||||
);
|
||||
|
||||
const setLocalDraft = useCallback(
|
||||
(nextValue: number) => {
|
||||
const clamped = clampDraft(nextValue);
|
||||
const source = valueRef.current;
|
||||
setDraftState({ value: clamped, source });
|
||||
setInputDraft({ value: formatNumericInput(clamped, scale), source });
|
||||
return clamped;
|
||||
},
|
||||
[clampDraft, scale],
|
||||
);
|
||||
|
||||
const commitDraft = useCallback(
|
||||
(nextValue: number) => {
|
||||
const clamped = setLocalDraft(nextValue);
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
if (clamped !== valueRef.current) onCommit(clamped);
|
||||
},
|
||||
[onCommit, setLocalDraft],
|
||||
);
|
||||
|
||||
const scheduleCommit = useCallback(
|
||||
(nextValue: number) => {
|
||||
const clamped = setLocalDraft(nextValue);
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = setTimeout(() => {
|
||||
if (clamped !== valueRef.current) onCommit(clamped);
|
||||
}, 40);
|
||||
},
|
||||
[onCommit, setLocalDraft],
|
||||
);
|
||||
|
||||
const draft = draftState?.source === value ? draftState.value : value;
|
||||
const inputValue =
|
||||
inputDraft?.source === value ? inputDraft.value : formatNumericInput(draft, scale);
|
||||
|
||||
const commitInputDraft = useCallback(() => {
|
||||
const parsed = parseNumericInput(inputValue, scale);
|
||||
if (parsed === null) {
|
||||
setInputDraft(null);
|
||||
return;
|
||||
}
|
||||
commitDraft(parsed);
|
||||
}, [commitDraft, inputValue, scale]);
|
||||
|
||||
const nudge = useCallback(
|
||||
(direction: -1 | 1) => {
|
||||
commitDraft(draft + step * direction);
|
||||
},
|
||||
[commitDraft, draft, 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 = Array.from(new Set([min, neutral, max])).sort((a, b) => a - b);
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-0.5 rounded-md bg-panel-input/30 px-1.5 py-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className={`${LABEL} min-w-0 flex-1 truncate`}>{label}</span>
|
||||
{settings && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={settings.label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
settings.onClick();
|
||||
}}
|
||||
className={`relative flex h-5 w-5 flex-shrink-0 items-center justify-center rounded transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
settings.active ? "text-studio-accent" : "text-panel-text-5"
|
||||
}`}
|
||||
title={settings.label}
|
||||
>
|
||||
<Settings size={11} />
|
||||
{settings.active && (
|
||||
<span className="absolute right-0.5 top-0.5 h-1 w-1 rounded-full bg-studio-accent" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{onReset && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={`Reset ${label}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onReset();
|
||||
}}
|
||||
className="flex h-5 w-5 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-5 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}
|
||||
aria-label={label}
|
||||
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">
|
||||
<div className="flex flex-shrink-0 items-center rounded-md bg-panel-input px-1.5 py-px">
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
min={min / scale}
|
||||
max={max / scale}
|
||||
step={step / scale}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
setInputDraft({ value: event.currentTarget.value, source: valueRef.current })
|
||||
}
|
||||
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-4 w-[36px] bg-transparent text-right text-[10px] 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-5 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-5 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>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizedDefaultValue(slider: { defaultValue?: number; scale: number }): number {
|
||||
return (slider.defaultValue ?? 0) / slider.scale;
|
||||
}
|
||||
@@ -491,6 +220,45 @@ export function ColorGradingControls({
|
||||
const firstLut = uploaded.find((asset) => LUT_EXT.test(asset));
|
||||
if (firstLut) applyLut(firstLut, 1);
|
||||
};
|
||||
const commitDetailSlider = (slider: DetailSlider, next: number) => {
|
||||
onCommitColorGrading({
|
||||
...grading,
|
||||
intensity: visibleIntensity(grading),
|
||||
details: {
|
||||
...grading.details,
|
||||
[slider.key]: next / slider.scale,
|
||||
},
|
||||
});
|
||||
};
|
||||
const resetDetailSlider = (slider: DetailSlider) => {
|
||||
onCommitColorGrading({
|
||||
...grading,
|
||||
intensity: visibleIntensity(grading),
|
||||
details: {
|
||||
...grading.details,
|
||||
[slider.key]: normalizedDefaultValue(slider),
|
||||
},
|
||||
});
|
||||
};
|
||||
const renderDetailSlider = (slider: DetailSlider, settings?: SliderSettings) => {
|
||||
const value = Math.round(grading.details[slider.key] * slider.scale);
|
||||
return (
|
||||
<ColorGradingSliderControl
|
||||
key={slider.key}
|
||||
label={slider.label}
|
||||
value={value}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
neutral={slider.defaultValue ?? 0}
|
||||
suffix={slider.suffix}
|
||||
displayValue={`${value}%`}
|
||||
settings={settings}
|
||||
onCommit={(next) => commitDetailSlider(slider, next)}
|
||||
onReset={() => resetDetailSlider(slider)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -669,51 +437,16 @@ export function ColorGradingControls({
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<span className={LABEL}>Finishing</span>
|
||||
<div className="grid min-w-0 grid-cols-2 gap-1.5">
|
||||
{AMOUNT_DETAIL_SLIDERS.map((slider) => {
|
||||
const value = grading.details[slider.key] * slider.scale;
|
||||
const defaultValue = slider.defaultValue ?? 0;
|
||||
return (
|
||||
<ColorGradingSliderControl
|
||||
key={slider.key}
|
||||
label={slider.label}
|
||||
value={Math.round(value)}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
neutral={defaultValue}
|
||||
suffix={slider.suffix}
|
||||
displayValue={`${Math.round(value)}%`}
|
||||
settings={{
|
||||
active: slider.key === "vignette" ? vignetteSettingsActive : grainSettingsActive,
|
||||
label: `${slider.label} settings`,
|
||||
onClick: () =>
|
||||
setDetailSettings((current) =>
|
||||
current === slider.key ? null : (slider.key as "vignette" | "grain"),
|
||||
),
|
||||
}}
|
||||
onCommit={(next) => {
|
||||
onCommitColorGrading({
|
||||
...grading,
|
||||
intensity: visibleIntensity(grading),
|
||||
details: {
|
||||
...grading.details,
|
||||
[slider.key]: next / slider.scale,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onReset={() => {
|
||||
onCommitColorGrading({
|
||||
...grading,
|
||||
intensity: visibleIntensity(grading),
|
||||
details: {
|
||||
...grading.details,
|
||||
[slider.key]: defaultValue / slider.scale,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{AMOUNT_DETAIL_SLIDERS.map((slider) =>
|
||||
renderDetailSlider(slider, {
|
||||
active: slider.key === "vignette" ? vignetteSettingsActive : grainSettingsActive,
|
||||
label: `${slider.label} settings`,
|
||||
onClick: () =>
|
||||
setDetailSettings((current) =>
|
||||
current === slider.key ? null : (slider.key as "vignette" | "grain"),
|
||||
),
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
{detailSettings && (
|
||||
<div className="grid min-w-0 gap-1.5 rounded-md border border-panel-border bg-panel-input/40 p-1.5 shadow-xl shadow-black/20">
|
||||
@@ -732,43 +465,7 @@ export function ColorGradingControls({
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid min-w-0 grid-cols-2 gap-1.5">
|
||||
{detailSettingsSliders.map((slider) => {
|
||||
const value = grading.details[slider.key] * slider.scale;
|
||||
const defaultValue = slider.defaultValue ?? 0;
|
||||
return (
|
||||
<ColorGradingSliderControl
|
||||
key={slider.key}
|
||||
label={slider.label}
|
||||
value={Math.round(value)}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
neutral={defaultValue}
|
||||
suffix={slider.suffix}
|
||||
displayValue={`${Math.round(value)}%`}
|
||||
onCommit={(next) => {
|
||||
onCommitColorGrading({
|
||||
...grading,
|
||||
intensity: visibleIntensity(grading),
|
||||
details: {
|
||||
...grading.details,
|
||||
[slider.key]: next / slider.scale,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onReset={() => {
|
||||
onCommitColorGrading({
|
||||
...grading,
|
||||
intensity: visibleIntensity(grading),
|
||||
details: {
|
||||
...grading.details,
|
||||
[slider.key]: defaultValue / slider.scale,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{detailSettingsSliders.map((slider) => renderDetailSlider(slider))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -76,6 +76,7 @@ function stripPreviewAssetPath(src: string, projectId: string): string | null {
|
||||
return assetPath;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function resolveProjectAssetPath(
|
||||
sourceFile: string,
|
||||
src: string,
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Minus, Plus, RotateCcw, Settings } from "../../icons/SystemIcons";
|
||||
import { LABEL } from "./propertyPanelHelpers";
|
||||
|
||||
const SLIDER_THUMB_SIZE = 10;
|
||||
const SLIDER_THUMB_RADIUS = SLIDER_THUMB_SIZE / 2;
|
||||
|
||||
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 tickPercent(value: number, min: number, max: number): number {
|
||||
if (max <= min) return 0;
|
||||
return ((value - min) / (max - min)) * 100;
|
||||
}
|
||||
|
||||
export function ColorGradingSliderControl({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
neutral = min,
|
||||
scale = 1,
|
||||
suffix = "",
|
||||
displayValue,
|
||||
disabled,
|
||||
onCommit,
|
||||
onReset,
|
||||
settings,
|
||||
}: {
|
||||
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;
|
||||
settings?: {
|
||||
active?: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}) {
|
||||
const [draftState, setDraftState] = useState<{ value: number; source: number } | null>(null);
|
||||
const [inputDraft, setInputDraft] = useState<{ value: string; source: number } | null>(null);
|
||||
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clampDraft = useCallback(
|
||||
(nextValue: number) => clampNumber(nextValue, min, max),
|
||||
[max, min],
|
||||
);
|
||||
|
||||
const setLocalDraft = useCallback(
|
||||
(nextValue: number) => {
|
||||
const clamped = clampDraft(nextValue);
|
||||
const source = valueRef.current;
|
||||
setDraftState({ value: clamped, source });
|
||||
setInputDraft({ value: formatNumericInput(clamped, scale), source });
|
||||
return clamped;
|
||||
},
|
||||
[clampDraft, scale],
|
||||
);
|
||||
|
||||
const commitDraft = useCallback(
|
||||
(nextValue: number) => {
|
||||
const clamped = setLocalDraft(nextValue);
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
if (clamped !== valueRef.current) onCommit(clamped);
|
||||
},
|
||||
[onCommit, setLocalDraft],
|
||||
);
|
||||
|
||||
const scheduleCommit = useCallback(
|
||||
(nextValue: number) => {
|
||||
const clamped = setLocalDraft(nextValue);
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = setTimeout(() => {
|
||||
if (clamped !== valueRef.current) onCommit(clamped);
|
||||
}, 40);
|
||||
},
|
||||
[onCommit, setLocalDraft],
|
||||
);
|
||||
|
||||
const draft = draftState?.source === value ? draftState.value : value;
|
||||
const inputValue =
|
||||
inputDraft?.source === value ? inputDraft.value : formatNumericInput(draft, scale);
|
||||
|
||||
const commitInputDraft = useCallback(() => {
|
||||
const parsed = parseNumericInput(inputValue, scale);
|
||||
if (parsed === null) {
|
||||
setInputDraft(null);
|
||||
return;
|
||||
}
|
||||
commitDraft(parsed);
|
||||
}, [commitDraft, inputValue, scale]);
|
||||
|
||||
const nudge = useCallback(
|
||||
(direction: -1 | 1) => {
|
||||
commitDraft(draft + step * direction);
|
||||
},
|
||||
[commitDraft, draft, 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 = Array.from(new Set([min, neutral, max])).sort((a, b) => a - b);
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-0.5 rounded-md bg-panel-input/30 px-1.5 py-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className={`${LABEL} min-w-0 flex-1 truncate`}>{label}</span>
|
||||
{settings && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={settings.label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
settings.onClick();
|
||||
}}
|
||||
className={`relative flex h-5 w-5 flex-shrink-0 items-center justify-center rounded transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
settings.active ? "text-studio-accent" : "text-panel-text-5"
|
||||
}`}
|
||||
title={settings.label}
|
||||
>
|
||||
<Settings size={11} />
|
||||
{settings.active && (
|
||||
<span className="absolute right-0.5 top-0.5 h-1 w-1 rounded-full bg-studio-accent" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{onReset && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={`Reset ${label}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onReset();
|
||||
}}
|
||||
className="flex h-5 w-5 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-5 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}
|
||||
aria-label={label}
|
||||
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">
|
||||
<div className="flex flex-shrink-0 items-center rounded-md bg-panel-input px-1.5 py-px">
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
min={min / scale}
|
||||
max={max / scale}
|
||||
step={step / scale}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
setInputDraft({ value: event.currentTarget.value, source: valueRef.current })
|
||||
}
|
||||
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-4 w-[36px] bg-transparent text-right text-[10px] 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-5 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-5 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>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +1,14 @@
|
||||
import { parseCssColor, type ParsedColor } from "./colorValue";
|
||||
import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import type { ImportedFontAsset } from "./fontAssets";
|
||||
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
|
||||
import { roundToCenti } from "../../utils/rounding";
|
||||
|
||||
export interface PropertyPanelProps {
|
||||
projectId: string;
|
||||
projectDir: string | null;
|
||||
assets: string[];
|
||||
element: DomEditSelection | null;
|
||||
multiSelectCount?: number;
|
||||
copiedAgentPrompt: boolean;
|
||||
onClearSelection: () => void;
|
||||
/** Dissolve the selected data-hf-group wrapper (shown only for group selections). */
|
||||
onUngroup?: () => 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>;
|
||||
onApplyColorGradingScope?: (
|
||||
scope: "source-file" | "project",
|
||||
value: string | null,
|
||||
) => Promise<{ changedFiles: number; changedElements: number }>;
|
||||
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
||||
onRemoveBackground?: (
|
||||
inputPath: string,
|
||||
options: {
|
||||
createBackgroundPlate?: boolean;
|
||||
quality?: "fast" | "balanced" | "best";
|
||||
onProgress?: (progress: BackgroundRemovalProgress) => void;
|
||||
},
|
||||
) => Promise<BackgroundRemovalResult>;
|
||||
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
|
||||
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
|
||||
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
|
||||
onSetText: (value: string, fieldKey?: string) => void;
|
||||
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
||||
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
||||
onRemoveTextField: (fieldKey: string) => void;
|
||||
onAskAgent: () => void;
|
||||
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
|
||||
fontAssets?: ImportedFontAsset[];
|
||||
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
||||
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
|
||||
gsapAnimations?: import("@hyperframes/parsers/gsap-parser").GsapAnimation[];
|
||||
gsapMultipleTimelines?: boolean;
|
||||
gsapUnsupportedTimelinePattern?: boolean;
|
||||
onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void;
|
||||
onUpdateGsapMeta?: (
|
||||
animId: string,
|
||||
updates: { duration?: number; ease?: string; position?: number },
|
||||
) => void;
|
||||
onDeleteGsapAnimation?: (animId: string) => void;
|
||||
onAddGsapProperty?: (animId: string, prop: string) => void;
|
||||
onRemoveGsapProperty?: (animId: string, prop: string) => void;
|
||||
onUpdateGsapFromProperty?: (animId: string, prop: string, value: number | string) => void;
|
||||
onAddGsapFromProperty?: (animId: string, prop: string) => void;
|
||||
onRemoveGsapFromProperty?: (animId: string, prop: string) => void;
|
||||
onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
onSetArcPath?: (
|
||||
animId: string,
|
||||
config: {
|
||||
enabled: boolean;
|
||||
autoRotate?: boolean | number;
|
||||
segments?: import("@hyperframes/parsers/gsap-parser").ArcPathSegment[];
|
||||
},
|
||||
) => void;
|
||||
onUpdateArcSegment?: (
|
||||
animId: string,
|
||||
segmentIndex: number,
|
||||
update: Partial<import("@hyperframes/parsers/gsap-parser").ArcPathSegment>,
|
||||
) => void;
|
||||
/** Unroll computed (helper/loop) tweens into literal tweens for direct editing. */
|
||||
onUnroll?: (animationId: string) => void;
|
||||
onAddKeyframe?: (
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
property: string,
|
||||
value: number | string,
|
||||
) => void;
|
||||
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
|
||||
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
|
||||
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
||||
onConvertToKeyframes?: (animationId: string, duration?: number) => void;
|
||||
onCommitAnimatedProperty?: (
|
||||
selection: DomEditSelection,
|
||||
property: string,
|
||||
value: number | string,
|
||||
) => Promise<void>;
|
||||
/** Batched variant: commit several props into ONE keyframe (e.g. the 3D cube's
|
||||
* rotationX/Y/Z) so multi-axis edits don't race into adjacent duplicates. */
|
||||
onCommitAnimatedProperties?: (
|
||||
selection: DomEditSelection,
|
||||
props: Record<string, number | string>,
|
||||
) => Promise<void>;
|
||||
onSeekToTime?: (time: number) => void;
|
||||
recordingState?: "idle" | "recording" | "preview";
|
||||
recordingDuration?: number;
|
||||
onToggleRecording?: () => void;
|
||||
}
|
||||
|
||||
export interface BackgroundRemovalProgress {
|
||||
status: "processing" | "complete" | "failed";
|
||||
progress: number;
|
||||
stage?: string;
|
||||
outputPath?: string;
|
||||
backgroundOutputPath?: string;
|
||||
error?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface BackgroundRemovalResult {
|
||||
outputPath: string;
|
||||
backgroundOutputPath?: string;
|
||||
provider?: string;
|
||||
}
|
||||
export type {
|
||||
BackgroundRemovalProgress,
|
||||
BackgroundRemovalResult,
|
||||
PropertyPanelProps,
|
||||
} from "./propertyPanelTypes";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Font types & constants (shared by font and section modules) */
|
||||
@@ -350,23 +244,24 @@ export function normalizeTextMetricValue(
|
||||
}
|
||||
|
||||
function splitCssFunctions(value: string): string[] {
|
||||
const source = value.trim();
|
||||
const functions: string[] = [];
|
||||
let current = "";
|
||||
// fallow-ignore-next-line code-duplication -- pre-existing; surfaced in this file's diff by an unrelated line shift
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
|
||||
for (const char of value.trim()) {
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
if (char === "(") depth += 1;
|
||||
if (char === ")") depth = Math.max(0, depth - 1);
|
||||
if (/\s/.test(char) && depth === 0) {
|
||||
if (current.trim()) functions.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
const part = source.slice(start, index).trim();
|
||||
if (part) functions.push(part);
|
||||
start = index + 1;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (current.trim()) functions.push(current.trim());
|
||||
const lastPart = source.slice(start).trim();
|
||||
if (lastPart) functions.push(lastPart);
|
||||
return functions;
|
||||
}
|
||||
|
||||
@@ -518,11 +413,11 @@ export function extractBackgroundImageUrl(value: string | undefined): string {
|
||||
|
||||
// ── GSAP runtime value readers (used by PropertyPanel) ────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity -- pre-existing; surfaced in this file's diff by an unrelated line shift
|
||||
// Core transform channels the panel ALWAYS reads live — even before a just-set
|
||||
// value (e.g. rotationX) has re-parsed into `gsapAnimations`. Without this the
|
||||
// cube + fields drop the prop and flicker to 0 on every commit; gsap.getProperty
|
||||
// reflects the in-place instant patch, so it's the true current value.
|
||||
// fallow-ignore-next-line complexity
|
||||
const ALWAYS_READ_CHANNELS = [
|
||||
"x",
|
||||
"y",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "./propertyPanelHelpers";
|
||||
import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives";
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function MediaSection({
|
||||
projectDir,
|
||||
element,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { RefObject } from "react";
|
||||
import type { ArcPathSegment, GsapAnimation } from "@hyperframes/parsers/gsap-parser";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import type { ImportedFontAsset } from "./fontAssets";
|
||||
|
||||
export interface BackgroundRemovalProgress {
|
||||
status: "processing" | "complete" | "failed";
|
||||
progress: number;
|
||||
stage?: string;
|
||||
outputPath?: string;
|
||||
backgroundOutputPath?: string;
|
||||
error?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface BackgroundRemovalResult {
|
||||
outputPath: string;
|
||||
backgroundOutputPath?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface PropertyPanelProps {
|
||||
projectId: string;
|
||||
projectDir: string | null;
|
||||
assets: string[];
|
||||
element: DomEditSelection | null;
|
||||
multiSelectCount?: number;
|
||||
copiedAgentPrompt: boolean;
|
||||
onClearSelection: () => void;
|
||||
onUngroup?: () => 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>;
|
||||
onApplyColorGradingScope?: (
|
||||
scope: "source-file" | "project",
|
||||
value: string | null,
|
||||
) => Promise<{ changedFiles: number; changedElements: number }>;
|
||||
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
||||
onRemoveBackground?: (
|
||||
inputPath: string,
|
||||
options: {
|
||||
createBackgroundPlate?: boolean;
|
||||
quality?: "fast" | "balanced" | "best";
|
||||
onProgress?: (progress: BackgroundRemovalProgress) => void;
|
||||
},
|
||||
) => Promise<BackgroundRemovalResult>;
|
||||
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
|
||||
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
|
||||
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
|
||||
onSetText: (value: string, fieldKey?: string) => void;
|
||||
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
||||
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
||||
onRemoveTextField: (fieldKey: string) => void;
|
||||
onAskAgent: () => void;
|
||||
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
|
||||
fontAssets?: ImportedFontAsset[];
|
||||
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
||||
previewIframeRef?: RefObject<HTMLIFrameElement | null>;
|
||||
gsapAnimations?: GsapAnimation[];
|
||||
gsapMultipleTimelines?: boolean;
|
||||
gsapUnsupportedTimelinePattern?: boolean;
|
||||
onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void;
|
||||
onUpdateGsapMeta?: (
|
||||
animId: string,
|
||||
updates: { duration?: number; ease?: string; position?: number },
|
||||
) => void;
|
||||
onDeleteGsapAnimation?: (animId: string) => void;
|
||||
onAddGsapProperty?: (animId: string, prop: string) => void;
|
||||
onRemoveGsapProperty?: (animId: string, prop: string) => void;
|
||||
onUpdateGsapFromProperty?: (animId: string, prop: string, value: number | string) => void;
|
||||
onAddGsapFromProperty?: (animId: string, prop: string) => void;
|
||||
onRemoveGsapFromProperty?: (animId: string, prop: string) => void;
|
||||
onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
onSetArcPath?: (
|
||||
animId: string,
|
||||
config: {
|
||||
enabled: boolean;
|
||||
autoRotate?: boolean | number;
|
||||
segments?: ArcPathSegment[];
|
||||
},
|
||||
) => void;
|
||||
onUpdateArcSegment?: (
|
||||
animId: string,
|
||||
segmentIndex: number,
|
||||
update: Partial<ArcPathSegment>,
|
||||
) => void;
|
||||
onUnroll?: (animationId: string) => void;
|
||||
onAddKeyframe?: (
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
property: string,
|
||||
value: number | string,
|
||||
) => void;
|
||||
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
|
||||
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
|
||||
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
||||
onConvertToKeyframes?: (animationId: string, duration?: number) => void;
|
||||
onCommitAnimatedProperty?: (
|
||||
selection: DomEditSelection,
|
||||
property: string,
|
||||
value: number | string,
|
||||
) => Promise<void>;
|
||||
onCommitAnimatedProperties?: (
|
||||
selection: DomEditSelection,
|
||||
props: Record<string, number | string>,
|
||||
) => Promise<void>;
|
||||
onSeekToTime?: (time: number) => void;
|
||||
recordingState?: "idle" | "recording" | "preview";
|
||||
recordingDuration?: number;
|
||||
onToggleRecording?: () => void;
|
||||
}
|
||||
Reference in New Issue
Block a user