@hyperframes/studio 0.7.71 → 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.
Files changed (79) hide show
  1. package/dist/assets/{hyperframes-player-C1X90psg.js → hyperframes-player-DGlzhf_s.js} +1 -1
  2. package/dist/assets/index-B37rWXo4.css +1 -0
  3. package/dist/assets/{index-DzxDHPR1.js → index-CbVTOJ-E.js} +1 -1
  4. package/dist/assets/{index-nY4lLBqM.js → index-CcA5WVJv.js} +1 -1
  5. package/dist/assets/index-DXcLI2wu.js +428 -0
  6. package/dist/index.d.ts +8 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7762 -4261
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +4 -4
  12. package/src/components/StudioLeftSidebar.tsx +2 -3
  13. package/src/components/StudioRightPanel.tsx +10 -4
  14. package/src/components/editor/AnimationCard.test.tsx +100 -0
  15. package/src/components/editor/AnimationCard.tsx +5 -2
  16. package/src/components/editor/EaseCurveSection.test.tsx +348 -0
  17. package/src/components/editor/EaseCurveSection.tsx +444 -237
  18. package/src/components/editor/EaseParamFields.test.tsx +188 -0
  19. package/src/components/editor/EaseParamFields.tsx +246 -0
  20. package/src/components/editor/PropertyPanel.test.tsx +9 -7
  21. package/src/components/editor/PropertyPanelFlat.tsx +75 -88
  22. package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
  23. package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
  24. package/src/components/editor/colorGradingScopePatch.test.ts +3 -3
  25. package/src/components/editor/easeCurveSvg.tsx +65 -0
  26. package/src/components/editor/easePresetLibrary.test.ts +114 -0
  27. package/src/components/editor/easePresetLibrary.ts +49 -0
  28. package/src/components/editor/gsapAnimationConstants.ts +15 -13
  29. package/src/components/editor/propertyPanelColorCurveGraph.tsx +549 -0
  30. package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
  31. package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
  32. package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
  33. package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
  34. package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
  35. package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
  36. package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
  37. package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
  38. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
  39. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +239 -41
  40. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +260 -300
  41. package/src/components/editor/propertyPanelFlatEffectControl.tsx +96 -0
  42. package/src/components/editor/propertyPanelFlatEffectSpecs.ts +305 -0
  43. package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +331 -0
  44. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +503 -0
  45. package/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx +113 -0
  46. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +108 -0
  47. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -0
  48. package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
  49. package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
  50. package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
  51. package/src/components/editor/propertyPanelTypes.ts +13 -0
  52. package/src/components/editor/useColorGradingController.test.ts +331 -10
  53. package/src/components/editor/useColorGradingController.ts +57 -90
  54. package/src/components/editor/useColorGradingPreviews.ts +350 -0
  55. package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
  56. package/src/components/editor/useColorGradingScopes.ts +62 -0
  57. package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
  58. package/src/components/nle/NLEContext.tsx +1 -1
  59. package/src/contexts/PanelLayoutContext.tsx +3 -6
  60. package/src/hooks/useBlockCatalog.ts +31 -13
  61. package/src/hooks/useBlockHandlers.ts +22 -0
  62. package/src/hooks/useGsapScriptCommits.test.tsx +15 -0
  63. package/src/hooks/useGsapScriptCommits.ts +14 -1
  64. package/src/hooks/usePanelLayout.test.ts +68 -1
  65. package/src/hooks/usePanelLayout.ts +72 -34
  66. package/src/icons/SystemIcons.tsx +4 -0
  67. package/src/player/components/PlayerControls.tsx +68 -229
  68. package/src/player/components/ShortcutsPanel.tsx +2 -4
  69. package/src/player/components/SpeedMenu.tsx +1 -2
  70. package/src/telemetry/events.test.ts +9 -0
  71. package/src/telemetry/events.ts +5 -0
  72. package/src/utils/blockInstaller.test.ts +67 -0
  73. package/src/utils/blockInstaller.ts +162 -109
  74. package/src/utils/studioUiPreferences.test.ts +5 -0
  75. package/src/utils/studioUiPreferences.ts +8 -0
  76. package/dist/assets/index-CSCh2Vrp.js +0 -428
  77. package/dist/assets/index-Ceyz8Qt2.css +0 -1
  78. package/src/player/components/PlayerControls.test.ts +0 -20
  79. package/src/player/components/useSeekBarDrag.ts +0 -169
