@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
@@ -1,13 +1,13 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import {
3
3
  HF_COLOR_GRADING_GRADE_PRESETS,
4
- isHfColorGradingActive,
5
4
  normalizeHfColorGrading,
5
+ serializeHfColorGrading,
6
6
  type HfColorGradingAdjustKey,
7
7
  type HfColorGradingDetailKey,
8
8
  type NormalizedHfColorGrading,
9
9
  } from "@hyperframes/core/color-grading";
10
- import { Compare, Plus, RotateCcw, Settings } from "../../icons/SystemIcons";
10
+ import { Plus, Settings } from "../../icons/SystemIcons";
11
11
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
12
12
  import { LUT_EXT } from "../../utils/mediaTypes";
13
13
  import { FLAT_PREVIEW_GRID, FlatSlider } from "./propertyPanelFlatPrimitives";
@@ -18,138 +18,23 @@ import type {
18
18
  MediaMetadata,
19
19
  } from "./useColorGradingController";
20
20
  import { presetPreviewHandlers } from "./propertyPanelPresetPreview";
21
+ import { ColorCurves } from "./propertyPanelColorCurves";
22
+ import {
23
+ COLOR_GRADING_ADJUST_SLIDERS,
24
+ COLOR_GRADING_DETAIL_SLIDERS,
25
+ colorGradingWithAdjust,
26
+ colorGradingWithDetail,
27
+ createColorGradingActions,
28
+ GRAIN_TUNE_SLIDERS,
29
+ normalizedColorGradingDefault,
30
+ VIGNETTE_TUNE_SLIDERS,
31
+ visibleColorGradingIntensity,
32
+ } from "./propertyPanelColorGradingControls";
33
+ import { PropertyPanelColorScopes } from "./propertyPanelColorScopes";
34
+ import { PropertyPanelColorSecondary } from "./propertyPanelColorSecondary";
35
+ import { ColorWheels } from "./propertyPanelColorWheels";
21
36
 
