@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.
Files changed (35) hide show
  1. package/dist/assets/{hyperframes-player-Bjf2HPzR.js → hyperframes-player-wiJqS2i-.js} +1 -1
  2. package/dist/assets/index-B37rWXo4.css +1 -0
  3. package/dist/assets/{index-Ct_pxETK.js → index-BXepgTJb.js} +1 -1
  4. package/dist/assets/index-CBDJuOGW.js +428 -0
  5. package/dist/assets/{index-Ch1hbJ3e.js → index-DGVNG1dd.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +4203 -2281
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  11. package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
  12. package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
  13. package/src/components/editor/propertyPanelColorCurveGraph.tsx +549 -0
  14. package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
  15. package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
  16. package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
  17. package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
  18. package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
  19. package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
  20. package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
  21. package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
  22. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
  23. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +168 -3
  24. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +184 -243
  25. package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
  26. package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
  27. package/src/components/editor/useColorGradingController.test.ts +133 -17
  28. package/src/components/editor/useColorGradingController.ts +11 -2
  29. package/src/components/editor/useColorGradingPreviews.ts +54 -11
  30. package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
  31. package/src/components/editor/useColorGradingScopes.ts +62 -0
  32. package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
  33. package/src/icons/SystemIcons.tsx +4 -0
  34. package/dist/assets/index-BgZMle9I.css +0 -1
  35. 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 { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
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 exact selected-media preview images and requests a fresh batch on mount", () => {
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).toHaveBeenCalledTimes(1);
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(