@hyperframes/studio 0.7.72 → 0.7.73
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-DGlzhf_s.js} +1 -1
- package/dist/assets/index-B37rWXo4.css +1 -0
- package/dist/assets/{index-Ct_pxETK.js → index-CbVTOJ-E.js} +1 -1
- package/dist/assets/{index-Ch1hbJ3e.js → index-CcA5WVJv.js} +1 -1
- package/dist/assets/index-DXcLI2wu.js +428 -0
- 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,334 @@
|
|
|
1
|
+
import { useRef, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
2
|
+
import {
|
|
3
|
+
getHfColorGradingCapabilities,
|
|
4
|
+
type HfColorGradingWheelKey,
|
|
5
|
+
type NormalizedHfColorGradingWheels,
|
|
6
|
+
} from "@hyperframes/core/color-grading";
|
|
7
|
+
import { RotateCcw } from "../../icons/SystemIcons";
|
|
8
|
+
import { clampNumber } from "../../utils/studioHelpers";
|
|
9
|
+
import { GradingNumberField } from "./propertyPanelGradingNumberField";
|
|
10
|
+
import { useInspectorGestureDraft } from "./useInspectorGestureTransaction";
|
|
11
|
+
|
|
12
|
+
const WHEELS: ReadonlyArray<{ key: HfColorGradingWheelKey; label: string }> = [
|
|
13
|
+
{ key: "shadows", label: "Shadows" },
|
|
14
|
+
{ key: "midtones", label: "Midtones" },
|
|
15
|
+
{ key: "highlights", label: "Highlights" },
|
|
16
|
+
];
|
|
17
|
+
type NormalizedTonalWheel = NormalizedHfColorGradingWheels[HfColorGradingWheelKey];
|
|
18
|
+
|
|
19
|
+
const WHEEL_CONTROLS = getHfColorGradingCapabilities().wheels.controls;
|
|
20
|
+
const PERCENT_SCALE = 100;
|
|
21
|
+
const HUE_MAX = WHEEL_CONTROLS.hue.maxExclusive - 0.01;
|
|
22
|
+
const RESET_WHEEL: NormalizedTonalWheel = {
|
|
23
|
+
hue: WHEEL_CONTROLS.hue.identity,
|
|
24
|
+
amount: WHEEL_CONTROLS.amount.identity,
|
|
25
|
+
level: WHEEL_CONTROLS.level.identity,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function wrapHue(value: number): number {
|
|
29
|
+
const { min, maxExclusive } = WHEEL_CONTROLS.hue;
|
|
30
|
+
const span = maxExclusive - min;
|
|
31
|
+
return ((((value - min) % span) + span) % span) + min;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatNumberInput(value: number, decimals: number): string {
|
|
35
|
+
return String(Number(value.toFixed(decimals)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const formatIntegerInput = (value: number) => formatNumberInput(value, 0);
|
|
39
|
+
|
|
40
|
+
function wheelFromPointer(
|
|
41
|
+
wheel: NormalizedTonalWheel,
|
|
42
|
+
clientX: number,
|
|
43
|
+
clientY: number,
|
|
44
|
+
rect: DOMRect,
|
|
45
|
+
): NormalizedTonalWheel {
|
|
46
|
+
const radius = Math.max(1, Math.min(rect.width, rect.height) / 2);
|
|
47
|
+
const x = (clientX - (rect.left + rect.width / 2)) / radius;
|
|
48
|
+
const y = (rect.top + rect.height / 2 - clientY) / radius;
|
|
49
|
+
return {
|
|
50
|
+
...wheel,
|
|
51
|
+
hue: wrapHue((Math.atan2(y, x) * 180) / Math.PI),
|
|
52
|
+
amount: clampNumber(Math.hypot(x, y), WHEEL_CONTROLS.amount.min, WHEEL_CONTROLS.amount.max),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function updateWheel(
|
|
57
|
+
value: NormalizedHfColorGradingWheels,
|
|
58
|
+
key: HfColorGradingWheelKey,
|
|
59
|
+
wheel: NormalizedTonalWheel,
|
|
60
|
+
): NormalizedHfColorGradingWheels {
|
|
61
|
+
return { ...value, [key]: wheel };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function wheelFromKey(
|
|
65
|
+
wheel: NormalizedTonalWheel,
|
|
66
|
+
key: string,
|
|
67
|
+
large: boolean,
|
|
68
|
+
): NormalizedTonalWheel | null {
|
|
69
|
+
const hueStep = large ? 10 : 1;
|
|
70
|
+
const amountStep = large ? 0.1 : 0.01;
|
|
71
|
+
switch (key) {
|
|
72
|
+
case "ArrowLeft":
|
|
73
|
+
return { ...wheel, hue: wrapHue(wheel.hue - hueStep) };
|
|
74
|
+
case "ArrowRight":
|
|
75
|
+
return { ...wheel, hue: wrapHue(wheel.hue + hueStep) };
|
|
76
|
+
case "ArrowDown":
|
|
77
|
+
return {
|
|
78
|
+
...wheel,
|
|
79
|
+
amount: clampNumber(
|
|
80
|
+
wheel.amount - amountStep,
|
|
81
|
+
WHEEL_CONTROLS.amount.min,
|
|
82
|
+
WHEEL_CONTROLS.amount.max,
|
|
83
|
+
),
|
|
84
|
+
};
|
|
85
|
+
case "ArrowUp":
|
|
86
|
+
return {
|
|
87
|
+
...wheel,
|
|
88
|
+
amount: clampNumber(
|
|
89
|
+
wheel.amount + amountStep,
|
|
90
|
+
WHEEL_CONTROLS.amount.min,
|
|
91
|
+
WHEEL_CONTROLS.amount.max,
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
case "Home":
|
|
95
|
+
return { ...wheel, amount: WHEEL_CONTROLS.amount.min };
|
|
96
|
+
case "End":
|
|
97
|
+
return { ...wheel, amount: WHEEL_CONTROLS.amount.max };
|
|
98
|
+
default:
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function TonalWheel({
|
|
104
|
+
label,
|
|
105
|
+
wheel,
|
|
106
|
+
disabled,
|
|
107
|
+
onBegin,
|
|
108
|
+
onPreview,
|
|
109
|
+
onSettle,
|
|
110
|
+
onCancel,
|
|
111
|
+
onReset,
|
|
112
|
+
}: {
|
|
113
|
+
label: string;
|
|
114
|
+
wheel: NormalizedTonalWheel;
|
|
115
|
+
disabled?: boolean;
|
|
116
|
+
onBegin: () => void;
|
|
117
|
+
onPreview: (wheel: NormalizedTonalWheel) => void;
|
|
118
|
+
onSettle: () => void;
|
|
119
|
+
onCancel: () => void;
|
|
120
|
+
onReset: () => void;
|
|
121
|
+
}) {
|
|
122
|
+
const pointerIdRef = useRef<number | null>(null);
|
|
123
|
+
const hueRadians = (wheel.hue * Math.PI) / 180;
|
|
124
|
+
const thumbLeft = 50 + Math.cos(hueRadians) * wheel.amount * 46;
|
|
125
|
+
const thumbTop = 50 - Math.sin(hueRadians) * wheel.amount * 46;
|
|
126
|
+
|
|
127
|
+
const previewPointer = (target: HTMLDivElement, clientX: number, clientY: number) => {
|
|
128
|
+
onPreview(wheelFromPointer(wheel, clientX, clientY, target.getBoundingClientRect()));
|
|
129
|
+
};
|
|
130
|
+
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
131
|
+
if (disabled) return;
|
|
132
|
+
if (event.key === "Escape") {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
onCancel();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const next = wheelFromKey(wheel, event.key, event.shiftKey);
|
|
138
|
+
if (!next) return;
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
onBegin();
|
|
141
|
+
onPreview(next);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div data-color-wheel={label.toLowerCase()} className="min-w-[88px] flex-1 space-y-1.5">
|
|
146
|
+
<div className="flex items-center justify-between gap-1">
|
|
147
|
+
<span className="truncate text-[10px] font-medium text-panel-text-2">{label}</span>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
aria-label={`Reset ${label}`}
|
|
151
|
+
title={`Reset ${label}`}
|
|
152
|
+
disabled={disabled}
|
|
153
|
+
onClick={onReset}
|
|
154
|
+
className="text-panel-text-4 hover:text-panel-text-1 disabled:opacity-40"
|
|
155
|
+
>
|
|
156
|
+
<RotateCcw size={10} />
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
<div
|
|
160
|
+
role="slider"
|
|
161
|
+
tabIndex={disabled ? -1 : 0}
|
|
162
|
+
aria-label={`${label} color`}
|
|
163
|
+
aria-valuemin={WHEEL_CONTROLS.amount.min * PERCENT_SCALE}
|
|
164
|
+
aria-valuemax={WHEEL_CONTROLS.amount.max * PERCENT_SCALE}
|
|
165
|
+
aria-valuenow={Math.round(wheel.amount * PERCENT_SCALE)}
|
|
166
|
+
aria-valuetext={`${Math.round(wheel.hue)} degrees, ${Math.round(
|
|
167
|
+
wheel.amount * PERCENT_SCALE,
|
|
168
|
+
)} percent`}
|
|
169
|
+
aria-disabled={disabled}
|
|
170
|
+
data-color-wheel-surface="true"
|
|
171
|
+
onDoubleClick={onReset}
|
|
172
|
+
onPointerDown={(event) => {
|
|
173
|
+
if (disabled) return;
|
|
174
|
+
event.preventDefault();
|
|
175
|
+
onBegin();
|
|
176
|
+
pointerIdRef.current = event.pointerId;
|
|
177
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
178
|
+
previewPointer(event.currentTarget, event.clientX, event.clientY);
|
|
179
|
+
}}
|
|
180
|
+
onPointerMove={(event) => {
|
|
181
|
+
if (disabled || pointerIdRef.current !== event.pointerId) return;
|
|
182
|
+
previewPointer(event.currentTarget, event.clientX, event.clientY);
|
|
183
|
+
}}
|
|
184
|
+
onPointerUp={(event) => {
|
|
185
|
+
if (pointerIdRef.current !== event.pointerId) return;
|
|
186
|
+
previewPointer(event.currentTarget, event.clientX, event.clientY);
|
|
187
|
+
pointerIdRef.current = null;
|
|
188
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
189
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
190
|
+
}
|
|
191
|
+
onSettle();
|
|
192
|
+
}}
|
|
193
|
+
onPointerCancel={(event) => {
|
|
194
|
+
if (pointerIdRef.current !== event.pointerId) return;
|
|
195
|
+
pointerIdRef.current = null;
|
|
196
|
+
onCancel();
|
|
197
|
+
}}
|
|
198
|
+
onKeyDown={handleKeyDown}
|
|
199
|
+
onKeyUp={(event) => {
|
|
200
|
+
if (
|
|
201
|
+
["ArrowLeft", "ArrowRight", "ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)
|
|
202
|
+
) {
|
|
203
|
+
onSettle();
|
|
204
|
+
}
|
|
205
|
+
}}
|
|
206
|
+
onBlur={onSettle}
|
|
207
|
+
className="relative mx-auto aspect-square w-full max-w-[112px] touch-none rounded-full border border-panel-border-input outline-none focus:ring-1 focus:ring-panel-accent disabled:opacity-40"
|
|
208
|
+
style={{
|
|
209
|
+
background:
|
|
210
|
+
"radial-gradient(circle, rgb(128 128 128) 0%, transparent 72%), conic-gradient(from 90deg, #f33, #f3f, #33f, #3ff, #3f3, #ff3, #f33)",
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
<span
|
|
214
|
+
data-color-wheel-thumb="true"
|
|
215
|
+
className="pointer-events-none absolute h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-transparent shadow-[0_0_0_1px_rgba(0,0,0,0.8)]"
|
|
216
|
+
style={{ left: `${thumbLeft}%`, top: `${thumbTop}%` }}
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
<input
|
|
220
|
+
type="range"
|
|
221
|
+
aria-label={`${label} level`}
|
|
222
|
+
min={WHEEL_CONTROLS.level.min}
|
|
223
|
+
max={WHEEL_CONTROLS.level.max}
|
|
224
|
+
step={0.01}
|
|
225
|
+
value={wheel.level}
|
|
226
|
+
disabled={disabled}
|
|
227
|
+
onPointerDown={onBegin}
|
|
228
|
+
onChange={(event) => onPreview({ ...wheel, level: Number(event.target.value) })}
|
|
229
|
+
onPointerUp={onSettle}
|
|
230
|
+
onPointerCancel={onCancel}
|
|
231
|
+
onKeyDown={(event) => {
|
|
232
|
+
if (event.key === "Escape") {
|
|
233
|
+
event.preventDefault();
|
|
234
|
+
onCancel();
|
|
235
|
+
} else {
|
|
236
|
+
onBegin();
|
|
237
|
+
}
|
|
238
|
+
}}
|
|
239
|
+
onKeyUp={onSettle}
|
|
240
|
+
onBlur={onSettle}
|
|
241
|
+
className="h-3 w-full accent-panel-accent"
|
|
242
|
+
/>
|
|
243
|
+
<div className="grid grid-cols-3 gap-1">
|
|
244
|
+
<GradingNumberField
|
|
245
|
+
label="Hue"
|
|
246
|
+
value={wheel.hue}
|
|
247
|
+
min={WHEEL_CONTROLS.hue.min}
|
|
248
|
+
max={HUE_MAX}
|
|
249
|
+
disabled={disabled}
|
|
250
|
+
formatValue={formatIntegerInput}
|
|
251
|
+
labelTextClassName="block text-[8px] uppercase text-panel-text-5"
|
|
252
|
+
onBegin={onBegin}
|
|
253
|
+
onPreview={(hue) => onPreview({ ...wheel, hue: wrapHue(hue) })}
|
|
254
|
+
onSettle={onSettle}
|
|
255
|
+
onCancel={onCancel}
|
|
256
|
+
/>
|
|
257
|
+
<GradingNumberField
|
|
258
|
+
label="Amount"
|
|
259
|
+
value={wheel.amount * PERCENT_SCALE}
|
|
260
|
+
min={WHEEL_CONTROLS.amount.min * PERCENT_SCALE}
|
|
261
|
+
max={WHEEL_CONTROLS.amount.max * PERCENT_SCALE}
|
|
262
|
+
disabled={disabled}
|
|
263
|
+
formatValue={formatIntegerInput}
|
|
264
|
+
labelTextClassName="block text-[8px] uppercase text-panel-text-5"
|
|
265
|
+
onBegin={onBegin}
|
|
266
|
+
onPreview={(amount) => onPreview({ ...wheel, amount: amount / PERCENT_SCALE })}
|
|
267
|
+
onSettle={onSettle}
|
|
268
|
+
onCancel={onCancel}
|
|
269
|
+
/>
|
|
270
|
+
<GradingNumberField
|
|
271
|
+
label="Level"
|
|
272
|
+
value={wheel.level * PERCENT_SCALE}
|
|
273
|
+
min={WHEEL_CONTROLS.level.min * PERCENT_SCALE}
|
|
274
|
+
max={WHEEL_CONTROLS.level.max * PERCENT_SCALE}
|
|
275
|
+
disabled={disabled}
|
|
276
|
+
formatValue={formatIntegerInput}
|
|
277
|
+
labelTextClassName="block text-[8px] uppercase text-panel-text-5"
|
|
278
|
+
onBegin={onBegin}
|
|
279
|
+
onPreview={(level) => onPreview({ ...wheel, level: level / PERCENT_SCALE })}
|
|
280
|
+
onSettle={onSettle}
|
|
281
|
+
onCancel={onCancel}
|
|
282
|
+
/>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function ColorWheels({
|
|
289
|
+
value,
|
|
290
|
+
disabled,
|
|
291
|
+
onPreview,
|
|
292
|
+
onCommit,
|
|
293
|
+
}: {
|
|
294
|
+
value: NormalizedHfColorGradingWheels;
|
|
295
|
+
disabled?: boolean;
|
|
296
|
+
onPreview: (value: NormalizedHfColorGradingWheels) => void;
|
|
297
|
+
onCommit: (value: NormalizedHfColorGradingWheels) => void;
|
|
298
|
+
}) {
|
|
299
|
+
const { draft, setDraft, transaction } = useInspectorGestureDraft({
|
|
300
|
+
sourceValue: value,
|
|
301
|
+
onPreview,
|
|
302
|
+
onCommit,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const previewWheel = (key: HfColorGradingWheelKey, wheel: NormalizedTonalWheel) =>
|
|
306
|
+
transaction.preview(updateWheel(draft, key, wheel));
|
|
307
|
+
const resetWheel = (key: HfColorGradingWheelKey) => {
|
|
308
|
+
transaction.cancel();
|
|
309
|
+
const next = updateWheel(draft, key, RESET_WHEEL);
|
|
310
|
+
setDraft(next);
|
|
311
|
+
onCommit(next);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
<div
|
|
316
|
+
data-color-wheels="true"
|
|
317
|
+
className="grid grid-cols-[repeat(auto-fit,minmax(88px,1fr))] gap-2"
|
|
318
|
+
>
|
|
319
|
+
{WHEELS.map(({ key, label }) => (
|
|
320
|
+
<TonalWheel
|
|
321
|
+
key={key}
|
|
322
|
+
label={label}
|
|
323
|
+
wheel={draft[key]}
|
|
324
|
+
disabled={disabled}
|
|
325
|
+
onBegin={transaction.begin}
|
|
326
|
+
onPreview={(wheel) => previewWheel(key, wheel)}
|
|
327
|
+
onSettle={transaction.settle}
|
|
328
|
+
onCancel={transaction.cancel}
|
|
329
|
+
onReset={() => resetWheel(key)}
|
|
330
|
+
/>
|
|
331
|
+
))}
|
|
332
|
+
</div>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { isHfColorGradingActive } from "@hyperframes/core/color-grading";
|
|
3
|
+
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
4
|
+
import { Compare, RotateCcw } from "../../icons/SystemIcons";
|
|
5
|
+
import type { ColorGradingControllerState } from "./useColorGradingController";
|
|
6
|
+
|
|
7
|
+
const STATUS_DOT_CLASS: Record<ColorGradingControllerState["runtimeStatus"]["state"], string> = {
|
|
8
|
+
active: "bg-emerald-400",
|
|
9
|
+
pending: "bg-amber-300",
|
|
10
|
+
unavailable: "bg-red-400",
|
|
11
|
+
missing: "bg-panel-text-5",
|
|
12
|
+
inactive: "bg-panel-text-5",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function FlatColorGradingAccessory({
|
|
16
|
+
state,
|
|
17
|
+
}: {
|
|
18
|
+
state: Pick<
|
|
19
|
+
ColorGradingControllerState,
|
|
20
|
+
"grading" | "compareEnabled" | "runtimeStatus" | "commitCompare" | "resetGrading"
|
|
21
|
+
>;
|
|
22
|
+
}) {
|
|
23
|
+
const track = useTrackDesignInput();
|
|
24
|
+
const { grading, compareEnabled, runtimeStatus, commitCompare, resetGrading } = state;
|
|
25
|
+
const gradingActive = isHfColorGradingActive(grading);
|
|
26
|
+
const releaseRef = useRef<(() => void) | null>(null);
|
|
27
|
+
useEffect(
|
|
28
|
+
() => () => {
|
|
29
|
+
releaseRef.current?.();
|
|
30
|
+
releaseRef.current = null;
|
|
31
|
+
},
|
|
32
|
+
[],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<span className="flex items-center gap-2.5">
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
aria-pressed={compareEnabled}
|
|
40
|
+
aria-label="Hold to show original"
|
|
41
|
+
disabled={!gradingActive}
|
|
42
|
+
onPointerDown={(e) => {
|
|
43
|
+
if (!gradingActive) return;
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
e.stopPropagation();
|
|
46
|
+
track("button", "Compare original");
|
|
47
|
+
commitCompare(true);
|
|
48
|
+
const release = () => {
|
|
49
|
+
commitCompare(false);
|
|
50
|
+
window.removeEventListener("pointerup", release);
|
|
51
|
+
window.removeEventListener("pointercancel", release);
|
|
52
|
+
window.removeEventListener("blur", release);
|
|
53
|
+
releaseRef.current = null;
|
|
54
|
+
};
|
|
55
|
+
releaseRef.current = release;
|
|
56
|
+
window.addEventListener("pointerup", release);
|
|
57
|
+
window.addEventListener("pointercancel", release);
|
|
58
|
+
window.addEventListener("blur", release);
|
|
59
|
+
}}
|
|
60
|
+
onBlur={() => {
|
|
61
|
+
if (compareEnabled) commitCompare(false);
|
|
62
|
+
}}
|
|
63
|
+
onKeyDown={(e) => {
|
|
64
|
+
if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
if (!compareEnabled) {
|
|
67
|
+
track("button", "Compare original");
|
|
68
|
+
commitCompare(true);
|
|
69
|
+
}
|
|
70
|
+
}}
|
|
71
|
+
onKeyUp={(e) => {
|
|
72
|
+
if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
commitCompare(false);
|
|
75
|
+
}}
|
|
76
|
+
title="Hold to show original"
|
|
77
|
+
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
|
|
78
|
+
>
|
|
79
|
+
<Compare size={12} />
|
|
80
|
+
</button>
|
|
81
|
+
<span className="flex min-w-0 items-center gap-1" title={runtimeStatus.message}>
|
|
82
|
+
<span
|
|
83
|
+
data-flat-grade-status-dot="true"
|
|
84
|
+
title={runtimeStatus.message}
|
|
85
|
+
className={`h-[5px] w-[5px] flex-shrink-0 rounded-full ${STATUS_DOT_CLASS[runtimeStatus.state]}`}
|
|
86
|
+
/>
|
|
87
|
+
<span
|
|
88
|
+
data-flat-grade-status-message="true"
|
|
89
|
+
className="max-w-[84px] truncate text-[9px] text-panel-text-4"
|
|
90
|
+
>
|
|
91
|
+
{runtimeStatus.message}
|
|
92
|
+
</span>
|
|
93
|
+
</span>
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
data-flat-grade-reset="true"
|
|
97
|
+
title="Reset color grading"
|
|
98
|
+
onClick={(e) => {
|
|
99
|
+
e.stopPropagation();
|
|
100
|
+
track("button", "Reset color grading");
|
|
101
|
+
resetGrading();
|
|
102
|
+
}}
|
|
103
|
+
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1"
|
|
104
|
+
>
|
|
105
|
+
<RotateCcw size={12} />
|
|
106
|
+
</button>
|
|
107
|
+
</span>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
FlatColorGradingAccessory,
|
|
8
8
|
FlatColorGradingSection,
|
|
9
9
|
} from "./propertyPanelFlatColorGradingSection";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
normalizeHfColorGrading,
|
|
12
|
+
type NormalizedHfColorGrading,
|
|
13
|
+
} from "@hyperframes/core/color-grading";
|
|
11
14
|
|
|
12
15
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
16
|
|
|
@@ -229,6 +232,7 @@ function neutralPropsBase() {
|
|
|
229
232
|
height: 90,
|
|
230
233
|
},
|
|
231
234
|
onRequestPresetPreviews: vi.fn(),
|
|
235
|
+
captureGradedFrame: vi.fn(async () => null),
|
|
232
236
|
};
|
|
233
237
|
}
|
|
234
238
|
|
|
@@ -241,6 +245,35 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
|
|
|
241
245
|
...base,
|
|
242
246
|
intensity: 0.4,
|
|
243
247
|
adjust: { ...base.adjust, contrast: 0.3 },
|
|
248
|
+
wheels: {
|
|
249
|
+
...base.wheels,
|
|
250
|
+
shadows: { hue: 210, amount: 0.12, level: 0.04 },
|
|
251
|
+
},
|
|
252
|
+
curves: {
|
|
253
|
+
...base.curves,
|
|
254
|
+
master: [
|
|
255
|
+
[0, 0],
|
|
256
|
+
[0.5, 0.58],
|
|
257
|
+
[1, 1],
|
|
258
|
+
] as const,
|
|
259
|
+
},
|
|
260
|
+
hueCurves: {
|
|
261
|
+
...base.hueCurves,
|
|
262
|
+
hueVsSaturation: [
|
|
263
|
+
[0, 0],
|
|
264
|
+
[120, 0.1],
|
|
265
|
+
[240, 0],
|
|
266
|
+
] as const,
|
|
267
|
+
},
|
|
268
|
+
secondaries:
|
|
269
|
+
normalizeHfColorGrading({
|
|
270
|
+
secondaries: [
|
|
271
|
+
{
|
|
272
|
+
key: { hue: { center: 30, range: 15 } },
|
|
273
|
+
correction: { saturation: 0.08 },
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
})?.secondaries ?? [],
|
|
244
277
|
details: { ...base.details, vignette: 0.2 },
|
|
245
278
|
effects: { ...base.effects, pixelate: 0.5 },
|
|
246
279
|
palette: ["#112233", "#ffffff"],
|
|
@@ -277,11 +310,109 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
|
|
|
277
310
|
palette: ["#112233", "#ffffff"],
|
|
278
311
|
lut: { src: "assets/luts/custom.cube", intensity: 0.6 },
|
|
279
312
|
});
|
|
313
|
+
expect(onCommitColorGrading.mock.calls[0][0].wheels.shadows.amount).toBe(0);
|
|
314
|
+
expect(onCommitColorGrading.mock.calls[0][0].curves.master).toEqual([
|
|
315
|
+
[0, 0],
|
|
316
|
+
[1, 1],
|
|
317
|
+
]);
|
|
318
|
+
expect(onCommitColorGrading.mock.calls[0][0].hueCurves.hueVsSaturation).toEqual([]);
|
|
319
|
+
expect(onCommitColorGrading.mock.calls[0][0].secondaries).toEqual([]);
|
|
280
320
|
expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).not.toBe(0.3);
|
|
281
321
|
expect(onCommitColorGrading.mock.calls[0][0].details.vignette).not.toBe(0.2);
|
|
282
322
|
act(() => root.unmount());
|
|
283
323
|
});
|
|
284
324
|
|
|
325
|
+
it("orders manual controls like the shader pipeline", () => {
|
|
326
|
+
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
|
|
327
|
+
const elements = [
|
|
328
|
+
host.querySelector('[data-flat-grade-adjust="true"]'),
|
|
329
|
+
host.querySelector('[data-color-wheels="true"]'),
|
|
330
|
+
host.querySelector('[data-color-curves="true"]'),
|
|
331
|
+
host.querySelector('[data-flat-grade-secondary="true"]'),
|
|
332
|
+
host.querySelector('[data-flat-grade-lut-toggle="true"]'),
|
|
333
|
+
];
|
|
334
|
+
expect(elements.every(Boolean)).toBe(true);
|
|
335
|
+
for (let index = 0; index < elements.length - 1; index += 1) {
|
|
336
|
+
const current = elements[index];
|
|
337
|
+
const next = elements[index + 1];
|
|
338
|
+
if (!current || !next) throw new Error("expected all grading sections");
|
|
339
|
+
expect(current.compareDocumentPosition(next) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
|
|
340
|
+
}
|
|
341
|
+
act(() => root.unmount());
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("samples the full-strength signal entering the secondary stage", async () => {
|
|
345
|
+
const base = neutralGrading();
|
|
346
|
+
const grading = normalizeHfColorGrading({
|
|
347
|
+
...base,
|
|
348
|
+
preset: "warm-polished",
|
|
349
|
+
intensity: 0.35,
|
|
350
|
+
adjust: { ...base.adjust, exposure: 0.2 },
|
|
351
|
+
wheels: {
|
|
352
|
+
...base.wheels,
|
|
353
|
+
shadows: { hue: 210, amount: 0.2, level: 0.05 },
|
|
354
|
+
},
|
|
355
|
+
curves: {
|
|
356
|
+
...base.curves,
|
|
357
|
+
master: [
|
|
358
|
+
[0, 0],
|
|
359
|
+
[0.5, 0.6],
|
|
360
|
+
[1, 1],
|
|
361
|
+
],
|
|
362
|
+
},
|
|
363
|
+
hueCurves: {
|
|
364
|
+
...base.hueCurves,
|
|
365
|
+
hueVsSaturation: [
|
|
366
|
+
[0, 0],
|
|
367
|
+
[120, 0.1],
|
|
368
|
+
[240, 0],
|
|
369
|
+
],
|
|
370
|
+
},
|
|
371
|
+
secondaries: [{ key: { hue: { center: 30, range: 15 } }, correction: {} }],
|
|
372
|
+
details: { ...base.details, vignette: 0.4, grain: 0.2 },
|
|
373
|
+
effects: { ...base.effects, pixelate: 0.5 },
|
|
374
|
+
lut: { src: "assets/luts/custom.cube", intensity: 0.8 },
|
|
375
|
+
});
|
|
376
|
+
if (!grading) throw new Error("expected a secondary grade");
|
|
377
|
+
const captureGradedFrame = vi.fn(
|
|
378
|
+
async (_options?: { grading?: NormalizedHfColorGrading }) => null,
|
|
379
|
+
);
|
|
380
|
+
const { host, root } = renderInto(
|
|
381
|
+
<FlatColorGradingSection
|
|
382
|
+
{...neutralPropsBase()}
|
|
383
|
+
grading={grading}
|
|
384
|
+
captureGradedFrame={captureGradedFrame}
|
|
385
|
+
/>,
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
await act(async () => {
|
|
389
|
+
const sample = Array.from(host.querySelectorAll<HTMLButtonElement>("button")).find((button) =>
|
|
390
|
+
button.textContent?.includes("Sample color from frame"),
|
|
391
|
+
);
|
|
392
|
+
sample?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
393
|
+
await Promise.resolve();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(captureGradedFrame).toHaveBeenCalledTimes(1);
|
|
397
|
+
const options = captureGradedFrame.mock.calls[0]?.[0];
|
|
398
|
+
const sampledGrading = options?.grading;
|
|
399
|
+
if (!sampledGrading) throw new Error("expected the sampled grading input");
|
|
400
|
+
expect(sampledGrading).toMatchObject({
|
|
401
|
+
preset: null,
|
|
402
|
+
intensity: 1,
|
|
403
|
+
adjust: grading.adjust,
|
|
404
|
+
wheels: grading.wheels,
|
|
405
|
+
curves: grading.curves,
|
|
406
|
+
hueCurves: grading.hueCurves,
|
|
407
|
+
secondaries: [],
|
|
408
|
+
lut: null,
|
|
409
|
+
});
|
|
410
|
+
expect(sampledGrading.details.vignette).toBe(0);
|
|
411
|
+
expect(sampledGrading.details.grain).toBe(0);
|
|
412
|
+
expect(sampledGrading.effects.pixelate).toBe(0);
|
|
413
|
+
act(() => root.unmount());
|
|
414
|
+
});
|
|
415
|
+
|
|
285
416
|
it("keeps effect-bearing saved styles out of Grade", () => {
|
|
286
417
|
const { host, root } = renderInto(<FlatColorGradingSection {...neutralPropsBase()} />);
|
|
287
418
|
const presets = host.querySelector('[data-flat-grade-preset-group="presets"]');
|
|
@@ -294,7 +425,7 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
|
|
|
294
425
|
act(() => root.unmount());
|
|
295
426
|
});
|
|
296
427
|
|
|
297
|
-
it("shows
|
|
428
|
+
it("shows ready selected-media preview images without requesting a duplicate batch", () => {
|
|
298
429
|
const onRequestPresetPreviews = vi.fn();
|
|
299
430
|
const { host, root } = renderInto(
|
|
300
431
|
<FlatColorGradingSection
|
|
@@ -302,7 +433,7 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
|
|
|
302
433
|
onRequestPresetPreviews={onRequestPresetPreviews}
|
|
303
434
|
/>,
|
|
304
435
|
);
|
|
305
|
-
expect(onRequestPresetPreviews).
|
|
436
|
+
expect(onRequestPresetPreviews).not.toHaveBeenCalled();
|
|
306
437
|
expect(
|
|
307
438
|
host.querySelector<HTMLImageElement>('[data-flat-grade-preview="bright-pop"]')?.src,
|
|
308
439
|
).toContain("data:image/png;base64,bright");
|
|
@@ -310,6 +441,40 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
|
|
|
310
441
|
act(() => root.unmount());
|
|
311
442
|
});
|
|
312
443
|
|
|
444
|
+
it("requests an idle preview batch once", () => {
|
|
445
|
+
const onRequestPresetPreviews = vi.fn();
|
|
446
|
+
const props = neutralPropsBase();
|
|
447
|
+
const { root } = renderInto(
|
|
448
|
+
<FlatColorGradingSection
|
|
449
|
+
{...props}
|
|
450
|
+
presetPreviews={{ ...props.presetPreviews, status: "idle", images: {} }}
|
|
451
|
+
onRequestPresetPreviews={onRequestPresetPreviews}
|
|
452
|
+
/>,
|
|
453
|
+
);
|
|
454
|
+
expect(onRequestPresetPreviews).toHaveBeenCalledTimes(1);
|
|
455
|
+
act(() => root.unmount());
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("requires an explicit retry after a preview failure", () => {
|
|
459
|
+
const onRequestPresetPreviews = vi.fn();
|
|
460
|
+
const props = neutralPropsBase();
|
|
461
|
+
const { host, root } = renderInto(
|
|
462
|
+
<FlatColorGradingSection
|
|
463
|
+
{...props}
|
|
464
|
+
presetPreviews={{ ...props.presetPreviews, status: "unavailable", images: {} }}
|
|
465
|
+
onRequestPresetPreviews={onRequestPresetPreviews}
|
|
466
|
+
/>,
|
|
467
|
+
);
|
|
468
|
+
expect(onRequestPresetPreviews).not.toHaveBeenCalled();
|
|
469
|
+
const retry = Array.from(host.querySelectorAll("button")).find(
|
|
470
|
+
(button) => button.textContent === "Retry look previews",
|
|
471
|
+
);
|
|
472
|
+
if (!retry) throw new Error("expected an explicit preview retry");
|
|
473
|
+
act(() => retry.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
474
|
+
expect(onRequestPresetPreviews).toHaveBeenCalledTimes(1);
|
|
475
|
+
act(() => root.unmount());
|
|
476
|
+
});
|
|
477
|
+
|
|
313
478
|
it("shows the Custom LUT row collapsed by default, expanding to reveal the strength slider when a LUT is set", () => {
|
|
314
479
|
const grading = { ...neutralGrading(), lut: { src: "assets/luts/warm.cube", intensity: 0.8 } };
|
|
315
480
|
const { host, root } = renderInto(
|