22
- const STATUS_DOT_CLASS: Record<ColorGradingControllerState["runtimeStatus"]["state"], string> = {
23
- active: "bg-emerald-400",
24
- pending: "bg-amber-300",
25
- unavailable: "bg-red-400",
26
- missing: "bg-panel-text-5",
27
- inactive: "bg-panel-text-5",
28
- };
29
-
30
- export function FlatColorGradingAccessory({
31
- state,
32
- }: {
33
- state: Pick<
34
- ColorGradingControllerState,
35
- "grading" | "compareEnabled" | "runtimeStatus" | "commitCompare" | "resetGrading"
36
- >;
37
- }) {
38
- const track = useTrackDesignInput();
39
- const { grading, compareEnabled, runtimeStatus, commitCompare, resetGrading } = state;
40
- const gradingActive = isHfColorGradingActive(grading);
41
- // Tracks the active hold's cleanup so it can be torn down on unmount too —
42
- // without this, switching selection away mid-hold (unmounting this
43
- // accessory) leaves the pointerup/pointercancel/blur listeners registered
44
- // on `window` forever, each holding a closure over the old commitCompare.
45
- const releaseRef = useRef<(() => void) | null>(null);
46
- useEffect(
47
- () => () => {
48
- releaseRef.current?.();
49
- releaseRef.current = null;
50
- },
51
- [],
52
- );
53
-
54
- return (
55
- <span className="flex items-center gap-2.5">
56
- <button
57
- type="button"
58
- aria-pressed={compareEnabled}
59
- aria-label="Hold to show original"
60
- disabled={!gradingActive}
61
- onPointerDown={(e) => {
62
- if (!gradingActive) return;
63
- e.preventDefault();
64
- e.stopPropagation();
65
- track("button", "Compare original");
66
- commitCompare(true);
67
- const release = () => {
68
- commitCompare(false);
69
- window.removeEventListener("pointerup", release);
70
- window.removeEventListener("pointercancel", release);
71
- window.removeEventListener("blur", release);
72
- releaseRef.current = null;
73
- };
74
- releaseRef.current = release;
75
- window.addEventListener("pointerup", release);
76
- window.addEventListener("pointercancel", release);
77
- window.addEventListener("blur", release);
78
- }}
79
- onBlur={() => {
80
- if (compareEnabled) commitCompare(false);
81
- }}
82
- onKeyDown={(e) => {
83
- if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
84
- e.preventDefault();
85
- if (!compareEnabled) {
86
- track("button", "Compare original");
87
- commitCompare(true);
88
- }
89
- }}
90
- onKeyUp={(e) => {
91
- if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
92
- e.preventDefault();
93
- commitCompare(false);
94
- }}
95
- title="Hold to show original"
96
- className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
97
- >
98
- <Compare size={12} />
99
- </button>
100
- <span className="flex min-w-0 items-center gap-1" title={runtimeStatus.message}>
101
- <span
102
- data-flat-grade-status-dot="true"
103
- title={runtimeStatus.message}
104
- className={`h-[5px] w-[5px] flex-shrink-0 rounded-full ${STATUS_DOT_CLASS[runtimeStatus.state]}`}
105
- />
106
- <span
107
- data-flat-grade-status-message="true"
108
- className="max-w-[84px] truncate text-[9px] text-panel-text-4"
109
- >
110
- {runtimeStatus.message}
111
- </span>
112
- </span>
113
- <button
114
- type="button"
115
- data-flat-grade-reset="true"
116
- title="Reset color grading"
117
- onClick={(e) => {
118
- e.stopPropagation();
119
- track("button", "Reset color grading");
120
- resetGrading();
121
- }}
122
- className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1"
123
- >
124
- <RotateCcw size={12} />
125
- </button>
126
- </span>
127
- );
128
- }
129
-
130
- const ADJUST_SLIDERS: Array<{
131
- key: HfColorGradingAdjustKey;
132
- label: string;
133
- min: number;
134
- max: number;
135
- step: number;
136
- }> = [
137
- { key: "exposure", label: "Exposure", min: -200, max: 200, step: 5 },
138
- { key: "contrast", label: "Contrast", min: -100, max: 100, step: 1 },
139
- { key: "highlights", label: "Highlights", min: -100, max: 100, step: 1 },
140
- { key: "shadows", label: "Shadows", min: -100, max: 100, step: 1 },
141
- { key: "whites", label: "White Point", min: -100, max: 100, step: 1 },
142
- { key: "blacks", label: "Black Point", min: -100, max: 100, step: 1 },
143
- { key: "temperature", label: "Warmth", min: -100, max: 100, step: 1 },
144
- { key: "tint", label: "Tint", min: -100, max: 100, step: 1 },
145
- { key: "vibrance", label: "Vibrance", min: -100, max: 100, step: 1 },
146
- { key: "saturation", label: "Saturation", min: -100, max: 100, step: 1 },
147
- ];
148
-
149
- function visibleIntensity(grading: NormalizedHfColorGrading): number {
150
- // Earlier drafts could persist 0% strength; the next manual edit should revive visible grading.
151
- return grading.intensity === 0 ? 1 : grading.intensity;
152
- }
37
+ export { FlatColorGradingAccessory } from "./propertyPanelFlatColorGradingAccessory";
153
38
 
154
39
  function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): string {
155
40
  if (key === "exposure") {
@@ -159,30 +44,17 @@ function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): st
159
44
  return `${Math.round(rawPercent)}%`;
160
45
  }
161
46
 
162
- const DETAIL_SLIDERS: Array<{
163
- key: HfColorGradingDetailKey;
164
- label: string;
165
- defaultValue: number;
166
- }> = [
167
- { key: "vignette", label: "Vignette", defaultValue: 0 },
168
- { key: "vignetteMidpoint", label: "Midpoint", defaultValue: 0.5 },
169
- { key: "vignetteRoundness", label: "Roundness", defaultValue: 0 },
170
- { key: "vignetteFeather", label: "Feather", defaultValue: 0.65 },
171
- { key: "grain", label: "Grain", defaultValue: 0 },
172
- { key: "grainSize", label: "Grain Size", defaultValue: 0.25 },
173
- { key: "grainRoughness", label: "Roughness", defaultValue: 0.5 },
174
- ];
175
47
  const detailByKey = (key: HfColorGradingDetailKey) => {
176
- const spec = DETAIL_SLIDERS.find((d) => d.key === key);
48
+ const spec = COLOR_GRADING_DETAIL_SLIDERS.find((candidate) => candidate.key === key);
177
49
  if (!spec) throw new Error(`Unknown color grading detail key: ${key}`);
178
50
  return spec;
179
51
  };
180
- const VIGNETTE_TUNE_KEYS: HfColorGradingDetailKey[] = [
181
- "vignetteMidpoint",
182
- "vignetteRoundness",
183
- "vignetteFeather",
184
- ];
185
- const GRAIN_TUNE_KEYS: HfColorGradingDetailKey[] = ["grainSize", "grainRoughness"];
52
+
53
+ function resolveColorGrading(grading: Parameters<typeof normalizeHfColorGrading>[0]) {
54
+ const resolved = normalizeHfColorGrading(grading);
55
+ if (!resolved) throw new Error("Missing resolved color grading");
56
+ return resolved;
57
+ }
186
58
 