@@ -1,153 +1,40 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import {
3
- HF_COLOR_GRADING_PRESETS,
4
- isHfColorGradingActive,
3
+ HF_COLOR_GRADING_GRADE_PRESETS,
5
4
  normalizeHfColorGrading,
5
+ serializeHfColorGrading,
6
6
  type HfColorGradingAdjustKey,
7
7
  type HfColorGradingDetailKey,
8
- type HfColorGradingEffectKey,
9
8
  type NormalizedHfColorGrading,
10
9
  } from "@hyperframes/core/color-grading";
11
- import { Compare, Plus, RotateCcw, Settings } from "../../icons/SystemIcons";
10
+ import { Plus, Settings } from "../../icons/SystemIcons";
12
11
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
13
12
  import { LUT_EXT } from "../../utils/mediaTypes";
14
- import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
15
- import { resolveValueTier } from "./propertyPanelValueTier";
16
- import type { ColorGradingControllerState, MediaMetadata } from "./useColorGradingController";
17
-
18
- const STATUS_DOT_CLASS: Record<ColorGradingControllerState["runtimeStatus"]["state"], string> = {
19
- active: "bg-emerald-400",
20
- pending: "bg-amber-300",
21
- unavailable: "bg-red-400",
22
- missing: "bg-panel-text-5",
23
- inactive: "bg-panel-text-5",
24
- };
25
-
26
- export function FlatColorGradingAccessory({
27
- state,
28
- }: {
29
- state: Pick<
30
- ColorGradingControllerState,
31
- "grading" | "compareEnabled" | "runtimeStatus" | "commitCompare" | "resetGrading"
32
- >;
33
- }) {
34
- const track = useTrackDesignInput();
35
- const { grading, compareEnabled, runtimeStatus, commitCompare, resetGrading } = state;
36
- const gradingActive = isHfColorGradingActive(grading);
37
- // Tracks the active hold's cleanup so it can be torn down on unmount too —
38
- // without this, switching selection away mid-hold (unmounting this
39
- // accessory) leaves the pointerup/pointercancel/blur listeners registered
40
- // on `window` forever, each holding a closure over the old commitCompare.
41
- const releaseRef = useRef<(() => void) | null>(null);
42
- useEffect(
43
- () => () => {
44
- releaseRef.current?.();
45
- releaseRef.current = null;
46
- },
47
- [],
48
- );
49
-
50
- return (
51
- <span className="flex items-center gap-2.5">
52
- <button
53
- type="button"
54
- aria-pressed={compareEnabled}
55
- aria-label="Hold to show original"
56
- disabled={!gradingActive}
57
- onPointerDown={(e) => {
58
- if (!gradingActive) return;
59
- e.preventDefault();
60
- e.stopPropagation();
61
- track("button", "Compare original");
62
- commitCompare(true);
63
- const release = () => {
64
- commitCompare(false);
65
- window.removeEventListener("pointerup", release);
66
- window.removeEventListener("pointercancel", release);
67
- window.removeEventListener("blur", release);
68
- releaseRef.current = null;
69
- };
70
- releaseRef.current = release;
71
- window.addEventListener("pointerup", release);
72
- window.addEventListener("pointercancel", release);
73
- window.addEventListener("blur", release);
74
- }}
75
- onBlur={() => {
76
- if (compareEnabled) commitCompare(false);
77
- }}
78
- onKeyDown={(e) => {
79
- if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
80
- e.preventDefault();
81
- if (!compareEnabled) {
82
- track("button", "Compare original");
83
- commitCompare(true);
84
- }
85
- }}
86
- onKeyUp={(e) => {
87
- if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
88
- e.preventDefault();
89
- commitCompare(false);
90
- }}
91
- title="Hold to show original"
92
- className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
93
- >
94
- <Compare size={12} />
95
- </button>
96
- <span className="flex min-w-0 items-center gap-1" title={runtimeStatus.message}>
97
- <span
98
- data-flat-grade-status-dot="true"
99
- title={runtimeStatus.message}
100
- className={`h-[5px] w-[5px] flex-shrink-0 rounded-full ${STATUS_DOT_CLASS[runtimeStatus.state]}`}
101
- />
102
- <span
103
- data-flat-grade-status-message="true"
104
- className="max-w-[84px] truncate text-[9px] text-panel-text-4"
105
- >
106
- {runtimeStatus.message}
107
- </span>
108
- </span>
109
- <button
110
- type="button"
111
- data-flat-grade-reset="true"
112
- title="Reset color grading"
113
- onClick={(e) => {
114
- e.stopPropagation();
115
- track("button", "Reset color grading");
116
- resetGrading();
117
- }}
118
- className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1"
119
- >
120
- <RotateCcw size={12} />
121
- </button>
122
- </span>
123
- );
124
- }
125
-
126
- const PRESET_OPTIONS = HF_COLOR_GRADING_PRESETS.map((p) => ({ value: p.id, label: p.label }));
127
-
128
- const ADJUST_SLIDERS: Array<{
129
- key: HfColorGradingAdjustKey;
130
- label: string;
131
- min: number;
132
- max: number;
133
- step: number;
134
- }> = [
135
- { key: "exposure", label: "Exposure", min: -200, max: 200, step: 5 },
136
- { key: "contrast", label: "Contrast", min: -100, max: 100, step: 1 },
137
- { key: "highlights", label: "Highlights", min: -100, max: 100, step: 1 },
138
- { key: "shadows", label: "Shadows", min: -100, max: 100, step: 1 },
139
- { key: "whites", label: "White Point", min: -100, max: 100, step: 1 },
140
- { key: "blacks", label: "Black Point", min: -100, max: 100, step: 1 },
141
- { key: "temperature", label: "Warmth", min: -100, max: 100, step: 1 },
142
- { key: "tint", label: "Tint", min: -100, max: 100, step: 1 },
143
- { key: "vibrance", label: "Vibrance", min: -100, max: 100, step: 1 },
144
- { key: "saturation", label: "Saturation", min: -100, max: 100, step: 1 },
145
- ];
13
+ import { FLAT_PREVIEW_GRID, FlatSlider } from "./propertyPanelFlatPrimitives";
14
+ import type {
15
+ ColorGradingControllerState,
16
+ ColorGradingPresetPreviews,
17
+ ColorGradingPreviewOptions,
18
+ MediaMetadata,
19
+ } from "./useColorGradingController";
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";
146
36
 
