@hyperframes/studio 0.7.58 → 0.7.59

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 (156) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-DeQPzqwH.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13514 -8528
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +29 -51
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  96. package/src/contexts/VariablePromoteContext.tsx +10 -5
  97. package/src/hooks/domEditCommitRunner.ts +11 -0
  98. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  99. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  100. package/src/hooks/timelineTrackVisibility.ts +27 -10
  101. package/src/hooks/useAppHotkeys.ts +9 -0
  102. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  103. package/src/hooks/useDomEditCommits.ts +29 -175
  104. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  105. package/src/hooks/useDomEditSession.ts +17 -5
  106. package/src/hooks/useDomEditTextCommits.ts +14 -9
  107. package/src/hooks/useDomEditWiring.ts +10 -9
  108. package/src/hooks/useElementLifecycleOps.ts +8 -3
  109. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  110. package/src/hooks/useFileManager.ts +82 -60
  111. package/src/hooks/useGsapAnimationOps.ts +7 -6
  112. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  113. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  114. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  115. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  116. package/src/hooks/useGsapScriptCommits.ts +150 -50
  117. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  118. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  119. package/src/hooks/useInspectorSplitResize.ts +51 -0
  120. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  121. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  122. package/src/hooks/usePersistentEditHistory.ts +71 -31
  123. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  124. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  125. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  126. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  127. package/src/hooks/useSdkSession.ts +155 -14
  128. package/src/hooks/useSlideshowPersist.ts +7 -1
  129. package/src/hooks/useStoryboard.ts +23 -4
  130. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  131. package/src/hooks/useTimelineEditing.ts +16 -10
  132. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  133. package/src/hooks/useTimelineGroupEditing.ts +16 -2
  134. package/src/hooks/useVariablesPersist.ts +9 -7
  135. package/src/icons/SystemIcons.tsx +2 -0
  136. package/src/styles/studio.css +22 -0
  137. package/src/styles/tailwind-preset.shared.js +7 -0
  138. package/src/utils/designInputTracking.test.ts +97 -0
  139. package/src/utils/designInputTracking.ts +80 -0
  140. package/src/utils/sdkCutover.gate.test.ts +7 -7
  141. package/src/utils/sdkCutover.test.ts +490 -57
  142. package/src/utils/sdkCutover.ts +181 -219
  143. package/src/utils/sdkEditTransaction.ts +292 -0
  144. package/src/utils/sdkResolverAttempts.ts +94 -0
  145. package/src/utils/sdkResolverShadow.test.ts +148 -0
  146. package/src/utils/sdkResolverShadow.ts +139 -106
  147. package/src/utils/setSlideshowManifest.test.ts +0 -9
  148. package/src/utils/setSlideshowManifest.ts +20 -27
  149. package/src/utils/studioFileHistory.test.ts +41 -0
  150. package/src/utils/studioFileHistory.ts +30 -27
  151. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  152. package/src/utils/studioHelpers.test.ts +45 -0
  153. package/src/utils/studioHelpers.ts +45 -14
  154. package/src/utils/studioSaveDiagnostics.ts +17 -0
  155. package/dist/assets/index-B_UvTX3E.js +0 -423
  156. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -0,0 +1,570 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ HF_COLOR_GRADING_PRESETS,