187
59
  function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) {
188
60
  if (metadata?.color.dynamicRange !== "hdr") return null;
@@ -237,6 +109,7 @@ export function FlatColorGradingSection({
237
109
  mediaMetadata,
238
110
  presetPreviews,
239
111
  onRequestPresetPreviews,
112
+ captureGradedFrame,
240
113
  }: {
241
114
  grading: NormalizedHfColorGrading;
242
115
  assets: string[];
@@ -254,6 +127,7 @@ export function FlatColorGradingSection({
254
127
  mediaMetadata: MediaMetadata | null;
255
128
  presetPreviews: ColorGradingPresetPreviews;
256
129
  onRequestPresetPreviews: () => void;
130
+ captureGradedFrame: ColorGradingControllerState["captureGradedFrame"];
257
131
  }) {
258
132
  const track = useTrackDesignInput();
259
133
  const lutInputRef = useRef<HTMLInputElement>(null);
@@ -265,70 +139,65 @@ export function FlatColorGradingSection({
265
139
  );
266
140
  const lut = grading.lut;
267
141
  const selectedLutName = lut?.src ? (lut.src.split("/").pop() ?? lut.src) : null;
142
+ const resolvedGrading = useMemo(() => resolveColorGrading(grading), [grading]);
143
+ const secondaryInputGrading = useMemo(
144
+ () =>
145
+ resolveColorGrading({
146
+ intensity: 1,
147
+ adjust: resolvedGrading.adjust,
148
+ wheels: resolvedGrading.wheels,
149
+ curves: resolvedGrading.curves,
150
+ hueCurves: resolvedGrading.hueCurves,
151
+ colorSpace: resolvedGrading.colorSpace,
152
+ }),
153
+ [
154
+ resolvedGrading.adjust,
155
+ resolvedGrading.colorSpace,
156
+ resolvedGrading.curves,
157
+ resolvedGrading.hueCurves,
158
+ resolvedGrading.wheels,
159
+ ],
160
+ );
161
+ const actions = createColorGradingActions(grading, onCommitColorGrading);
162
+ const scopesRefreshKey = useMemo(() => serializeHfColorGrading(grading), [grading]);
268
163
 
269
164
  useEffect(() => {
270
- if (
271
- presetPreviews.status !== "loading" &&
272
- HF_COLOR_GRADING_GRADE_PRESETS.some((preset) => !presetPreviews.images[preset.id])
273
- ) {
274
- onRequestPresetPreviews();
275
- }
276
- }, [onRequestPresetPreviews, presetPreviews.images, presetPreviews.status]);
165
+ if (presetPreviews.status === "idle") onRequestPresetPreviews();
166
+ }, [onRequestPresetPreviews, presetPreviews.status]);
277
167
 
278
168
  const resolvePreset = (presetId: string) => {
279
169
  const resolved = normalizeHfColorGrading({ preset: presetId, lut: grading.lut });
280
- return resolved ? { ...resolved, effects: grading.effects, palette: grading.palette } : grading;
170
+ return resolved
171
+ ? {
172
+ ...resolved,
173
+ effects: grading.effects,
174
+ palette: grading.palette,
175
+ }
176
+ : grading;
281
177
  };
282
178
 
283
179
  useEffect(() => () => onPreviewColorGrading(null), [onPreviewColorGrading]);
284
- const updateIntensity = (value: number) => {
285
- onCommitColorGrading({ ...grading, intensity: value / 100 });
286
- };
287
- const applyLut = (src: string | null, intensity = 1) => {
288
- onCommitColorGrading({
289
- ...grading,
290
- intensity: visibleIntensity(grading),
291
- lut: src ? { src, intensity } : null,
292
- });
293
- };
294
- const importLuts = async (files: FileList | null) => {
295
- if (!files?.length || !onImportAssets) return;
296
- const uploaded = await onImportAssets(files, "assets/luts");
297
- const firstLut = uploaded.find((asset) => LUT_EXT.test(asset));
298
- if (firstLut) {
299
- track("button", "Import LUT");
300
- applyLut(firstLut, 1);
301
- }
302
- };
303
180
 
304
181
  const renderDetailSlider = (key: HfColorGradingDetailKey) => {
305
182
  const spec = detailByKey(key);
306
183
  const value = grading.details[key];
307
- const isSet = Math.abs(value - spec.defaultValue) > 1e-4;
184
+ const defaultValue = normalizedColorGradingDefault(spec);
185
+ const isSet = Math.abs(value - defaultValue) > 1e-4;
308
186
  return (
309
187
  <FlatSlider
310
188
  key={key}
311
189
  label={spec.label}
312
- value={Math.round(value * 100)}
313
- min={key === "vignetteRoundness" ? -100 : 0}
314
- max={100}
190
+ value={Math.round(value * spec.scale)}
191
+ min={spec.min}
192
+ max={spec.max}
193
+ step={spec.step}
315
194
  tier={isSet ? "explicitCustom" : "default"}
316
- displayValue={`${Math.round(value * 100)}%`}
195
+ displayValue={`${Math.round(value * spec.scale)}${spec.suffix}`}
317
196
  centerTick={key === "vignetteRoundness"}
318
197
  onCommit={(next) =>
319
- onCommitColorGrading({
320
- ...grading,
321
- intensity: visibleIntensity(grading),
322
- details: { ...grading.details, [key]: next / 100 },
323
- })
324
- }
325
- onReset={() =>
326
- onCommitColorGrading({
327
- ...grading,
328
- intensity: visibleIntensity(grading),
329
- details: { ...grading.details, [key]: spec.defaultValue },
330
- })
198
+ onCommitColorGrading(colorGradingWithDetail(grading, key, next / spec.scale))
331
199
  }
200
+ onReset={() => onCommitColorGrading(colorGradingWithDetail(grading, key, defaultValue))}
332
201
  />
333
202
  );
334
203
  };
@@ -336,7 +205,20 @@ export function FlatColorGradingSection({
336
205
  return (
337
206
  <div className="space-y-1.5">
338
207
  <HdrBanner metadata={mediaMetadata} />
208
+ <PropertyPanelColorScopes
209
+ captureFrame={() => captureGradedFrame()}
210
+ refreshKey={scopesRefreshKey}
211
+ />
339
212
  <div data-flat-grade-presets="true" className="space-y-1.5">
213
+ {presetPreviews.status === "unavailable" && (
214
+ <button
215
+ type="button"
216
+ onClick={onRequestPresetPreviews}
217
+ className="text-[10px] font-medium text-panel-accent hover:text-panel-accent/80"
218
+ >
219
+ Retry look previews
220
+ </button>
221
+ )}
340
222
  <div data-flat-grade-preset-group="presets" className={FLAT_PREVIEW_GRID}>
341
223
  {HF_COLOR_GRADING_GRADE_PRESETS.map((preset) => {
342
224
  const label = preset.label;
@@ -395,10 +277,105 @@ export function FlatColorGradingSection({
395
277
  max={100}
396
278
  tier={grading.intensity === 1 ? "default" : "explicitCustom"}
397
279
  displayValue={`${Math.round(grading.intensity * 100)}%`}
398
- onCommit={updateIntensity}
399
- onReset={() => updateIntensity(100)}
280
+ onCommit={actions.setIntensityPercent}
281
+ onReset={() => actions.setIntensityPercent(100)}
400
282
  />
401
283
 
284
+ <div className="space-y-0.5 border-t border-panel-hairline pt-1.5">
285
+ <div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
286
+ Primary
287
+ </div>
288
+ {COLOR_GRADING_ADJUST_SLIDERS.map((slider) => {
289
+ const rawPercent = grading.adjust[slider.key] * slider.scale;
290
+ const isSet = Math.abs(grading.adjust[slider.key]) > 1e-6;
291
+ return (
292
+ <div key={slider.key} data-flat-grade-adjust="true">
293
+ <FlatSlider
294
+ label={slider.label}
295
+ value={rawPercent}
296
+ min={slider.min}
297
+ max={slider.max}
298
+ step={slider.step}
299
+ tier={isSet ? "explicitCustom" : "default"}
300
+ displayValue={formatAdjustValue(slider.key, rawPercent)}
301
+ centerTick
302
+ onCommit={(next) =>
303
+ onCommitColorGrading(
304
+ colorGradingWithAdjust(grading, slider.key, next / slider.scale),
305
+ )
306
+ }
307
+ onReset={() => onCommitColorGrading(colorGradingWithAdjust(grading, slider.key, 0))}
308
+ />
309
+ </div>
310
+ );
311
+ })}
312
+ </div>
313
+
314
+ <div className="space-y-1.5 border-t border-panel-hairline pt-1.5">
315
+ <div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
316
+ Color wheels
317
+ </div>
318
+ <ColorWheels
319
+ value={resolvedGrading.wheels}
320
+ onPreview={(wheels) =>
321
+ onPreviewColorGrading({
322
+ ...grading,
323
+ intensity: visibleColorGradingIntensity(grading),
324
+ wheels,
325
+ })
326
+ }
327
+ onCommit={(wheels) =>
328
+ onCommitColorGrading({
329
+ ...grading,
330
+ intensity: visibleColorGradingIntensity(grading),
331
+ wheels,
332
+ })
333
+ }
334
+ />
335
+ </div>
336
+
337
+ <div className="space-y-1.5 border-t border-panel-hairline pt-1.5">
338
+ <div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
339
+ Curves
340
+ </div>
341
+ <ColorCurves
342
+ value={{ curves: resolvedGrading.curves, hueCurves: resolvedGrading.hueCurves }}
343
+ onPreview={({ curves, hueCurves }) =>
344
+ onPreviewColorGrading({
345
+ ...grading,
346
+ intensity: visibleColorGradingIntensity(grading),
347
+ curves,
348
+ hueCurves,
349
+ })
350
+ }
351
+ onCommit={({ curves, hueCurves }) =>
352
+ onCommitColorGrading({
353
+ ...grading,
354
+ intensity: visibleColorGradingIntensity(grading),
355
+ curves,
356
+ hueCurves,
357
+ })
358
+ }
359
+ />
360
+ </div>
361
+
362
+ <div className="space-y-1.5 border-t border-panel-hairline pt-1.5">
363
+ <div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
364
+ Secondary color
365
+ </div>
366
+ <PropertyPanelColorSecondary
367
+ secondaries={resolvedGrading.secondaries}
368
+ captureFrame={() => captureGradedFrame({ grading: secondaryInputGrading })}
369
+ onCommit={(secondaries) =>
370
+ onCommitColorGrading({
371
+ ...grading,
372
+ intensity: visibleColorGradingIntensity(grading),
373
+ secondaries,
374
+ })
375
+ }
376
+ />
377
+ </div>
378
+
402
379
  <div className="border-t border-panel-hairline pt-1.5">
403
380
  <button
404
381
  type="button"
@@ -430,7 +407,7 @@ export function FlatColorGradingSection({
430
407
  onChange={(e) => {
431
408
  const src = e.target.value;
432
409
  track("select", "Custom LUT");
433
- applyLut(src || null, src && lut?.src === src ? lut.intensity : 1);
410
+ actions.applyLut(src || null, src && lut?.src === src ? lut.intensity : 1);
434
411
  }}
435
412
  className="border-b border-panel-border-input/50 bg-transparent font-mono text-[10px] text-panel-text-3 outline-none hover:border-panel-border-input"
436
413
  >
@@ -456,7 +433,9 @@ export function FlatColorGradingSection({
456
433
  accept=".cube"
457
434
  className="hidden"
458
435
  onChange={(e) => {
459
- void importLuts(e.currentTarget.files);
436
+ void actions.importLut(e.currentTarget.files, onImportAssets, () =>
437
+ track("button", "Import LUT"),
438
+ );
460
439
  e.currentTarget.value = "";
461
440
  }}
462
441
  />
@@ -469,52 +448,14 @@ export function FlatColorGradingSection({
469
448
  max={100}
470
449
  tier={lut.intensity === 1 ? "default" : "explicitCustom"}
471
450
  displayValue={`${Math.round((lut.intensity ?? 1) * 100)}%`}
472
- onCommit={(v) => applyLut(lut.src, v / 100)}
473
- onReset={() => applyLut(lut.src, 1)}
451
+ onCommit={(v) => actions.applyLut(lut.src, v / 100)}
452
+ onReset={() => actions.applyLut(lut.src, 1)}
474
453
  />
475
454
  )}
476
455
  </div>
477
456
  )}
478
457
  </div>
479
458
 
480
- <div className="space-y-0.5 border-t border-panel-hairline pt-1.5">
481
- <div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
482
- Adjust
483
- </div>
484
- {ADJUST_SLIDERS.map((slider) => {
485
- const rawPercent = grading.adjust[slider.key] * 100;
486
- const isSet = Math.abs(grading.adjust[slider.key]) > 1e-6;
487
- return (
488
- <div key={slider.key} data-flat-grade-adjust="true">
489
- <FlatSlider
490
- label={slider.label}
491
- value={rawPercent}
492
- min={slider.min}
493
- max={slider.max}
494
- step={slider.step}
495
- tier={isSet ? "explicitCustom" : "default"}
496
- displayValue={formatAdjustValue(slider.key, rawPercent)}
497
- centerTick
498
- onCommit={(next) =>
499
- onCommitColorGrading({
500
- ...grading,
501
- intensity: visibleIntensity(grading),
502
- adjust: { ...grading.adjust, [slider.key]: next / 100 },
503
- })
504
- }
505
- onReset={() =>
506
- onCommitColorGrading({
507
- ...grading,
508
- intensity: visibleIntensity(grading),
509
- adjust: { ...grading.adjust, [slider.key]: 0 },
510
- })
511
- }
512
- />
513
- </div>
514
- );
515
- })}
516
- </div>
517
-
518
459
  <div className="space-y-1.5 border-t border-panel-hairline pt-1.5">
519
460
  <div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
520
461
  Finish
@@ -545,8 +486,8 @@ export function FlatColorGradingSection({
545
486
  </div>
546
487
  {detailSettingsOpen && (
547
488
  <div className="space-y-0.5 border-l-2 border-panel-border-input pl-2.5">
548
- {(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_KEYS : GRAIN_TUNE_KEYS).map(
549
- renderDetailSlider,
489
+ {(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_SLIDERS : GRAIN_TUNE_SLIDERS).map(
490
+ (slider) => renderDetailSlider(slider.key),
550
491
  )}
551
492
  </div>
552
493
  )}
@@ -0,0 +1,79 @@
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 { GradingNumberField } from "./propertyPanelGradingNumberField";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ });
13
+
14
+ function changeInput(input: HTMLInputElement, value: string) {
15
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
16
+ if (!setter) throw new Error("Expected the native input setter");
17
+ setter.call(input, value);
18
+ input.dispatchEvent(new Event("input", { bubbles: true }));
19
+ }
20
+
21
+ function renderField() {
22
+ const callbacks = {
23
+ onBegin: vi.fn(),
24
+ onPreview: vi.fn(),
25
+ onSettle: vi.fn(),
26
+ onCancel: vi.fn(),
27
+ };
28
+ const host = document.body.appendChild(document.createElement("div"));
29
+ const root = createRoot(host);
30
+ act(() =>
31
+ root.render(
32
+ <GradingNumberField label="Level" value={10} min={-100} max={100} {...callbacks} />,
33
+ ),
34
+ );
35
+ const input = host.querySelector("input");
36
+ if (!input) throw new Error("Expected a grading field");
37
+ return { root, input, callbacks };
38
+ }
39
+
40
+ describe("GradingNumberField", () => {
41
+ it("does not begin or settle an untouched focus and blur", () => {
42
+ const { root, input, callbacks } = renderField();
43
+ act(() => input.focus());
44
+ act(() => input.blur());
45
+
46
+ expect(callbacks.onBegin).not.toHaveBeenCalled();
47
+ expect(callbacks.onPreview).not.toHaveBeenCalled();
48
+ expect(callbacks.onSettle).not.toHaveBeenCalled();
49
+ expect(callbacks.onCancel).not.toHaveBeenCalled();
50
+ act(() => root.unmount());
51
+ });
52
+
53
+ it("cancels a real edit that returns to its baseline", () => {
54
+ const { root, input, callbacks } = renderField();
55
+ act(() => input.focus());
56
+ act(() => changeInput(input, "25"));
57
+ act(() => changeInput(input, "10"));
58
+ act(() => input.blur());
59
+
60
+ expect(callbacks.onBegin).toHaveBeenCalledOnce();
61
+ expect(callbacks.onSettle).not.toHaveBeenCalled();
62
+ expect(callbacks.onCancel).toHaveBeenCalledOnce();
63
+ act(() => root.unmount());
64
+ });
65
+
66
+ it("previews valid text and settles once on Enter", () => {
67
+ const { root, input, callbacks } = renderField();
68
+ act(() => input.focus());
69
+ act(() => changeInput(input, "-25"));
70
+ act(() => {
71
+ input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
72
+ });
73
+
74
+ expect(callbacks.onBegin).toHaveBeenCalledOnce();
75
+ expect(callbacks.onPreview).toHaveBeenLastCalledWith(-25);
76
+ expect(callbacks.onSettle).toHaveBeenCalledOnce();
77
+ act(() => root.unmount());
78
+ });
79
+ });