147
- function visibleIntensity(grading: NormalizedHfColorGrading): number {
148
- // Earlier drafts could persist 0% strength; the next manual edit should revive visible grading.
149
- return grading.intensity === 0 ? 1 : grading.intensity;
150
- }
37
+ export { FlatColorGradingAccessory } from "./propertyPanelFlatColorGradingAccessory";
151
38
 
152
39
  function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): string {
153
40
  if (key === "exposure") {
@@ -157,35 +44,17 @@ function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): st
157
44
  return `${Math.round(rawPercent)}%`;
158
45
  }
159
46
 
160
- const DETAIL_SLIDERS: Array<{
161
- key: HfColorGradingDetailKey;
162
- label: string;
163
- defaultValue: number;
164
- }> = [
165
- { key: "vignette", label: "Vignette", defaultValue: 0 },
166
- { key: "vignetteMidpoint", label: "Midpoint", defaultValue: 0.5 },
167
- { key: "vignetteRoundness", label: "Roundness", defaultValue: 0 },
168
- { key: "vignetteFeather", label: "Feather", defaultValue: 0.65 },
169
- { key: "grain", label: "Grain", defaultValue: 0 },
170
- { key: "grainSize", label: "Grain Size", defaultValue: 0.25 },
171
- { key: "grainRoughness", label: "Roughness", defaultValue: 0.5 },
172
- ];
173
47
  const detailByKey = (key: HfColorGradingDetailKey) => {
174
- const spec = DETAIL_SLIDERS.find((d) => d.key === key);
48
+ const spec = COLOR_GRADING_DETAIL_SLIDERS.find((candidate) => candidate.key === key);
175
49
  if (!spec) throw new Error(`Unknown color grading detail key: ${key}`);
176
50
  return spec;
177
51
  };
