@hyperframes/studio 0.7.72 → 0.7.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-Bjf2HPzR.js → hyperframes-player-wiJqS2i-.js} +1 -1
- package/dist/assets/index-B37rWXo4.css +1 -0
- package/dist/assets/{index-Ct_pxETK.js → index-BXepgTJb.js} +1 -1
- package/dist/assets/index-CBDJuOGW.js +428 -0
- package/dist/assets/{index-Ch1hbJ3e.js → index-DGVNG1dd.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +4203 -2281
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/PropertyPanelFlat.tsx +1 -0
- package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
- package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
- package/src/components/editor/propertyPanelColorCurveGraph.tsx +549 -0
- package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
- package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
- package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
- package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
- package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
- package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
- package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
- package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
- package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +168 -3
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +184 -243
- package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
- package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
- package/src/components/editor/useColorGradingController.test.ts +133 -17
- package/src/components/editor/useColorGradingController.ts +11 -2
- package/src/components/editor/useColorGradingPreviews.ts +54 -11
- package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
- package/src/components/editor/useColorGradingScopes.ts +62 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
- package/src/icons/SystemIcons.tsx +4 -0
- package/dist/assets/index-BgZMle9I.css +0 -1
- package/dist/assets/index-DCyWLHnx.js +0 -428
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
getHfColorGradingCapabilities,
|
|
4
|
+
normalizeHfColorGrading,
|
|
5
|
+
type NormalizedHfColorGradingSecondary,
|
|
6
|
+
} from "@hyperframes/core/color-grading";
|
|
7
|
+
import { Eyedropper, Plus, Trash } from "../../icons/SystemIcons";
|
|
8
|
+
import { FlatSlider } from "./propertyPanelFlatPrimitives";
|
|
9
|
+
import { FlatToggle } from "./propertyPanelFlatToggle";
|
|
10
|
+
import type { ColorGradingCapturedFrame } from "./useColorGradingPreviews";
|
|
11
|
+
import {
|
|
12
|
+
buildColorGradingSecondaryMatte,
|
|
13
|
+
readColorGradingFramePixels,
|
|
14
|
+
sampleColorGradingSecondary,
|
|
15
|
+
} from "./colorGradingFrameAnalysis";
|
|
16
|
+
|
|
17
|
+
type Secondaries = readonly NormalizedHfColorGradingSecondary[];
|
|
18
|
+
|
|
19
|
+
const SECONDARY_CAPABILITIES = getHfColorGradingCapabilities().secondaries;
|
|
20
|
+
const PERCENT_SCALE = 100;
|
|
21
|
+
const RANGE_GAP = 0.01;
|
|
22
|
+
const HUE_CENTER_MAX = SECONDARY_CAPABILITIES.hue.center.maxExclusive - RANGE_GAP;
|
|
23
|
+
|
|
24
|
+
function wrapHueCenter(value: number): number {
|
|
25
|
+
const { min, maxExclusive } = SECONDARY_CAPABILITIES.hue.center;
|
|
26
|
+
const span = maxExclusive - min;
|
|
27
|
+
return ((((value - min) % span) + span) % span) + min;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CORRECTION_CONTROLS = [
|
|
31
|
+
["hueShift", "Hue shift", 1, "°"],
|
|
32
|
+
["saturation", "Saturation", PERCENT_SCALE, "%"],
|
|
33
|
+
["luma", "Luma", PERCENT_SCALE, "%"],
|
|
34
|
+
["temperature", "Warmth", PERCENT_SCALE, "%"],
|
|
35
|
+
["tint", "Tint", PERCENT_SCALE, "%"],
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
function defaultSecondary(): NormalizedHfColorGradingSecondary {
|
|
39
|
+
const secondary = normalizeHfColorGrading({
|
|
40
|
+
secondaries: [{ key: {}, correction: {} }],
|
|
41
|
+
})?.secondaries?.[0];
|
|
42
|
+
if (!secondary) throw new Error("Missing default color grading secondary");
|
|
43
|
+
return secondary;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_SECONDARY = defaultSecondary();
|
|
47
|
+
|
|
48
|
+
function sampleCapturedFrame(
|
|
49
|
+
pixels: Uint8ClampedArray,
|
|
50
|
+
frame: ColorGradingCapturedFrame,
|
|
51
|
+
target: HTMLElement,
|
|
52
|
+
clientX?: number,
|
|
53
|
+
clientY?: number,
|
|
54
|
+
) {
|
|
55
|
+
const rect = target.getBoundingClientRect();
|
|
56
|
+
const x = (clientX === undefined ? 0.5 : (clientX - rect.left) / rect.width) * frame.width;
|
|
57
|
+
const y = (clientY === undefined ? 0.5 : (clientY - rect.top) / rect.height) * frame.height;
|
|
58
|
+
return sampleColorGradingSecondary(pixels, frame.width, frame.height, x, y);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function percent(value: number): string {
|
|
62
|
+
return `${Math.round(value * 100)}%`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function PropertyPanelColorSecondary({
|
|
66
|
+
secondaries,
|
|
67
|
+
captureFrame,
|
|
68
|
+
onCommit,
|
|
69
|
+
}: {
|
|
70
|
+
secondaries: Secondaries;
|
|
71
|
+
captureFrame: () => Promise<ColorGradingCapturedFrame | null>;
|
|
72
|
+
onCommit: (secondaries: Secondaries) => void;
|
|
73
|
+
}) {
|
|
74
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
75
|
+
const [sampleFrame, setSampleFrame] = useState<ColorGradingCapturedFrame | null>(null);
|
|
76
|
+
const [samplePixels, setSamplePixels] = useState<Uint8ClampedArray | null>(null);
|
|
77
|
+
const [showMatte, setShowMatte] = useState(false);
|
|
78
|
+
const [captureError, setCaptureError] = useState<string | null>(null);
|
|
79
|
+
const [sampling, setSampling] = useState(false);
|
|
80
|
+
const matteCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
81
|
+
const activeIndex = Math.min(selectedIndex, Math.max(0, secondaries.length - 1));
|
|
82
|
+
const selected = secondaries[activeIndex];
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const canvas = matteCanvasRef.current;
|
|
86
|
+
if (!showMatte || !canvas || !sampleFrame || !samplePixels || !selected) return;
|
|
87
|
+
const context = canvas.getContext("2d");
|
|
88
|
+
if (!context) return;
|
|
89
|
+
const image = context.createImageData(sampleFrame.width, sampleFrame.height);
|
|
90
|
+
image.data.set(
|
|
91
|
+
buildColorGradingSecondaryMatte(
|
|
92
|
+
samplePixels,
|
|
93
|
+
sampleFrame.width,
|
|
94
|
+
sampleFrame.height,
|
|
95
|
+
selected.key,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
context.putImageData(image, 0, 0);
|
|
99
|
+
}, [sampleFrame, samplePixels, selected, showMatte]);
|
|
100
|
+
|
|
101
|
+
const replaceSelected = (next: NormalizedHfColorGradingSecondary) => {
|
|
102
|
+
if (!selected) return;
|
|
103
|
+
onCommit(secondaries.map((secondary, index) => (index === activeIndex ? next : secondary)));
|
|
104
|
+
};
|
|
105
|
+
const addSecondary = () => {
|
|
106
|
+
if (secondaries.length >= SECONDARY_CAPABILITIES.max) return;
|
|
107
|
+
onCommit([...secondaries, DEFAULT_SECONDARY]);
|
|
108
|
+
setSelectedIndex(secondaries.length);
|
|
109
|
+
};
|
|
110
|
+
const removeSelected = () => {
|
|
111
|
+
if (!selected) return;
|
|
112
|
+
const next = secondaries.filter((_, index) => index !== activeIndex);
|
|
113
|
+
onCommit(next);
|
|
114
|
+
setSelectedIndex(Math.max(0, Math.min(activeIndex, next.length - 1)));
|
|
115
|
+
setSampleFrame(null);
|
|
116
|
+
setSamplePixels(null);
|
|
117
|
+
setCaptureError(null);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className="space-y-1.5" data-flat-grade-secondary="true">
|
|
122
|
+
<div className="flex items-center justify-between gap-2">
|
|
123
|
+
<span className="flex items-center gap-1">
|
|
124
|
+
{secondaries.map((_, index) => (
|
|
125
|
+
<button
|
|
126
|
+
key={index}
|
|
127
|
+
type="button"
|
|
128
|
+
aria-pressed={activeIndex === index}
|
|
129
|
+
onClick={() => {
|
|
130
|
+
setSelectedIndex(index);
|
|
131
|
+
setSampleFrame(null);
|
|
132
|
+
setSamplePixels(null);
|
|
133
|
+
setCaptureError(null);
|
|
134
|
+
}}
|
|
135
|
+
className={`h-6 min-w-6 border-b-2 px-1 font-mono text-[10px] ${
|
|
136
|
+
activeIndex === index
|
|
137
|
+
? "border-panel-accent text-panel-text-0"
|
|
138
|
+
: "border-transparent text-panel-text-4 hover:text-panel-text-2"
|
|
139
|
+
}`}
|
|
140
|
+
>
|
|
141
|
+
{index + 1}
|
|
142
|
+
</button>
|
|
143
|
+
))}
|
|
144
|
+
</span>
|
|
145
|
+
<span className="flex items-center gap-2">
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
aria-label="Add secondary color selection"
|
|
149
|
+
title="Add secondary color selection"
|
|
150
|
+
disabled={secondaries.length >= SECONDARY_CAPABILITIES.max}
|
|
151
|
+
onClick={addSecondary}
|
|
152
|
+
className="text-panel-text-3 hover:text-panel-text-1 disabled:opacity-35"
|
|
153
|
+
>
|
|
154
|
+
<Plus size={12} />
|
|
155
|
+
</button>
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
aria-label="Remove selected secondary"
|
|
159
|
+
title="Remove selected secondary"
|
|
160
|
+
disabled={!selected}
|
|
161
|
+
onClick={removeSelected}
|
|
162
|
+
className="text-panel-text-3 hover:text-panel-text-1 disabled:opacity-35"
|
|
163
|
+
>
|
|
164
|
+
<Trash size={12} />
|
|
165
|
+
</button>
|
|
166
|
+
</span>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{!selected ? (
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
title="Add color selection"
|
|
173
|
+
onClick={addSecondary}
|
|
174
|
+
className="w-full border border-dashed border-panel-border-input px-2 py-2 text-[10px] text-panel-text-4 hover:border-panel-accent/50 hover:text-panel-text-2"
|
|
175
|
+
>
|
|
176
|
+
Add a color selection
|
|
177
|
+
</button>
|
|
178
|
+
) : (
|
|
179
|
+
<>
|
|
180
|
+
<FlatToggle
|
|
181
|
+
label="Selection enabled"
|
|
182
|
+
checked={selected.enabled}
|
|
183
|
+
onChange={(enabled) => replaceSelected({ ...selected, enabled })}
|
|
184
|
+
/>
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
disabled={sampling}
|
|
188
|
+
onClick={async () => {
|
|
189
|
+
setSampling(true);
|
|
190
|
+
setCaptureError(null);
|
|
191
|
+
try {
|
|
192
|
+
const frame = await captureFrame();
|
|
193
|
+
if (!frame) throw new Error("Frame capture unavailable");
|
|
194
|
+
const pixels = await readColorGradingFramePixels(frame);
|
|
195
|
+
if (!pixels) throw new Error("Frame pixels unavailable");
|
|
196
|
+
setSampleFrame(frame);
|
|
197
|
+
setSamplePixels(pixels);
|
|
198
|
+
setShowMatte(false);
|
|
199
|
+
} catch {
|
|
200
|
+
setSampleFrame(null);
|
|
201
|
+
setSamplePixels(null);
|
|
202
|
+
setCaptureError("Preview frame unavailable. Try again after the media loads.");
|
|
203
|
+
} finally {
|
|
204
|
+
setSampling(false);
|
|
205
|
+
}
|
|
206
|
+
}}
|
|
207
|
+
className="flex min-h-7 items-center gap-1.5 text-[10px] font-medium text-panel-accent hover:text-panel-accent/80 disabled:opacity-50"
|
|
208
|
+
>
|
|
209
|
+
<Eyedropper size={12} />
|
|
210
|
+
{sampling ? "Capturing frame" : "Sample color from frame"}
|
|
211
|
+
</button>
|
|
212
|
+
{captureError && (
|
|
213
|
+
<p role="alert" className="text-[9px] leading-4 text-red-300">
|
|
214
|
+
{captureError}
|
|
215
|
+
</p>
|
|
216
|
+
)}
|
|
217
|
+
{sampleFrame && samplePixels && (
|
|
218
|
+
<div className="space-y-1">
|
|
219
|
+
<div className="flex gap-2 text-[9px]">
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
aria-pressed={!showMatte}
|
|
223
|
+
onClick={() => setShowMatte(false)}
|
|
224
|
+
className={!showMatte ? "text-panel-accent" : "text-panel-text-4"}
|
|
225
|
+
>
|
|
226
|
+
Source
|
|
227
|
+
</button>
|
|
228
|
+
<button
|
|
229
|
+
type="button"
|
|
230
|
+
aria-pressed={showMatte}
|
|
231
|
+
onClick={() => setShowMatte(true)}
|
|
232
|
+
className={showMatte ? "text-panel-accent" : "text-panel-text-4"}
|
|
233
|
+
>
|
|
234
|
+
Selection matte
|
|
235
|
+
</button>
|
|
236
|
+
</div>
|
|
237
|
+
{showMatte ? (
|
|
238
|
+
<canvas
|
|
239
|
+
ref={matteCanvasRef}
|
|
240
|
+
width={sampleFrame.width}
|
|
241
|
+
height={sampleFrame.height}
|
|
242
|
+
role="img"
|
|
243
|
+
aria-label="Selected color matte"
|
|
244
|
+
className="block h-auto w-full border border-panel-hairline bg-black"
|
|
245
|
+
/>
|
|
246
|
+
) : (
|
|
247
|
+
<button
|
|
248
|
+
type="button"
|
|
249
|
+
className="block w-full overflow-hidden border border-panel-hairline bg-black"
|
|
250
|
+
title="Click a color to initialize this selection"
|
|
251
|
+
aria-label="Sample color from captured frame"
|
|
252
|
+
onClick={(event) => {
|
|
253
|
+
const pointer: [] | [number, number] =
|
|
254
|
+
event.detail === 0 ? [] : [event.clientX, event.clientY];
|
|
255
|
+
const key = sampleCapturedFrame(
|
|
256
|
+
samplePixels,
|
|
257
|
+
sampleFrame,
|
|
258
|
+
event.currentTarget,
|
|
259
|
+
...pointer,
|
|
260
|
+
);
|
|
261
|
+
if (!key) return;
|
|
262
|
+
replaceSelected({ ...selected, key });
|
|
263
|
+
setShowMatte(true);
|
|
264
|
+
}}
|
|
265
|
+
>
|
|
266
|
+
<img
|
|
267
|
+
src={sampleFrame.dataUrl}
|
|
268
|
+
alt=""
|
|
269
|
+
draggable={false}
|
|
270
|
+
className="block h-auto w-full cursor-crosshair object-contain"
|
|
271
|
+
/>
|
|
272
|
+
</button>
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
)}
|
|
276
|
+
|
|
277
|
+
<div className="text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
|
|
278
|
+
Qualifier
|
|
279
|
+
</div>
|
|
280
|
+
<FlatSlider
|
|
281
|
+
label="Hue"
|
|
282
|
+
value={selected.key.hue.center}
|
|
283
|
+
min={SECONDARY_CAPABILITIES.hue.center.min}
|
|
284
|
+
max={HUE_CENTER_MAX}
|
|
285
|
+
step={0.1}
|
|
286
|
+
tier="explicitCustom"
|
|
287
|
+
displayValue={`${Math.round(selected.key.hue.center)}°`}
|
|
288
|
+
onCommit={(center) =>
|
|
289
|
+
replaceSelected({
|
|
290
|
+
...selected,
|
|
291
|
+
key: {
|
|
292
|
+
...selected.key,
|
|
293
|
+
hue: { ...selected.key.hue, center: wrapHueCenter(center) },
|
|
294
|
+
},
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
/>
|
|
298
|
+
<FlatSlider
|
|
299
|
+
label="Hue range"
|
|
300
|
+
value={selected.key.hue.range}
|
|
301
|
+
min={SECONDARY_CAPABILITIES.hue.range.min}
|
|
302
|
+
max={SECONDARY_CAPABILITIES.hue.range.max}
|
|
303
|
+
tier="explicitCustom"
|
|
304
|
+
displayValue={`${Math.round(selected.key.hue.range)}°`}
|
|
305
|
+
onCommit={(range) =>
|
|
306
|
+
replaceSelected({
|
|
307
|
+
...selected,
|
|
308
|
+
key: {
|
|
309
|
+
...selected.key,
|
|
310
|
+
hue: {
|
|
311
|
+
...selected.key.hue,
|
|
312
|
+
range,
|
|
313
|
+
softness: Math.min(
|
|
314
|
+
selected.key.hue.softness,
|
|
315
|
+
SECONDARY_CAPABILITIES.hue.rangePlusSoftnessMax - range,
|
|
316
|
+
),
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
/>
|
|
322
|
+
<FlatSlider
|
|
323
|
+
label="Hue softness"
|
|
324
|
+
value={selected.key.hue.softness}
|
|
325
|
+
min={SECONDARY_CAPABILITIES.hue.softness.min}
|
|
326
|
+
max={Math.min(
|
|
327
|
+
SECONDARY_CAPABILITIES.hue.softness.max,
|
|
328
|
+
SECONDARY_CAPABILITIES.hue.rangePlusSoftnessMax - selected.key.hue.range,
|
|
329
|
+
)}
|
|
330
|
+
tier="explicitCustom"
|
|
331
|
+
displayValue={`${Math.round(selected.key.hue.softness)}°`}
|
|
332
|
+
onCommit={(softness) =>
|
|
333
|
+
replaceSelected({
|
|
334
|
+
...selected,
|
|
335
|
+
key: { ...selected.key, hue: { ...selected.key.hue, softness } },
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
/>
|
|
339
|
+
{(["saturation", "luma"] as const).flatMap((key) => {
|
|
340
|
+
const label = key === "saturation" ? "Saturation" : "Luma";
|
|
341
|
+
const range = selected.key[key];
|
|
342
|
+
const capability = SECONDARY_CAPABILITIES[key];
|
|
343
|
+
return [
|
|
344
|
+
<FlatSlider
|
|
345
|
+
key={`${key}-min`}
|
|
346
|
+
label={`${label} min`}
|
|
347
|
+
value={range.min * PERCENT_SCALE}
|
|
348
|
+
min={capability.min.min * PERCENT_SCALE}
|
|
349
|
+
max={capability.min.max * PERCENT_SCALE}
|
|
350
|
+
tier="explicitCustom"
|
|
351
|
+
displayValue={percent(range.min)}
|
|
352
|
+
onCommit={(value) =>
|
|
353
|
+
replaceSelected({
|
|
354
|
+
...selected,
|
|
355
|
+
key: {
|
|
356
|
+
...selected.key,
|
|
357
|
+
[key]: {
|
|
358
|
+
...range,
|
|
359
|
+
min: Math.max(
|
|
360
|
+
capability.min.min,
|
|
361
|
+
Math.min(value / PERCENT_SCALE, range.max - RANGE_GAP),
|
|
362
|
+
),
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
/>,
|
|
368
|
+
<FlatSlider
|
|
369
|
+
key={`${key}-max`}
|
|
370
|
+
label={`${label} max`}
|
|
371
|
+
value={range.max * PERCENT_SCALE}
|
|
372
|
+
min={capability.max.min * PERCENT_SCALE}
|
|
373
|
+
max={capability.max.max * PERCENT_SCALE}
|
|
374
|
+
tier="explicitCustom"
|
|
375
|
+
displayValue={percent(range.max)}
|
|
376
|
+
onCommit={(value) =>
|
|
377
|
+
replaceSelected({
|
|
378
|
+
...selected,
|
|
379
|
+
key: {
|
|
380
|
+
...selected.key,
|
|
381
|
+
[key]: {
|
|
382
|
+
...range,
|
|
383
|
+
max: Math.min(
|
|
384
|
+
capability.max.max,
|
|
385
|
+
Math.max(value / PERCENT_SCALE, range.min + RANGE_GAP),
|
|
386
|
+
),
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
/>,
|
|
392
|
+
<FlatSlider
|
|
393
|
+
key={`${key}-softness`}
|
|
394
|
+
label={`${label} softness`}
|
|
395
|
+
value={range.softness * PERCENT_SCALE}
|
|
396
|
+
min={capability.softness.min * PERCENT_SCALE}
|
|
397
|
+
max={capability.softness.max * PERCENT_SCALE}
|
|
398
|
+
tier="explicitCustom"
|
|
399
|
+
displayValue={percent(range.softness)}
|
|
400
|
+
onCommit={(value) =>
|
|
401
|
+
replaceSelected({
|
|
402
|
+
...selected,
|
|
403
|
+
key: {
|
|
404
|
+
...selected.key,
|
|
405
|
+
[key]: { ...range, softness: value / PERCENT_SCALE },
|
|
406
|
+
},
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
/>,
|
|
410
|
+
];
|
|
411
|
+
})}
|
|
412
|
+
|
|
413
|
+
<div className="pt-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
|
|
414
|
+
Correction
|
|
415
|
+
</div>
|
|
416
|
+
{CORRECTION_CONTROLS.map(([key, label, scale, suffix]) => {
|
|
417
|
+
const limit = SECONDARY_CAPABILITIES.correction[key];
|
|
418
|
+
const value = selected.correction[key] * scale;
|
|
419
|
+
return (
|
|
420
|
+
<FlatSlider
|
|
421
|
+
key={key}
|
|
422
|
+
label={label}
|
|
423
|
+
value={value}
|
|
424
|
+
min={limit.min * scale}
|
|
425
|
+
max={limit.max * scale}
|
|
426
|
+
centerTick
|
|
427
|
+
tier={Math.abs(value) > 0.0001 ? "explicitCustom" : "default"}
|
|
428
|
+
displayValue={`${Math.round(value)}${suffix}`}
|
|
429
|
+
onCommit={(next) =>
|
|
430
|
+
replaceSelected({
|
|
431
|
+
...selected,
|
|
432
|
+
correction: { ...selected.correction, [key]: next / scale },
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
onReset={() =>
|
|
436
|
+
replaceSelected({
|
|
437
|
+
...selected,
|
|
438
|
+
correction: { ...selected.correction, [key]: 0 },
|
|
439
|
+
})
|
|
440
|
+
}
|
|
441
|
+
/>
|
|
442
|
+
);
|
|
443
|
+
})}
|
|
444
|
+
</>
|
|
445
|
+
)}
|
|
446
|
+
</div>
|
|
447
|
+
);
|
|
448
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { NormalizedHfColorGradingWheels } from "@hyperframes/core/color-grading";
|
|
7
|
+
import { ColorWheels } from "./propertyPanelColorWheels";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
const NEUTRAL: NormalizedHfColorGradingWheels = {
|
|
12
|
+
shadows: { hue: 0, amount: 0, level: 0 },
|
|
13
|
+
midtones: { hue: 0, amount: 0, level: 0 },
|
|
14
|
+
highlights: { hue: 0, amount: 0, level: 0 },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
document.body.innerHTML = "";
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function renderWheels() {
|
|
22
|
+
const onPreview = vi.fn();
|
|
23
|
+
const onCommit = vi.fn();
|
|
24
|
+
const host = document.body.appendChild(document.createElement("div"));
|
|
25
|
+
const root = createRoot(host);
|
|
26
|
+
act(() => root.render(<ColorWheels value={NEUTRAL} onPreview={onPreview} onCommit={onCommit} />));
|
|
27
|
+
const surface = host.querySelector<HTMLDivElement>(
|
|
28
|
+
'[data-color-wheel="shadows"] [data-color-wheel-surface="true"]',
|
|
29
|
+
);
|
|
30
|
+
if (!surface) throw new Error("Expected the shadows wheel");
|
|
31
|
+
Object.defineProperty(surface, "getBoundingClientRect", {
|
|
32
|
+
value: () => ({ left: 0, top: 0, width: 100, height: 100, right: 100, bottom: 100 }),
|
|
33
|
+
});
|
|
34
|
+
return { root, surface, onPreview, onCommit };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pointerAt(surface: HTMLElement, x: number, y: number, pointerId = 1) {
|
|
38
|
+
act(() => {
|
|
39
|
+
surface.dispatchEvent(
|
|
40
|
+
new PointerEvent("pointerdown", {
|
|
41
|
+
bubbles: true,
|
|
42
|
+
pointerId,
|
|
43
|
+
clientX: x,
|
|
44
|
+
clientY: y,
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
surface.dispatchEvent(
|
|
48
|
+
new PointerEvent("pointerup", {
|
|
49
|
+
bubbles: true,
|
|
50
|
+
pointerId,
|
|
51
|
+
clientX: x,
|
|
52
|
+
clientY: y,
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("ColorWheels", () => {
|
|
59
|
+
it("maps cardinal pointer colors to the documented counter-clockwise hue angles", () => {
|
|
60
|
+
const cases = [
|
|
61
|
+
[100, 50, 0],
|
|
62
|
+
[50, 0, 90],
|
|
63
|
+
[0, 50, 180],
|
|
64
|
+
[50, 100, 270],
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
for (const [x, y, expectedHue] of cases) {
|
|
68
|
+
const { root, surface, onCommit } = renderWheels();
|
|
69
|
+
pointerAt(surface, x, y);
|
|
70
|
+
expect(onCommit.mock.calls[0]?.[0]?.shadows).toMatchObject({
|
|
71
|
+
hue: expectedHue,
|
|
72
|
+
amount: 1,
|
|
73
|
+
});
|
|
74
|
+
act(() => root.unmount());
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("renders a color sequence that follows the same hue direction as the pointer", () => {
|
|
79
|
+
const { root, surface } = renderWheels();
|
|
80
|
+
expect(surface.style.background).toContain("#f33, #f3f, #33f, #3ff, #3f3, #ff3, #f33");
|
|
81
|
+
act(() => root.unmount());
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("previews during drag and commits only once on release", () => {
|
|
85
|
+
const { root, surface, onPreview, onCommit } = renderWheels();
|
|
86
|
+
act(() => {
|
|
87
|
+
surface.dispatchEvent(
|
|
88
|
+
new PointerEvent("pointerdown", {
|
|
89
|
+
bubbles: true,
|
|
90
|
+
pointerId: 7,
|
|
91
|
+
clientX: 100,
|
|
92
|
+
clientY: 50,
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
surface.dispatchEvent(
|
|
96
|
+
new PointerEvent("pointermove", {
|
|
97
|
+
bubbles: true,
|
|
98
|
+
pointerId: 7,
|
|
99
|
+
clientX: 50,
|
|
100
|
+
clientY: 0,
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
expect(onPreview.mock.calls.at(-1)?.[0]?.shadows.hue).toBe(90);
|
|
105
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
106
|
+
act(() => {
|
|
107
|
+
surface.dispatchEvent(
|
|
108
|
+
new PointerEvent("pointerup", {
|
|
109
|
+
bubbles: true,
|
|
110
|
+
pointerId: 7,
|
|
111
|
+
clientX: 50,
|
|
112
|
+
clientY: 0,
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
expect(onCommit).toHaveBeenCalledOnce();
|
|
117
|
+
act(() => root.unmount());
|
|
118
|
+
});
|
|
119
|
+
});
|