4
+ isHfColorGradingActive,
5
+ normalizeHfColorGrading,
6
+ type HfColorGradingAdjustKey,
7
+ type HfColorGradingDetailKey,
8
+ type HfColorGradingEffectKey,
9
+ type NormalizedHfColorGrading,
10
+ } from "@hyperframes/core/color-grading";
11
+ import { Compare, Plus, RotateCcw, Settings } from "../../icons/SystemIcons";
12
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
13
+ 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
+ ];
146
+
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
+ }
151
+
152
+ function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): string {
153
+ if (key === "exposure") {
154
+ const stops = rawPercent / 100;
155
+ return `${stops >= 0 ? "+" : ""}${stops.toFixed(2)}`;
156
+ }
157
+ return `${Math.round(rawPercent)}%`;
158
+ }
159
+
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
+ const detailByKey = (key: HfColorGradingDetailKey) => {
174
+ const spec = DETAIL_SLIDERS.find((d) => d.key === key);
175
+ if (!spec) throw new Error(`Unknown color grading detail key: ${key}`);
176
+ return spec;
177
+ };
178
+ const VIGNETTE_TUNE_KEYS: HfColorGradingDetailKey[] = [
179
+ "vignetteMidpoint",
180
+ "vignetteRoundness",
181
+ "vignetteFeather",
182
+ ];
183
+ const GRAIN_TUNE_KEYS: HfColorGradingDetailKey[] = ["grainSize", "grainRoughness"];
184
+
185
+ const EFFECT_SLIDERS: Array<{ key: HfColorGradingEffectKey; label: string }> = [
186
+ { key: "blur", label: "Blur" },
187
+ { key: "pixelate", label: "Pixelate" },
188
+ ];
189
+
190
+ function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) {
191
+ if (metadata?.color.dynamicRange !== "hdr") return null;
192
+ const details = [
193
+ metadata.color.codecName,
194
+ metadata.color.profile,
195
+ metadata.color.pixelFormat,
196
+ metadata.color.colorPrimaries,
197
+ metadata.color.colorTransfer,
198
+ ]
199
+ .filter(Boolean)
200
+ .join(" · ");
201
+ return (
202
+ <div
203
+ data-flat-grade-hdr-banner="true"
204
+ className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-1.5 text-[10px] leading-4 text-amber-100"
205
+ >
206
+ <div className="mb-0.5 flex items-center justify-between gap-2">
207
+ <span className="font-semibold">{metadata.color.label} source</span>
208
+ <span className="rounded bg-amber-400/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-100">
209
+ SDR preview
210
+ </span>
211
+ </div>
212
+ <p className="text-amber-100/80">
213
+ These controls use the current SDR shader preview path. Render may stay HDR-tagged, but this
214
+ is not true HDR color grading yet.
215
+ </p>
216
+ {details && (
217
+ <p
218
+ data-flat-grade-hdr-detail="true"
219
+ className="mt-0.5 truncate text-[9px] text-amber-100/55"
220
+ >
221
+ {details}
222
+ </p>
223
+ )}
224
+ </div>
225
+ );
226
+ }
227
+
228
+ // fallow-ignore-next-line complexity
229
+ export function FlatColorGradingSection({
230
+ grading,
231
+ assets,
232
+ onImportAssets,
233
+ onCommitColorGrading,
234
+ applyScope,
235
+ applyBusy,
236
+ onSetApplyScope,
237
+ onApplyToScope,
238
+ onApplyScopeAvailable,
239
+ mediaMetadata,
240
+ }: {
241
+ grading: NormalizedHfColorGrading;
242
+ assets: string[];
243
+ onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
244
+ onCommitColorGrading: (next: NormalizedHfColorGrading) => void;
245
+ applyScope: "source-file" | "project";
246
+ applyBusy: boolean;
247
+ onSetApplyScope: (scope: "source-file" | "project") => void;
248
+ onApplyToScope: () => void;
249
+ onApplyScopeAvailable: boolean;
250
+ mediaMetadata: MediaMetadata | null;
251
+ }) {
252
+ const track = useTrackDesignInput();
253
+ const lutInputRef = useRef<HTMLInputElement>(null);
254
+ const [lutOpen, setLutOpen] = useState(false);
255
+ const [detailSettingsOpen, setDetailSettingsOpen] = useState<"vignette" | "grain" | null>(null);
256
+ const lutAssets = useMemo(
257
+ () => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)),
258
+ [assets],
259
+ );
260
+ const lut = grading.lut;
261
+ const selectedLutName = lut?.src ? (lut.src.split("/").pop() ?? lut.src) : null;
262
+
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
+ }
285
+ };
286
+
287
+ const renderDetailSlider = (key: HfColorGradingDetailKey) => {
288
+ const spec = detailByKey(key);
289
+ const value = grading.details[key];
290
+ const isSet = Math.abs(value - spec.defaultValue) > 1e-4;
291
+ return (
292
+ <FlatSlider
293
+ key={key}
294
+ label={spec.label}
295
+ value={Math.round(value * 100)}
296
+ min={key === "vignetteRoundness" ? -100 : 0}
297
+ max={100}
298
+ tier={isSet ? "explicitCustom" : "default"}
299
+ displayValue={`${Math.round(value * 100)}%`}
300
+ centerTick={key === "vignetteRoundness"}
301
+ 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
+ })
314
+ }
315
+ />
316
+ );
317
+ };
318
+
319
+ return (
320
+ <div className="space-y-1.5">
321
+ <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
+ />
335
+ </div>
336
+ <FlatSlider
337
+ label="Strength"
338
+ value={Math.round(grading.intensity * 100)}
339
+ min={0}
340
+ max={100}
341
+ tier={grading.intensity === 1 ? "default" : "explicitCustom"}
342
+ displayValue={`${Math.round(grading.intensity * 100)}%`}
343
+ onCommit={updateIntensity}
344
+ onReset={() => updateIntensity(100)}
345
+ />
346
+
347
+ <div className="border-t border-panel-hairline pt-1.5">
348
+ <button
349
+ type="button"
350
+ data-flat-grade-lut-toggle="true"
351
+ onClick={() => setLutOpen((v) => !v)}
352
+ className="flex min-h-[30px] w-full items-center justify-between text-left"
353
+ >
354
+ <span className="text-[11px] text-panel-text-2">Custom LUT</span>
355
+ <svg
356
+ width="10"
357
+ height="10"
358
+ viewBox="0 0 10 10"
359
+ fill="currentColor"
360
+ className={`flex-shrink-0 text-panel-text-5 transition-transform ${lutOpen ? "rotate-90" : ""}`}
361
+ >
362
+ <path d="M2 3l3 4 3-4z" />
363
+ </svg>
364
+ </button>
365
+ {lutOpen && (
366
+ <div className="space-y-1.5 pb-1">
367
+ <div className="flex items-center justify-between gap-2">
368
+ <span className="min-w-0 flex-1 truncate font-mono text-[10px] text-panel-text-3">
369
+ {selectedLutName ?? "None"}
370
+ </span>
371
+ <select
372
+ data-flat-grade-lut-select="true"
373
+ aria-label="Custom LUT"
374
+ value={lut?.src ?? ""}
375
+ onChange={(e) => {
376
+ const src = e.target.value;
377
+ track("select", "Custom LUT");
378
+ applyLut(src || null, src && lut?.src === src ? lut.intensity : 1);
379
+ }}
380
+ className="bg-transparent font-mono text-[10px] text-panel-text-3 outline-none"
381
+ >
382
+ <option value="">None</option>
383
+ {lutAssets.map((asset) => (
384
+ <option key={asset} value={asset}>
385
+ {asset.split("/").pop() ?? asset}
386
+ </option>
387
+ ))}
388
+ </select>
389
+ <button
390
+ type="button"
391
+ disabled={!onImportAssets}
392
+ onClick={() => lutInputRef.current?.click()}
393
+ title="Import .cube LUT"
394
+ className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
395
+ >
396
+ <Plus size={12} />
397
+ </button>
398
+ <input
399
+ ref={lutInputRef}
400
+ type="file"
401
+ accept=".cube"
402
+ className="hidden"
403
+ onChange={(e) => {
404
+ void importLuts(e.currentTarget.files);
405
+ e.currentTarget.value = "";
406
+ }}
407
+ />
408
+ </div>
409
+ {lut && (
410
+ <FlatSlider
411
+ label="LUT strength"
412
+ value={Math.round((lut.intensity ?? 1) * 100)}
413
+ min={0}
414
+ max={100}
415
+ tier={lut.intensity === 1 ? "default" : "explicitCustom"}
416
+ displayValue={`${Math.round((lut.intensity ?? 1) * 100)}%`}
417
+ onCommit={(v) => applyLut(lut.src, v / 100)}
418
+ onReset={() => applyLut(lut.src, 1)}
419
+ />
420
+ )}
421
+ </div>
422
+ )}
423
+ </div>
424
+
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
+ <div className="space-y-1.5 border-t border-panel-hairline pt-1.5">
464
+ <div className="mb-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
465
+ Finishing
466
+ </div>
467
+ <div className="flex items-center gap-1.5">
468
+ <div className="flex-1">{renderDetailSlider("vignette")}</div>
469
+ <button
470
+ type="button"
471
+ data-flat-grade-settings="vignette"
472
+ title="Vignette settings"
473
+ onClick={() => setDetailSettingsOpen((c) => (c === "vignette" ? null : "vignette"))}
474
+ className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
475
+ >
476
+ <Settings size={12} />
477
+ </button>
478
+ </div>
479
+ <div className="flex items-center gap-1.5">
480
+ <div className="flex-1">{renderDetailSlider("grain")}</div>
481
+ <button
482
+ type="button"
483
+ data-flat-grade-settings="grain"
484
+ title="Grain settings"
485
+ onClick={() => setDetailSettingsOpen((c) => (c === "grain" ? null : "grain"))}
486
+ className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
487
+ >
488
+ <Settings size={12} />
489
+ </button>
490
+ </div>
491
+ {detailSettingsOpen && (
492
+ <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,
495
+ )}
496
+ </div>
497
+ )}
498
+ </div>
499
+
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
+ {onApplyScopeAvailable && (
537
+ <div className="flex items-center justify-between gap-2 border-t border-panel-hairline pt-1.5">
538
+ <span className="flex items-center gap-1.5 text-[11px] text-panel-text-2">
539
+ Copy grade to
540
+ <select
541
+ aria-label="Copy grade to"
542
+ value={applyScope}
543
+ onChange={(e) => {
544
+ track("select", "Copy grade scope");
545
+ onSetApplyScope(e.target.value as "source-file" | "project");
546
+ }}
547
+ disabled={applyBusy}
548
+ className="bg-transparent font-mono text-[11px] text-panel-text-0 outline-none disabled:opacity-50"
549
+ >
550
+ <option value="source-file">Current file media</option>
551
+ <option value="project">All project media</option>
552
+ </select>
553
+ </span>
554
+ <button
555
+ type="button"
556
+ data-flat-grade-apply="true"
557
+ disabled={applyBusy}
558
+ onClick={() => {
559
+ track("button", "Apply grade to scope");
560
+ onApplyToScope();
561
+ }}
562
+ className="text-[11px] font-medium text-panel-accent hover:text-panel-accent/80 disabled:cursor-not-allowed disabled:opacity-50"
563
+ >
564
+ {applyBusy ? "Applying" : "Apply"}
565
+ </button>
566
+ </div>
567
+ )}
568
+ </div>
569
+ );
570
+ }