178
- const VIGNETTE_TUNE_KEYS: HfColorGradingDetailKey[] = [
179
- "vignetteMidpoint",
180
- "vignetteRoundness",
181
- "vignetteFeather",
182
- ];
183
- const GRAIN_TUNE_KEYS: HfColorGradingDetailKey[] = ["grainSize", "grainRoughness"];
184
52
 
185
- const EFFECT_SLIDERS: Array<{ key: HfColorGradingEffectKey; label: string }> = [
186
- { key: "blur", label: "Blur" },
187
- { key: "pixelate", label: "Pixelate" },
188
- ];
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
+ }
189
58
 
190
59
  function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) {
191
60
  if (metadata?.color.dynamicRange !== "hdr") return null;
@@ -231,23 +100,34 @@ export function FlatColorGradingSection({
231
100
  assets,
232
101
  onImportAssets,
233
102
  onCommitColorGrading,
103
+ onPreviewColorGrading,
234
104
  applyScope,
235
105
  applyBusy,
236
106
  onSetApplyScope,
237
107
  onApplyToScope,
238
108
  onApplyScopeAvailable,
239
109
  mediaMetadata,
110
+ presetPreviews,
111
+ onRequestPresetPreviews,
112
+ captureGradedFrame,
240
113
  }: {
241
114
  grading: NormalizedHfColorGrading;
242
115
  assets: string[];
243
116
  onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
244
117
  onCommitColorGrading: (next: NormalizedHfColorGrading) => void;
118
+ onPreviewColorGrading: (
119
+ next: NormalizedHfColorGrading | null,
120
+ options?: ColorGradingPreviewOptions,
121
+ ) => void;
245
122
  applyScope: "source-file" | "project";
246
123
  applyBusy: boolean;
247
124
  onSetApplyScope: (scope: "source-file" | "project") => void;
248
125
  onApplyToScope: () => void;
249
126
  onApplyScopeAvailable: boolean;
250
127
  mediaMetadata: MediaMetadata | null;
128
+ presetPreviews: ColorGradingPresetPreviews;
129
+ onRequestPresetPreviews: () => void;
130
+ captureGradedFrame: ColorGradingControllerState["captureGradedFrame"];
251
131
  }) {
252
132
  const track = useTrackDesignInput();
253
133
  const lutInputRef = useRef<HTMLInputElement>(null);
@@ -259,59 +139,65 @@ export function FlatColorGradingSection({
259
139
  );
260
140
  const lut = grading.lut;
261
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]);
262
163
 
263
- const applyPreset = (presetId: string) => {
264
- const next = normalizeHfColorGrading({ preset: presetId, intensity: 1, lut: grading.lut });
265
- if (next) onCommitColorGrading(next);
266
- };
267
- const updateIntensity = (value: number) => {
268
- onCommitColorGrading({ ...grading, intensity: value / 100 });
269
- };
270
- const applyLut = (src: string | null, intensity = 1) => {
271
- onCommitColorGrading({
272
- ...grading,
273
- intensity: visibleIntensity(grading),
274
- lut: src ? { src, intensity } : null,
275
- });
276
- };
277
- const importLuts = async (files: FileList | null) => {
278
- if (!files?.length || !onImportAssets) return;
279
- const uploaded = await onImportAssets(files, "assets/luts");
280
- const firstLut = uploaded.find((asset) => LUT_EXT.test(asset));
281
- if (firstLut) {
282
- track("button", "Import LUT");
283
- applyLut(firstLut, 1);
284
- }
164
+ useEffect(() => {
165
+ if (presetPreviews.status === "idle") onRequestPresetPreviews();
166
+ }, [onRequestPresetPreviews, presetPreviews.status]);
167
+
168
+ const resolvePreset = (presetId: string) => {
169
+ const resolved = normalizeHfColorGrading({ preset: presetId, lut: grading.lut });
170
+ return resolved
171
+ ? {
172
+ ...resolved,
173
+ effects: grading.effects,
174
+ palette: grading.palette,
175
+ }
176
+ : grading;
285
177
  };
286
178
 
179
+ useEffect(() => () => onPreviewColorGrading(null), [onPreviewColorGrading]);
180
+
287
181
  const renderDetailSlider = (key: HfColorGradingDetailKey) => {
288
182
  const spec = detailByKey(key);
289
183
  const value = grading.details[key];
290
- const isSet = Math.abs(value - spec.defaultValue) > 1e-4;
184
+ const defaultValue = normalizedColorGradingDefault(spec);
185
+ const isSet = Math.abs(value - defaultValue) > 1e-4;
291
186
  return (
292
187
  <FlatSlider
293
188
  key={key}
294
189
  label={spec.label}
295
- value={Math.round(value * 100)}
296
- min={key === "vignetteRoundness" ? -100 : 0}
297
- max={100}
190
+ value={Math.round(value * spec.scale)}
191
+ min={spec.min}
192
+ max={spec.max}
193
+ step={spec.step}
298
194
  tier={isSet ? "explicitCustom" : "default"}
299
- displayValue={`${Math.round(value * 100)}%`}
195
+ displayValue={`${Math.round(value * spec.scale)}${spec.suffix}`}
300
196
  centerTick={key === "vignetteRoundness"}
301
197
  onCommit={(next) =>
302
- onCommitColorGrading({
303
- ...grading,
304
- intensity: visibleIntensity(grading),
305
- details: { ...grading.details, [key]: next / 100 },
306
- })
307
- }
308
- onReset={() =>
309
- onCommitColorGrading({
310
- ...grading,
311
- intensity: visibleIntensity(grading),
312
- details: { ...grading.details, [key]: spec.defaultValue },
313
- })
198
+ onCommitColorGrading(colorGradingWithDetail(grading, key, next / spec.scale))
314
199
  }
200
+ onReset={() => onCommitColorGrading(colorGradingWithDetail(grading, key, defaultValue))}
315
201
  />
316
202
  );
317
203
  };
@@ -319,31 +205,177 @@ export function FlatColorGradingSection({
319
205
  return (
320
206
  <div className="space-y-1.5">
321
207
  <HdrBanner metadata={mediaMetadata} />
322
- <div data-flat-grade-preset="true" className="flex min-h-[30px] items-center justify-between">
323
- <span className="text-[11px] text-panel-text-2">Preset</span>
324
- <FlatSelectRow
325
- label=""
326
- ariaLabel="Preset"
327
- value={grading.preset ?? "neutral"}
328
- options={PRESET_OPTIONS}
329
- tier={resolveValueTier(
330
- grading.preset === "neutral" ? undefined : (grading.preset ?? undefined),
331
- "neutral",
332
- )}
333
- onChange={applyPreset}
334
- />
208
+ <PropertyPanelColorScopes
209
+ captureFrame={() => captureGradedFrame()}
210
+ refreshKey={scopesRefreshKey}
211
+ />
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
+ )}
222
+ <div data-flat-grade-preset-group="presets" className={FLAT_PREVIEW_GRID}>
223
+ {HF_COLOR_GRADING_GRADE_PRESETS.map((preset) => {
224
+ const label = preset.label;
225
+ const selected = grading.preset === preset.id;
226
+ const preview = presetPreviews.images[preset.id];
227
+ return (
228
+ <button
229
+ key={preset.id}
230
+ type="button"
231
+ data-flat-grade-preset={preset.id}
232
+ aria-pressed={selected}
233
+ {...presetPreviewHandlers({
234
+ id: preset.id,
235
+ label,
236
+ resolve: () => resolvePreset(preset.id),
237
+ onPreview: onPreviewColorGrading,
238
+ onCommit: onCommitColorGrading,
239
+ onTrack: (name) => track("button", `Apply ${name}`),
240
+ })}
241
+ className={`min-w-0 overflow-hidden border text-left text-[10px] transition-colors ${
242
+ selected
243
+ ? "border-panel-accent bg-panel-accent/10 text-panel-text-0"
244
+ : "border-panel-hairline bg-panel-bg-soft text-panel-text-3 hover:border-panel-border-input hover:text-panel-text-1"
245
+ }`}
246
+ >
247
+ <span
248
+ data-flat-grade-preview-frame={preset.id}
249
+ className="flex w-full items-center justify-center overflow-hidden bg-black/20"
250
+ style={{ aspectRatio: `${presetPreviews.width} / ${presetPreviews.height}` }}
251
+ >
252
+ {preview ? (
253
+ <img
254
+ data-flat-grade-preview={preset.id}
255
+ src={preview}
256
+ alt=""
257
+ draggable={false}
258
+ className="block h-full w-full object-contain"
259
+ />
260
+ ) : (
261
+ <span
262
+ data-flat-grade-preview-placeholder={presetPreviews.status}
263
+ className="h-full w-full bg-panel-bg-soft"
264
+ />
265
+ )}
266
+ </span>
267
+ <span className="block truncate px-2 py-1.5">{label}</span>
268
+ </button>
269
+ );
270
+ })}
271
+ </div>
335
272
  </div>
336
273
  <FlatSlider
337
- label="Strength"
274
+ label="Amount"
338
275
  value={Math.round(grading.intensity * 100)}
339
276
  min={0}
340
277
  max={100}
341
278
  tier={grading.intensity === 1 ? "default" : "explicitCustom"}
342
279
  displayValue={`${Math.round(grading.intensity * 100)}%`}
343
- onCommit={updateIntensity}
344
- onReset={() => updateIntensity(100)}
280
+ onCommit={actions.setIntensityPercent}
281
+ onReset={() => actions.setIntensityPercent(100)}
345
282
  />
346
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
+
347
379
  <div className="border-t border-panel-hairline pt-1.5">
348
380
  <button
349
381
  type="button"
@@ -375,7 +407,7 @@ export function FlatColorGradingSection({
375
407
  onChange={(e) => {
376
408
  const src = e.target.value;
377
409
  track("select", "Custom LUT");
378
- applyLut(src || null, src && lut?.src === src ? lut.intensity : 1);
410
+ actions.applyLut(src || null, src && lut?.src === src ? lut.intensity : 1);
379
411
  }}
380
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"
381
413
  >
@@ -401,7 +433,9 @@ export function FlatColorGradingSection({
401
433
  accept=".cube"
402
434
  className="hidden"
403
435
  onChange={(e) => {
404
- void importLuts(e.currentTarget.files);
436
+ void actions.importLut(e.currentTarget.files, onImportAssets, () =>
437
+ track("button", "Import LUT"),
438
+ );
405
439
  e.currentTarget.value = "";
406
440
  }}
407
441
  />
@@ -414,55 +448,17 @@ export function FlatColorGradingSection({
414
448
  max={100}
415
449
  tier={lut.intensity === 1 ? "default" : "explicitCustom"}
416
450
  displayValue={`${Math.round((lut.intensity ?? 1) * 100)}%`}
417
- onCommit={(v) => applyLut(lut.src, v / 100)}
418
- onReset={() => applyLut(lut.src, 1)}
451
+ onCommit={(v) => actions.applyLut(lut.src, v / 100)}
452
+ onReset={() => actions.applyLut(lut.src, 1)}
419
453
  />
420
454
  )}
421
455
  </div>
422
456
  )}
423
457
  </div>
424
458
 
425
- <div className="space-y-0.5 border-t border-panel-hairline pt-1.5">
426
- <div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
427
- Adjust
428
- </div>
429
- {ADJUST_SLIDERS.map((slider) => {
430
- const rawPercent = grading.adjust[slider.key] * 100;
431
- const isSet = Math.abs(grading.adjust[slider.key]) > 1e-6;
432
- return (
433
- <div key={slider.key} data-flat-grade-adjust="true">
434
- <FlatSlider
435
- label={slider.label}
436
- value={rawPercent}
437
- min={slider.min}
438
- max={slider.max}
439
- step={slider.step}
440
- tier={isSet ? "explicitCustom" : "default"}
441
- displayValue={formatAdjustValue(slider.key, rawPercent)}
442
- centerTick
443
- onCommit={(next) =>
444
- onCommitColorGrading({
445
- ...grading,
446
- intensity: visibleIntensity(grading),
447
- adjust: { ...grading.adjust, [slider.key]: next / 100 },
448
- })
449
- }
450
- onReset={() =>
451
- onCommitColorGrading({
452
- ...grading,
453
- intensity: visibleIntensity(grading),
454
- adjust: { ...grading.adjust, [slider.key]: 0 },
455
- })
456
- }
457
- />
458
- </div>
459
- );
460
- })}
461
- </div>
462
-
463
459
  <div className="space-y-1.5 border-t border-panel-hairline pt-1.5">
464
460
  <div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
465
- Finishing
461
+ Finish
466
462
  </div>
467
463
  <div className="flex items-center gap-1.5">
468
464
  <div className="flex-1">{renderDetailSlider("vignette")}</div>
@@ -490,49 +486,13 @@ export function FlatColorGradingSection({
490
486
  </div>
491
487
  {detailSettingsOpen && (
492
488
  <div className="space-y-0.5 border-l-2 border-panel-border-input pl-2.5">
493
- {(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_KEYS : GRAIN_TUNE_KEYS).map(
494
- renderDetailSlider,
489
+ {(detailSettingsOpen === "vignette" ? VIGNETTE_TUNE_SLIDERS : GRAIN_TUNE_SLIDERS).map(
490
+ (slider) => renderDetailSlider(slider.key),
495
491
  )}
496
492
  </div>
497
493
  )}
498
494
  </div>
499
495
 
500
- <div className="space-y-0.5 border-t border-panel-hairline pt-1.5">
501
- <div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
502
- Effects
503
- </div>
504
- {EFFECT_SLIDERS.map((slider) => {
505
- const value = grading.effects[slider.key];
506
- const isSet = value > 1e-6;
507
- return (
508
- <div key={slider.key} data-flat-grade-effect="true">
509
- <FlatSlider
510
- label={slider.label}
511
- value={Math.round(value * 100)}
512
- min={0}
513
- max={100}
514
- tier={isSet ? "explicitCustom" : "default"}
515
- displayValue={`${Math.round(value * 100)}%`}
516
- onCommit={(next) =>
517
- onCommitColorGrading({
518
- ...grading,
519
- intensity: visibleIntensity(grading),
520
- effects: { ...grading.effects, [slider.key]: next / 100 },
521
- })
522
- }
523
- onReset={() =>
524
- onCommitColorGrading({
525
- ...grading,
526
- intensity: visibleIntensity(grading),
527
- effects: { ...grading.effects, [slider.key]: 0 },
528
- })
529
- }
530
- />
531
- </div>
532
- );
533
- })}
534
- </div>
535
-
536
496
  {onApplyScopeAvailable && (
537
497
  <div className="flex items-center justify-between gap-2 border-t border-panel-hairline pt-1.5">
538
498
  <span className="flex items-center gap-1.5 text-[11px] text-panel-text-2">