@hyperframes/studio 0.7.36 → 0.7.38

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 (75) hide show
  1. package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
  2. package/dist/assets/index-BLRTwY5l.js +396 -0
  3. package/dist/assets/{index-BltxqwG6.js → index-ClUipc8i.js} +1 -1
  4. package/dist/assets/index-DJaiR8T2.css +1 -0
  5. package/dist/assets/{index-_bjyggFK.js → index-Ykq7ihge.js} +1 -1
  6. package/dist/index.d.ts +35 -10
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5147 -3322
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +19 -18
  12. package/src/components/AskAgentModal.tsx +23 -6
  13. package/src/components/LintModal.tsx +40 -10
  14. package/src/components/MediaPreview.tsx +36 -1
  15. package/src/components/SaveQueuePausedBanner.tsx +7 -6
  16. package/src/components/StudioErrorBoundary.tsx +19 -31
  17. package/src/components/StudioFeedbackBar.tsx +11 -2
  18. package/src/components/StudioGlobalDragOverlay.tsx +1 -1
  19. package/src/components/StudioHeader.tsx +184 -106
  20. package/src/components/StudioLeftSidebar.tsx +24 -3
  21. package/src/components/StudioOverlays.tsx +34 -8
  22. package/src/components/StudioRightPanel.tsx +154 -9
  23. package/src/components/StudioSplash.tsx +6 -3
  24. package/src/components/StudioToast.tsx +12 -11
  25. package/src/components/TimelineToolbar.test.tsx +9 -3
  26. package/src/components/TimelineToolbar.tsx +63 -29
  27. package/src/components/editor/PropertyPanel.tsx +18 -17
  28. package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
  29. package/src/components/editor/colorGradingScopePatch.ts +57 -0
  30. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  31. package/src/components/editor/manualEditingAvailability.ts +0 -6
  32. package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
  33. package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
  34. package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
  35. package/src/components/editor/propertyPanelHelpers.ts +22 -91
  36. package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
  37. package/src/components/editor/propertyPanelTypes.ts +111 -0
  38. package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
  39. package/src/components/nle/NLELayout.tsx +54 -51
  40. package/src/components/nle/NLEPreview.tsx +41 -5
  41. package/src/components/nle/TimelineResizeDivider.tsx +97 -0
  42. package/src/components/nle/usePreviewBlockDrop.ts +31 -7
  43. package/src/components/renders/RenderQueue.tsx +125 -24
  44. package/src/components/renders/RenderQueueItem.tsx +132 -64
  45. package/src/components/renders/useRenderQueue.ts +136 -24
  46. package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
  47. package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
  48. package/src/components/storyboard/StoryboardView.tsx +6 -0
  49. package/src/components/studioColorGradingScope.ts +124 -0
  50. package/src/components/studioMediaJobs.ts +123 -0
  51. package/src/components/ui/Button.tsx +20 -11
  52. package/src/components/ui/HyperframesLoader.tsx +9 -2
  53. package/src/components/ui/SearchInput.tsx +53 -0
  54. package/src/components/ui/Tooltip.tsx +52 -6
  55. package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
  56. package/src/components/ui/useDialogBehavior.ts +83 -0
  57. package/src/contexts/PanelLayoutContext.tsx +3 -0
  58. package/src/contexts/StudioContext.tsx +7 -0
  59. package/src/hooks/useCompositionContentLoader.ts +40 -0
  60. package/src/hooks/useEditorSave.ts +14 -0
  61. package/src/hooks/useFileManager.ts +39 -25
  62. package/src/hooks/useFrameCapture.ts +12 -1
  63. package/src/hooks/usePanelLayout.ts +1 -0
  64. package/src/hooks/usePreviewPersistence.ts +24 -15
  65. package/src/hooks/useStudioContextValue.ts +5 -0
  66. package/src/hooks/useToast.ts +66 -13
  67. package/src/styles/studio.css +61 -0
  68. package/src/utils/sdkResolverShadow.test.ts +70 -3
  69. package/src/utils/sdkResolverShadow.ts +47 -4
  70. package/src/utils/studioPendingEdits.test.ts +43 -0
  71. package/src/utils/studioPendingEdits.ts +45 -0
  72. package/src/utils/studioUiPreferences.ts +4 -0
  73. package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
  74. package/dist/assets/index-DfeE1_Rl.js +0 -396
  75. package/dist/assets/index-DmkOvZns.css +0 -1
@@ -1,19 +1,20 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1
+ import { useMemo, useRef, useState } from "react";
2
2
  import {
3
3
  HF_COLOR_GRADING_PRESETS,
4
4
  normalizeHfColorGrading,
5
5
  type HfColorGradingAdjustKey,
6
+ type HfColorGradingDetailKey,
7
+ type HfColorGradingEffectKey,
6
8
  type NormalizedHfColorGrading,
7
9
  } from "@hyperframes/core/color-grading";
8
- import { Minus, Plus, RotateCcw } from "../../icons/SystemIcons";
10
+ import { ChevronDown, ChevronRight, Plus, X } from "../../icons/SystemIcons";
9
11
  import { LUT_EXT } from "../../utils/mediaTypes";
10
12
  import { LABEL } from "./propertyPanelHelpers";
13
+ import { ColorGradingSliderControl } from "./propertyPanelColorGradingSlider";
11
14
 
12
15
  const LUT_UPLOAD_DIR = "assets/luts";
13
- const SLIDER_THUMB_SIZE = 10;
14
- const SLIDER_THUMB_RADIUS = SLIDER_THUMB_SIZE / 2;
15
16
 
16
- const SLIDERS: Array<{
17
+ const ADJUST_SLIDERS: Array<{
17
18
  key: HfColorGradingAdjustKey;
18
19
  label: string;
19
20
  min: number;
@@ -34,254 +35,133 @@ const SLIDERS: Array<{
34
35
  suffix: "%",
35
36
  },
36
37
  { key: "shadows", label: "Shadows", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
37
- { key: "whites", label: "Whites", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
38
- { key: "blacks", label: "Blacks", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
38
+ {
39
+ key: "whites",
40
+ label: "White Point",
41
+ min: -100,
42
+ max: 100,
43
+ step: 1,
44
+ scale: 100,
45
+ suffix: "%",
46
+ },
47
+ {
48
+ key: "blacks",
49
+ label: "Black Point",
50
+ min: -100,
51
+ max: 100,
52
+ step: 1,
53
+ scale: 100,
54
+ suffix: "%",
55
+ },
39
56
  { key: "temperature", label: "Warmth", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
40
57
  { key: "tint", label: "Tint", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
58
+ { key: "vibrance", label: "Vibrance", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
41
59
  { key: "saturation", label: "Saturation", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
42
60
  ];
43
61
 
44
- function clampNumber(value: number, min: number, max: number): number {
45
- if (!Number.isFinite(value)) return min;
46
- return Math.min(max, Math.max(min, value));
47
- }
48
-
49
- function formatNumericInput(value: number, scale: number): string {
50
- const scaled = value / scale;
51
- return scale === 100 ? scaled.toFixed(2) : String(Math.round(scaled));
52
- }
53
-
54
- function parseNumericInput(value: string, scale: number): number | null {
55
- const parsed = Number(value);
56
- if (!Number.isFinite(parsed)) return null;
57
- return parsed * scale;
58
- }
59
-
60
- function tickPercent(value: number, min: number, max: number): number {
61
- if (max <= min) return 0;
62
- return ((value - min) / (max - min)) * 100;
63
- }
64
-
65
- function ColorGradingSliderControl({
66
- label,
67
- value,
68
- min,
69
- max,
70
- step,
71
- neutral = min,
72
- scale = 1,
73
- suffix = "",
74
- displayValue,
75
- disabled,
76
- onCommit,
77
- onReset,
78
- }: {
62
+ const DETAIL_SLIDERS: Array<{
63
+ key: HfColorGradingDetailKey;
79
64
  label: string;
80
- value: number;
81
65
  min: number;
82
66
  max: number;
83
67
  step: number;
84
- neutral?: number;
85
- scale?: number;
86
- suffix?: string;
87
- displayValue: string;
88
- disabled?: boolean;
89
- onCommit: (nextValue: number) => void;
90
- onReset?: () => void;
91
- }) {
92
- const [draftState, setDraftState] = useState<{ value: number; source: number } | null>(null);
93
- const [inputDraft, setInputDraft] = useState<{ value: string; source: number } | null>(null);
94
- const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
95
- const valueRef = useRef(value);
96
- valueRef.current = value;
97
-
98
- useEffect(
99
- () => () => {
100
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
101
- },
102
- [],
103
- );
104
-
105
- const clampDraft = useCallback(
106
- (nextValue: number) => clampNumber(nextValue, min, max),
107
- [max, min],
108
- );
109
-
110
- const setLocalDraft = useCallback(
111
- (nextValue: number) => {
112
- const clamped = clampDraft(nextValue);
113
- const source = valueRef.current;
114
- setDraftState({ value: clamped, source });
115
- setInputDraft({ value: formatNumericInput(clamped, scale), source });
116
- return clamped;
117
- },
118
- [clampDraft, scale],
119
- );
120
-
121
- const commitDraft = useCallback(
122
- (nextValue: number) => {
123
- const clamped = setLocalDraft(nextValue);
124
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
125
- if (clamped !== valueRef.current) onCommit(clamped);
126
- },
127
- [onCommit, setLocalDraft],
128
- );
129
-
130
- const scheduleCommit = useCallback(
131
- (nextValue: number) => {
132
- const clamped = setLocalDraft(nextValue);
133
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
134
- commitTimerRef.current = setTimeout(() => {
135
- if (clamped !== valueRef.current) onCommit(clamped);
136
- }, 40);
137
- },
138
- [onCommit, setLocalDraft],
139
- );
140
-
141
- const draft = draftState?.source === value ? draftState.value : value;
142
- const inputValue =
143
- inputDraft?.source === value ? inputDraft.value : formatNumericInput(draft, scale);
144
-
145
- const commitInputDraft = useCallback(() => {
146
- const parsed = parseNumericInput(inputValue, scale);
147
- if (parsed === null) {
148
- setInputDraft(null);
149
- return;
150
- }
151
- commitDraft(parsed);
152
- }, [commitDraft, inputValue, scale]);
68
+ scale: number;
69
+ suffix: string;
70
+ defaultValue?: number;
71
+ }> = [
72
+ { key: "vignette", label: "Vignette", min: 0, max: 100, step: 1, scale: 100, suffix: "%" },
73
+ {
74
+ key: "vignetteMidpoint",
75
+ label: "Midpoint",
76
+ min: 0,
77
+ max: 100,
78
+ step: 1,
79
+ scale: 100,
80
+ suffix: "%",
81
+ defaultValue: 50,
82
+ },
83
+ {
84
+ key: "vignetteRoundness",
85
+ label: "Roundness",
86
+ min: -100,
87
+ max: 100,
88
+ step: 1,
89
+ scale: 100,
90
+ suffix: "%",
91
+ },
92
+ {
93
+ key: "vignetteFeather",
94
+ label: "Feather",
95
+ min: 0,
96
+ max: 100,
97
+ step: 1,
98
+ scale: 100,
99
+ suffix: "%",
100
+ defaultValue: 65,
101
+ },
102
+ { key: "grain", label: "Grain", min: 0, max: 100, step: 1, scale: 100, suffix: "%" },
103
+ {
104
+ key: "grainSize",
105
+ label: "Grain Size",
106
+ min: 0,
107
+ max: 100,
108
+ step: 1,
109
+ scale: 100,
110
+ suffix: "%",
111
+ defaultValue: 25,
112
+ },
113
+ {
114
+ key: "grainRoughness",
115
+ label: "Roughness",
116
+ min: 0,
117
+ max: 100,
118
+ step: 1,
119
+ scale: 100,
120
+ suffix: "%",
121
+ defaultValue: 50,
122
+ },
123
+ ];
153
124
 
154
- const nudge = useCallback(
155
- (direction: -1 | 1) => {
156
- commitDraft(draft + step * direction);
157
- },
158
- [commitDraft, draft, step],
159
- );
125
+ type DetailSlider = (typeof DETAIL_SLIDERS)[number];
126
+ type SliderSettings = {
127
+ active?: boolean;
128
+ label: string;
129
+ onClick: () => void;
130
+ };
160
131
 
161
- const range = max - min;
162
- const valuePercent = range === 0 ? 0 : ((draft - min) / range) * 100;
163
- const neutralPercent = range === 0 ? 0 : ((neutral - min) / range) * 100;
164
- const fillLeft = Math.min(valuePercent, neutralPercent);
165
- const fillWidth = Math.abs(valuePercent - neutralPercent);
166
- const ticks = Array.from(new Set([min, neutral, max])).sort((a, b) => a - b);
132
+ const EFFECT_SLIDERS: Array<{
133
+ key: HfColorGradingEffectKey;
134
+ label: string;
135
+ min: number;
136
+ max: number;
137
+ step: number;
138
+ scale: number;
139
+ suffix: string;
140
+ }> = [
141
+ { key: "blur", label: "Blur", min: 0, max: 100, step: 1, scale: 100, suffix: "%" },
142
+ { key: "pixelate", label: "Pixelate", min: 0, max: 100, step: 1, scale: 100, suffix: "%" },
143
+ ];
167
144
 
168
- return (
169
- <div className="grid min-w-0 gap-1.5 rounded-md bg-panel-input/30 p-2">
170
- <div className="flex min-w-0 items-center gap-1.5">
171
- <span className={`${LABEL} min-w-0 flex-1 truncate`}>{label}</span>
172
- {onReset && (
173
- <button
174
- type="button"
175
- disabled={disabled}
176
- aria-label={`Reset ${label}`}
177
- onClick={(event) => {
178
- event.stopPropagation();
179
- onReset();
180
- }}
181
- className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-panel-text-5 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
182
- title={`Reset ${label}`}
183
- >
184
- <RotateCcw size={11} />
185
- </button>
186
- )}
187
- </div>
145
+ const AMOUNT_DETAIL_SLIDERS = DETAIL_SLIDERS.filter(
146
+ (slider) => slider.key === "vignette" || slider.key === "grain",
147
+ );
148
+ const VIGNETTE_TUNE_SLIDERS = DETAIL_SLIDERS.filter(
149
+ (slider) =>
150
+ slider.key === "vignetteMidpoint" ||
151
+ slider.key === "vignetteRoundness" ||
152
+ slider.key === "vignetteFeather",
153
+ );
154
+ const GRAIN_TUNE_SLIDERS = DETAIL_SLIDERS.filter(
155
+ (slider) => slider.key === "grainSize" || slider.key === "grainRoughness",
156
+ );
188
157
 
189
- <div className="relative h-7 min-w-0">
190
- <div
191
- data-color-grading-slider-track="true"
192
- className="pointer-events-none absolute inset-y-0 z-0"
193
- style={{ left: SLIDER_THUMB_RADIUS, right: SLIDER_THUMB_RADIUS }}
194
- >
195
- {ticks.map((tick) => (
196
- <div
197
- key={tick}
198
- data-color-grading-slider-tick="true"
199
- className="absolute top-1/2 h-3 w-px -translate-y-1/2 bg-panel-text-3"
200
- style={{ left: `${tickPercent(tick, min, max)}%` }}
201
- title={String(tick / scale)}
202
- />
203
- ))}
204
- <div className="absolute left-0 right-0 top-1/2 z-10 h-0.5 -translate-y-1/2 rounded-full bg-panel-border" />
205
- <div
206
- className="absolute top-1/2 z-20 h-0.5 -translate-y-1/2 rounded-full bg-studio-accent"
207
- style={{ left: `${fillLeft}%`, width: `${fillWidth}%` }}
208
- />
209
- </div>
210
- <input
211
- type="range"
212
- min={min}
213
- max={max}
214
- step={step}
215
- value={draft}
216
- disabled={disabled}
217
- aria-label={label}
218
- onChange={(event) => scheduleCommit(Number(event.currentTarget.value))}
219
- onMouseUp={() => commitDraft(draft)}
220
- onTouchEnd={() => commitDraft(draft)}
221
- onBlur={() => commitDraft(draft)}
222
- className="hf-color-grading-range absolute left-0 right-0 top-1/2 z-30 min-w-0 w-full -translate-y-1/2"
223
- title={displayValue}
224
- />
225
- </div>
158
+ function normalizedDefaultValue(slider: { defaultValue?: number; scale: number }): number {
159
+ return (slider.defaultValue ?? 0) / slider.scale;
160
+ }
226
161
 
227
- <div className="flex min-w-0 items-center justify-end gap-1.5">
228
- <div className="flex flex-shrink-0 items-center rounded-md bg-panel-input px-1.5 py-1">
229
- <input
230
- type="number"
231
- value={inputValue}
232
- min={min / scale}
233
- max={max / scale}
234
- step={step / scale}
235
- disabled={disabled}
236
- onChange={(event) =>
237
- setInputDraft({ value: event.currentTarget.value, source: valueRef.current })
238
- }
239
- onBlur={commitInputDraft}
240
- onKeyDown={(event) => {
241
- if (event.key === "Enter") {
242
- event.currentTarget.blur();
243
- return;
244
- }
245
- if (event.key === "ArrowUp") {
246
- event.preventDefault();
247
- nudge(1);
248
- return;
249
- }
250
- if (event.key === "ArrowDown") {
251
- event.preventDefault();
252
- nudge(-1);
253
- }
254
- }}
255
- className="hf-color-grading-number h-5 w-[38px] bg-transparent text-right text-[11px] font-medium tabular-nums text-panel-text-1 outline-none disabled:cursor-not-allowed"
256
- title={displayValue}
257
- />
258
- {suffix && <span className="ml-0.5 text-[10px] text-panel-text-5">{suffix}</span>}
259
- </div>
260
- <div className="flex flex-shrink-0 overflow-hidden rounded-md bg-panel-input">
261
- <button
262
- type="button"
263
- disabled={disabled}
264
- aria-label={`Decrease ${label}`}
265
- onClick={() => nudge(-1)}
266
- className="flex h-7 w-5 items-center justify-center text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
267
- title={`Decrease ${label}`}
268
- >
269
- <Minus size={11} />
270
- </button>
271
- <button
272
- type="button"
273
- disabled={disabled}
274
- aria-label={`Increase ${label}`}
275
- onClick={() => nudge(1)}
276
- className="flex h-7 w-5 items-center justify-center border-l border-panel-border text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
277
- title={`Increase ${label}`}
278
- >
279
- <Plus size={11} />
280
- </button>
281
- </div>
282
- </div>
283
- </div>
284
- );
162
+ function visibleIntensity(grading: NormalizedHfColorGrading): number {
163
+ // Earlier drafts could persist 0% strength; the next manual edit should revive visible grading.
164
+ return grading.intensity === 0 ? 1 : grading.intensity;
285
165
  }
286
166
 
287
167
  export function ColorGradingControls({
@@ -296,21 +176,37 @@ export function ColorGradingControls({
296
176
  onCommitColorGrading: (nextGrading: NormalizedHfColorGrading) => void;
297
177
  }) {
298
178
  const lutInputRef = useRef<HTMLInputElement>(null);
179
+ const [lutOpen, setLutOpen] = useState(false);
180
+ const [detailSettings, setDetailSettings] = useState<"vignette" | "grain" | null>(null);
299
181
  const lutAssets = useMemo(
300
182
  () => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)),
301
183
  [assets],
302
184
  );
303
185
  const selectedLut = grading.lut?.src ?? "";
304
186
  const selectedProjectLut = selectedLut ? (selectedLut.split("/").pop() ?? selectedLut) : null;
187
+ const detailSettingsSliders =
188
+ detailSettings === "vignette" ? VIGNETTE_TUNE_SLIDERS : GRAIN_TUNE_SLIDERS;
189
+ const vignetteSettingsActive = VIGNETTE_TUNE_SLIDERS.some(
190
+ (slider) => Math.abs(grading.details[slider.key] - normalizedDefaultValue(slider)) > 0.0001,
191
+ );
192
+ const grainSettingsActive = GRAIN_TUNE_SLIDERS.some(
193
+ (slider) => Math.abs(grading.details[slider.key] - normalizedDefaultValue(slider)) > 0.0001,
194
+ );
305
195
 
306
196
  const applyPreset = (preset: string) => {
307
- const next = normalizeHfColorGrading({ preset, intensity: 1 });
197
+ const next = normalizeHfColorGrading({ preset, intensity: 1, lut: grading.lut });
308
198
  if (next) onCommitColorGrading(next);
309
199
  };
200
+ const updateFilterIntensity = (value: number) => {
201
+ onCommitColorGrading({
202
+ ...grading,
203
+ intensity: value / 100,
204
+ });
205
+ };
310
206
  const applyLut = (src: string | null, intensity = 1) => {
311
207
  onCommitColorGrading({
312
208
  ...grading,
313
- intensity: 1,
209
+ intensity: visibleIntensity(grading),
314
210
  lut: src ? { src, intensity } : null,
315
211
  });
316
212
  };
@@ -324,9 +220,48 @@ export function ColorGradingControls({
324
220
  const firstLut = uploaded.find((asset) => LUT_EXT.test(asset));
325
221
  if (firstLut) applyLut(firstLut, 1);
326
222
  };
223
+ const commitDetailSlider = (slider: DetailSlider, next: number) => {
224
+ onCommitColorGrading({
225
+ ...grading,
226
+ intensity: visibleIntensity(grading),
227
+ details: {
228
+ ...grading.details,
229
+ [slider.key]: next / slider.scale,
230
+ },
231
+ });
232
+ };
233
+ const resetDetailSlider = (slider: DetailSlider) => {
234
+ onCommitColorGrading({
235
+ ...grading,
236
+ intensity: visibleIntensity(grading),
237
+ details: {
238
+ ...grading.details,
239
+ [slider.key]: normalizedDefaultValue(slider),
240
+ },
241
+ });
242
+ };
243
+ const renderDetailSlider = (slider: DetailSlider, settings?: SliderSettings) => {
244
+ const value = Math.round(grading.details[slider.key] * slider.scale);
245
+ return (
246
+ <ColorGradingSliderControl
247
+ key={slider.key}
248
+ label={slider.label}
249
+ value={value}
250
+ min={slider.min}
251
+ max={slider.max}
252
+ step={slider.step}
253
+ neutral={slider.defaultValue ?? 0}
254
+ suffix={slider.suffix}
255
+ displayValue={`${value}%`}
256
+ settings={settings}
257
+ onCommit={(next) => commitDetailSlider(slider, next)}
258
+ onReset={() => resetDetailSlider(slider)}
259
+ />
260
+ );
261
+ };
327
262
 
328
263
  return (
329
- <div className="space-y-4">
264
+ <div className="space-y-3">
330
265
  <label className="grid min-w-0 gap-1.5">
331
266
  <span className={LABEL}>Preset</span>
332
267
  <select
@@ -341,97 +276,125 @@ export function ColorGradingControls({
341
276
  ))}
342
277
  </select>
343
278
  </label>
279
+ <ColorGradingSliderControl
280
+ label="Preset strength"
281
+ value={Math.round(grading.intensity * 100)}
282
+ min={0}
283
+ max={100}
284
+ step={1}
285
+ neutral={0}
286
+ suffix="%"
287
+ displayValue={`${Math.round(grading.intensity * 100)}%`}
288
+ onCommit={updateFilterIntensity}
289
+ onReset={() => updateFilterIntensity(100)}
290
+ />
344
291
 
345
- <div className="grid min-w-0 gap-1.5">
346
- <span className={LABEL}>LUT Filter</span>
347
- <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_28px] gap-2">
348
- <select
349
- value={selectedLut}
350
- onChange={(event) => {
351
- const nextSrc = event.target.value;
352
- applyLut(
353
- nextSrc || null,
354
- nextSrc && grading.lut?.src === nextSrc ? grading.lut.intensity : 1,
355
- );
356
- }}
357
- className="w-full min-w-0 rounded-md bg-panel-input px-3 py-2 text-[11px] font-medium text-panel-text-1 outline-none"
358
- title="Uploaded .cube LUT filter"
359
- >
360
- <option value="">None</option>
361
- {lutAssets.length > 0 && (
362
- <optgroup label="Uploaded LUTs">
363
- {lutAssets.map((asset) => (
364
- <option key={asset} value={asset}>
365
- {asset.split("/").pop() ?? asset}
366
- </option>
367
- ))}
368
- </optgroup>
369
- )}
370
- </select>
371
- <button
372
- type="button"
373
- disabled={!onImportAssets}
374
- onClick={(event) => {
375
- event.stopPropagation();
376
- lutInputRef.current?.click();
377
- }}
378
- className="flex h-8 w-8 items-center justify-center rounded-md bg-panel-input text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
379
- title="Import .cube LUT"
380
- aria-label="Import .cube LUT"
381
- >
382
- <Plus size={13} />
383
- </button>
384
- <input
385
- ref={lutInputRef}
386
- type="file"
387
- accept=".cube"
388
- multiple
389
- className="hidden"
390
- onChange={(event) => {
391
- void importLuts(event.currentTarget.files);
392
- event.currentTarget.value = "";
393
- }}
394
- />
395
- </div>
396
- {grading.lut && (
397
- <div className="grid gap-2">
398
- {selectedProjectLut && (
399
- <div className="flex min-w-0 items-start gap-2 text-[10px] leading-4 text-panel-text-3">
400
- <span className="mt-[5px] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-studio-accent" />
401
- <span className="min-w-0">
402
- <span className="font-medium text-panel-text-2">Uploaded LUT</span>
403
- {` · ${selectedProjectLut}`}
404
- </span>
292
+ <div className="min-w-0 rounded-md border border-panel-border/70 bg-panel-input/15">
293
+ <button
294
+ type="button"
295
+ className="flex h-8 w-full min-w-0 items-center gap-1.5 px-2 text-left text-[11px] font-medium text-panel-text-3 transition-colors hover:bg-panel-hover/60 hover:text-panel-text-1"
296
+ onClick={() => setLutOpen((value) => !value)}
297
+ aria-expanded={lutOpen}
298
+ >
299
+ {lutOpen ? (
300
+ <ChevronDown size={11} className="flex-shrink-0 text-panel-text-5" />
301
+ ) : (
302
+ <ChevronRight size={11} className="flex-shrink-0 text-panel-text-5" />
303
+ )}
304
+ <span className="min-w-0 flex-1 truncate">Custom LUT</span>
305
+ {grading.lut && (
306
+ <span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-studio-accent" />
307
+ )}
308
+ </button>
309
+ {lutOpen && (
310
+ <div className="grid gap-1.5 border-t border-panel-border/60 p-1.5">
311
+ <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_28px] gap-2">
312
+ <select
313
+ value={selectedLut}
314
+ onChange={(event) => {
315
+ const nextSrc = event.target.value;
316
+ applyLut(
317
+ nextSrc || null,
318
+ nextSrc && grading.lut?.src === nextSrc ? grading.lut.intensity : 1,
319
+ );
320
+ }}
321
+ className="w-full min-w-0 rounded-md bg-panel-input px-3 py-2 text-[11px] font-medium text-panel-text-1 outline-none"
322
+ title="Uploaded .cube LUT"
323
+ >
324
+ <option value="">None</option>
325
+ {lutAssets.length > 0 && (
326
+ <optgroup label="Uploaded LUTs">
327
+ {lutAssets.map((asset) => (
328
+ <option key={asset} value={asset}>
329
+ {asset.split("/").pop() ?? asset}
330
+ </option>
331
+ ))}
332
+ </optgroup>
333
+ )}
334
+ </select>
335
+ <button
336
+ type="button"
337
+ disabled={!onImportAssets}
338
+ onClick={(event) => {
339
+ event.stopPropagation();
340
+ lutInputRef.current?.click();
341
+ }}
342
+ className="flex h-8 w-8 items-center justify-center rounded-md bg-panel-input text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
343
+ title="Import .cube LUT"
344
+ aria-label="Import .cube LUT"
345
+ >
346
+ <Plus size={13} />
347
+ </button>
348
+ <input
349
+ ref={lutInputRef}
350
+ type="file"
351
+ accept=".cube"
352
+ multiple
353
+ className="hidden"
354
+ onChange={(event) => {
355
+ void importLuts(event.currentTarget.files);
356
+ event.currentTarget.value = "";
357
+ }}
358
+ />
359
+ </div>
360
+ {grading.lut && (
361
+ <div className="grid gap-2">
362
+ {selectedProjectLut && (
363
+ <div className="flex min-w-0 items-start gap-2 text-[10px] leading-4 text-panel-text-3">
364
+ <span className="mt-[5px] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-studio-accent" />
365
+ <span className="min-w-0 flex-1 truncate" title={selectedProjectLut}>
366
+ <span className="font-medium text-panel-text-2">Uploaded LUT</span>
367
+ {` · ${selectedProjectLut}`}
368
+ </span>
369
+ </div>
370
+ )}
371
+ <ColorGradingSliderControl
372
+ label="LUT Strength"
373
+ value={Math.round((grading.lut.intensity ?? 1) * 100)}
374
+ min={0}
375
+ max={100}
376
+ step={1}
377
+ neutral={0}
378
+ suffix="%"
379
+ displayValue={`${Math.round((grading.lut.intensity ?? 1) * 100)}%`}
380
+ onCommit={updateLutIntensity}
381
+ onReset={() => updateLutIntensity(100)}
382
+ />
405
383
  </div>
406
384
  )}
407
- <ColorGradingSliderControl
408
- label="LUT Strength"
409
- value={Math.round((grading.lut.intensity ?? 1) * 100)}
410
- min={0}
411
- max={100}
412
- step={1}
413
- neutral={0}
414
- suffix="%"
415
- displayValue={`${Math.round((grading.lut.intensity ?? 1) * 100)}%`}
416
- onCommit={updateLutIntensity}
417
- onReset={() => updateLutIntensity(100)}
418
- />
419
385
  </div>
420
386
  )}
421
387
  </div>
422
388
 
423
- <div className="grid min-w-0 grid-cols-2 gap-3">
424
- {SLIDERS.map((slider, index) => {
425
- const value = grading.adjust[slider.key] * slider.scale;
426
- const isExposure = slider.key === "exposure";
427
- return (
428
- <div
429
- key={slider.key}
430
- className={
431
- SLIDERS.length % 2 === 1 && index === SLIDERS.length - 1 ? "col-span-2" : ""
432
- }
433
- >
389
+ <div className="grid min-w-0 gap-1.5">
390
+ <span className={LABEL}>Adjust</span>
391
+ <div className="grid min-w-0 grid-cols-2 gap-1.5">
392
+ {ADJUST_SLIDERS.map((slider) => {
393
+ const value = grading.adjust[slider.key] * slider.scale;
394
+ const isExposure = slider.key === "exposure";
395
+ return (
434
396
  <ColorGradingSliderControl
397
+ key={slider.key}
435
398
  label={slider.label}
436
399
  value={Math.round(value)}
437
400
  min={slider.min}
@@ -448,7 +411,7 @@ export function ColorGradingControls({
448
411
  onCommit={(next) => {
449
412
  onCommitColorGrading({
450
413
  ...grading,
451
- intensity: 1,
414
+ intensity: visibleIntensity(grading),
452
415
  adjust: {
453
416
  ...grading.adjust,
454
417
  [slider.key]: next / slider.scale,
@@ -458,7 +421,7 @@ export function ColorGradingControls({
458
421
  onReset={() => {
459
422
  onCommitColorGrading({
460
423
  ...grading,
461
- intensity: 1,
424
+ intensity: visibleIntensity(grading),
462
425
  adjust: {
463
426
  ...grading.adjust,
464
427
  [slider.key]: 0,
@@ -466,9 +429,88 @@ export function ColorGradingControls({
466
429
  });
467
430
  }}
468
431
  />
432
+ );
433
+ })}
434
+ </div>
435
+ </div>
436
+
437
+ <div className="grid min-w-0 gap-1.5">
438
+ <span className={LABEL}>Finishing</span>
439
+ <div className="grid min-w-0 grid-cols-2 gap-1.5">
440
+ {AMOUNT_DETAIL_SLIDERS.map((slider) =>
441
+ renderDetailSlider(slider, {
442
+ active: slider.key === "vignette" ? vignetteSettingsActive : grainSettingsActive,
443
+ label: `${slider.label} settings`,
444
+ onClick: () =>
445
+ setDetailSettings((current) =>
446
+ current === slider.key ? null : (slider.key as "vignette" | "grain"),
447
+ ),
448
+ }),
449
+ )}
450
+ </div>
451
+ {detailSettings && (
452
+ <div className="grid min-w-0 gap-1.5 rounded-md border border-panel-border bg-panel-input/40 p-1.5 shadow-xl shadow-black/20">
453
+ <div className="flex min-w-0 items-center gap-2 px-0.5">
454
+ <span className={`${LABEL} min-w-0 flex-1 truncate`}>
455
+ {detailSettings === "vignette" ? "Vignette settings" : "Grain settings"}
456
+ </span>
457
+ <button
458
+ type="button"
459
+ aria-label="Close settings"
460
+ title="Close settings"
461
+ onClick={() => setDetailSettings(null)}
462
+ className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-panel-text-5 transition-colors hover:bg-panel-hover hover:text-panel-text-1"
463
+ >
464
+ <X size={11} />
465
+ </button>
466
+ </div>
467
+ <div className="grid min-w-0 grid-cols-2 gap-1.5">
468
+ {detailSettingsSliders.map((slider) => renderDetailSlider(slider))}
469
469
  </div>
470
- );
471
- })}
470
+ </div>
471
+ )}
472
+ </div>
473
+
474
+ <div className="grid min-w-0 gap-1.5">
475
+ <span className={LABEL}>Effects</span>
476
+ <div className="grid min-w-0 grid-cols-2 gap-1.5">
477
+ {EFFECT_SLIDERS.map((slider) => {
478
+ const value = grading.effects[slider.key] * slider.scale;
479
+ return (
480
+ <ColorGradingSliderControl
481
+ key={slider.key}
482
+ label={slider.label}
483
+ value={Math.round(value)}
484
+ min={slider.min}
485
+ max={slider.max}
486
+ step={slider.step}
487
+ neutral={0}
488
+ suffix={slider.suffix}
489
+ displayValue={`${Math.round(value)}%`}
490
+ onCommit={(next) => {
491
+ onCommitColorGrading({
492
+ ...grading,
493
+ intensity: visibleIntensity(grading),
494
+ effects: {
495
+ ...grading.effects,
496
+ [slider.key]: next / slider.scale,
497
+ },
498
+ });
499
+ }}
500
+ onReset={() => {
501
+ onCommitColorGrading({
502
+ ...grading,
503
+ intensity: visibleIntensity(grading),
504
+ effects: {
505
+ ...grading.effects,
506
+ [slider.key]: 0,
507
+ },
508
+ });
509
+ }}
510
+ />
511
+ );
512
+ })}
513
+ </div>
472
514
  </div>
473
515
  </div>
474
516
  );