@hyperframes/studio 0.7.71 → 0.7.72

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 (61) hide show
  1. package/dist/assets/{hyperframes-player-C1X90psg.js → hyperframes-player-Bjf2HPzR.js} +1 -1
  2. package/dist/assets/index-BgZMle9I.css +1 -0
  3. package/dist/assets/{index-nY4lLBqM.js → index-Ch1hbJ3e.js} +1 -1
  4. package/dist/assets/{index-DzxDHPR1.js → index-Ct_pxETK.js} +1 -1
  5. package/dist/assets/index-DCyWLHnx.js +428 -0
  6. package/dist/index.d.ts +8 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5383 -3804
  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 +74 -88
  22. package/src/components/editor/colorGradingScopePatch.test.ts +3 -3
  23. package/src/components/editor/easeCurveSvg.tsx +65 -0
  24. package/src/components/editor/easePresetLibrary.test.ts +114 -0
  25. package/src/components/editor/easePresetLibrary.ts +49 -0
  26. package/src/components/editor/gsapAnimationConstants.ts +15 -13
  27. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +73 -40
  28. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +85 -66
  29. package/src/components/editor/propertyPanelFlatEffectControl.tsx +96 -0
  30. package/src/components/editor/propertyPanelFlatEffectSpecs.ts +305 -0
  31. package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +331 -0
  32. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +503 -0
  33. package/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx +113 -0
  34. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +108 -0
  35. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -0
  36. package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
  37. package/src/components/editor/propertyPanelTypes.ts +13 -0
  38. package/src/components/editor/useColorGradingController.test.ts +215 -10
  39. package/src/components/editor/useColorGradingController.ts +46 -88
  40. package/src/components/editor/useColorGradingPreviews.ts +307 -0
  41. package/src/components/nle/NLEContext.tsx +1 -1
  42. package/src/contexts/PanelLayoutContext.tsx +3 -6
  43. package/src/hooks/useBlockCatalog.ts +31 -13
  44. package/src/hooks/useBlockHandlers.ts +22 -0
  45. package/src/hooks/useGsapScriptCommits.test.tsx +15 -0
  46. package/src/hooks/useGsapScriptCommits.ts +14 -1
  47. package/src/hooks/usePanelLayout.test.ts +68 -1
  48. package/src/hooks/usePanelLayout.ts +72 -34
  49. package/src/player/components/PlayerControls.tsx +68 -229
  50. package/src/player/components/ShortcutsPanel.tsx +2 -4
  51. package/src/player/components/SpeedMenu.tsx +1 -2
  52. package/src/telemetry/events.test.ts +9 -0
  53. package/src/telemetry/events.ts +5 -0
  54. package/src/utils/blockInstaller.test.ts +67 -0
  55. package/src/utils/blockInstaller.ts +162 -109
  56. package/src/utils/studioUiPreferences.test.ts +5 -0
  57. package/src/utils/studioUiPreferences.ts +8 -0
  58. package/dist/assets/index-CSCh2Vrp.js +0 -428
  59. package/dist/assets/index-Ceyz8Qt2.css +0 -1
  60. package/src/player/components/PlayerControls.test.ts +0 -20
  61. package/src/player/components/useSeekBarDrag.ts +0 -169
@@ -0,0 +1,108 @@
1
+ import { useState } from "react";
2
+ import type { RegistryItem } from "@hyperframes/core/registry";
3
+ import { useBlockCatalog } from "../../hooks/useBlockCatalog";
4
+ import { Film, Plus } from "../../icons/SystemIcons";
5
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
6
+ import type { DomEditSelection } from "./domEditing";
7
+ import { FLAT_PREVIEW_GRID } from "./propertyPanelFlatPrimitives";
8
+ import type { ElementTiming } from "./propertyPanelFlatTimingDerivation";
9
+
10
+ export const MEDIA_TREATMENT_OVERLAY_TAG = "media-treatment-overlay";
11
+
12
+ export function filterMediaTreatmentOverlays(items: readonly RegistryItem[]): RegistryItem[] {
13
+ return items.filter(
14
+ (item) =>
15
+ item.type === "hyperframes:block" &&
16
+ item.tags?.includes(MEDIA_TREATMENT_OVERLAY_TAG) === true,
17
+ );
18
+ }
19
+
20
+ export function deriveMediaOverlayPlacement(
21
+ element: Pick<DomEditSelection, "dataAttributes" | "sourceFile">,
22
+ timing: Pick<ElementTiming, "start" | "duration">,
23
+ ) {
24
+ const authoredTrack = Number.parseInt(element.dataAttributes["track-index"] ?? "", 10);
25
+ return {
26
+ start: timing.start,
27
+ ...(timing.duration > 0 ? { duration: timing.duration } : {}),
28
+ ...(Number.isFinite(authoredTrack) ? { track: authoredTrack + 1 } : {}),
29
+ compositionPath: element.sourceFile,
30
+ };
31
+ }
32
+
33
+ export function FlatOverlaysSection({
34
+ onAddOverlay,
35
+ }: {
36
+ onAddOverlay: (name: string) => Promise<void>;
37
+ }) {
38
+ const track = useTrackDesignInput();
39
+ const { blocks, loading, error } = useBlockCatalog();
40
+ const overlays = filterMediaTreatmentOverlays(blocks);
41
+ const [adding, setAdding] = useState<string | null>(null);
42
+ const [previewing, setPreviewing] = useState<string | null>(null);
43
+
44
+ if (loading) {
45
+ return <div className="py-4 text-center text-[10px] text-panel-text-4">Loading overlays…</div>;
46
+ }
47
+ if (error) {
48
+ return <div className="py-4 text-center text-[10px] text-red-300">{error}</div>;
49
+ }
50
+
51
+ const busy = adding !== null;
52
+ return (
53
+ <div data-flat-overlays="true" className={FLAT_PREVIEW_GRID}>
54
+ {overlays.map((overlay) => (
55
+ <button
56
+ key={overlay.name}
57
+ type="button"
58
+ data-flat-overlay={overlay.name}
59
+ aria-label={`Add ${overlay.title}`}
60
+ disabled={busy}
61
+ title={overlay.description}
62
+ onPointerEnter={() => setPreviewing(overlay.name)}
63
+ onPointerLeave={() => setPreviewing(null)}
64
+ onFocus={() => setPreviewing(overlay.name)}
65
+ onBlur={() => setPreviewing(null)}
66
+ onClick={() => {
67
+ track("button", `Add ${overlay.title}`);
68
+ setAdding(overlay.name);
69
+ void onAddOverlay(overlay.name).finally(() => setAdding(null));
70
+ }}
71
+ className="group min-w-0 overflow-hidden border border-panel-hairline bg-panel-bg-soft text-left transition-colors hover:border-panel-border-input hover:bg-panel-bg disabled:cursor-wait disabled:opacity-50"
72
+ >
73
+ <span className="relative flex aspect-video w-full items-center justify-center overflow-hidden bg-black/20">
74
+ {previewing === overlay.name && overlay.preview?.video ? (
75
+ <video
76
+ src={overlay.preview.video}
77
+ poster={overlay.preview.poster}
78
+ autoPlay
79
+ muted
80
+ loop
81
+ playsInline
82
+ className="block h-full w-full object-cover"
83
+ />
84
+ ) : overlay.preview?.poster ? (
85
+ <img
86
+ data-flat-overlay-preview={overlay.name}
87
+ src={overlay.preview.poster}
88
+ alt=""
89
+ draggable={false}
90
+ loading="lazy"
91
+ className="block h-full w-full object-cover"
92
+ />
93
+ ) : (
94
+ <Film size={15} className="text-panel-text-4" />
95
+ )}
96
+ <Plus
97
+ size={12}
98
+ className="absolute right-1.5 top-1.5 text-white opacity-70 drop-shadow group-hover:text-panel-accent group-hover:opacity-100"
99
+ />
100
+ </span>
101
+ <span className="block truncate px-2 py-1.5 text-[10px] text-panel-text-2">
102
+ {adding === overlay.name ? "Adding…" : overlay.title}
103
+ </span>
104
+ </button>
105
+ ))}
106
+ </div>
107
+ );
108
+ }
@@ -8,6 +8,8 @@ import {
8
8
  type PropertyValueTier,
9
9
  } from "./propertyPanelValueTier";
10
10
 
11
+ export const FLAT_PREVIEW_GRID = "grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-1";
12
+
11
13
  /* ------------------------------------------------------------------ */
12
14
  /* FlatRow — single-column label/value property row */
13
15
  /* ------------------------------------------------------------------ */
@@ -0,0 +1,36 @@
1
+ import type { NormalizedHfColorGrading } from "@hyperframes/core/color-grading";
2
+ import type { ColorGradingPreviewOptions } from "./useColorGradingController";
3
+
4
+ export function presetPreviewHandlers({
5
+ id,
6
+ label,
7
+ resolve,
8
+ onPreview,
9
+ onCommit,
10
+ onTrack,
11
+ }: {
12
+ id: string;
13
+ label: string;
14
+ resolve: () => NormalizedHfColorGrading;
15
+ onPreview: (
16
+ grading: NormalizedHfColorGrading | null,
17
+ options?: ColorGradingPreviewOptions,
18
+ ) => void;
19
+ onCommit: (grading: NormalizedHfColorGrading) => void;
20
+ onTrack: (label: string) => void;
21
+ }) {
22
+ return {
23
+ title: `Preview ${label}`,
24
+ onPointerEnter: () =>
25
+ onPreview(resolve(), {
26
+ animatedPreview: { kind: "presets" as const, id },
27
+ }),
28
+ onPointerLeave: () => onPreview(null),
29
+ onFocus: () => onPreview(resolve()),
30
+ onBlur: () => onPreview(null),
31
+ onClick: () => {
32
+ onTrack(label);
33
+ onCommit(resolve());
34
+ },
35
+ };
36
+ }
@@ -19,6 +19,18 @@ export interface BackgroundRemovalResult {
19
19
  provider?: string;
20
20
  }
21
21
 
22
+ export interface MediaOverlayPlacement {
23
+ start: number;
24
+ duration?: number;
25
+ track?: number;
26
+ compositionPath?: string;
27
+ }
28
+
29
+ export type AddMediaOverlayHandler = (
30
+ blockName: string,
31
+ placement: MediaOverlayPlacement,
32
+ ) => Promise<void>;
33
+
22
34
  export interface PropertyPanelProps {
23
35
  projectId: string;
24
36
  projectDir: string | null;
@@ -69,6 +81,7 @@ export interface PropertyPanelProps {
69
81
  onAskAgent: () => void;
70
82
  onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise<void>;
71
83
  onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
84
+ onAddMediaOverlay?: AddMediaOverlayHandler;
72
85
  fontAssets?: ImportedFontAsset[];
73
86
  onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
74
87
  previewIframeRef?: RefObject<HTMLIFrameElement | null>;
@@ -13,9 +13,9 @@ function brightPopGrading() {
13
13
  return next;
14
14
  }
15
15
 
16
- function warmDaylightGrading() {
17
- const next = normalizeHfColorGrading({ preset: "warm-daylight", intensity: 1 });
18
- if (!next) throw new Error("expected warm-daylight preset to normalize");
16
+ function cleanStudioGrading() {
17
+ const next = normalizeHfColorGrading({ preset: "clean-studio", intensity: 1 });
18
+ if (!next) throw new Error("expected clean-studio preset to normalize");
19
19
  return next;
20
20
  }
21
21
 
@@ -60,14 +60,17 @@ function HookHost({
60
60
  onState,
61
61
  onSetAttributeLive,
62
62
  element,
63
+ previewIframeRef,
63
64
  }: {
64
65
  onState: (state: ReturnType<typeof useColorGradingController>) => void;
65
66
  onSetAttributeLive: (attr: string, value: string | null) => void;
66
67
  element: DomEditSelection;
68
+ previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
67
69
  }) {
68
70
  const state = useColorGradingController({
69
71
  projectId: "proj",
70
72
  element,
73
+ previewIframeRef,
71
74
  onSetAttributeLive,
72
75
  });
73
76
  onState(state);
@@ -77,6 +80,7 @@ function HookHost({
77
80
  function renderHook(
78
81
  onSetAttributeLive: (attr: string, value: string | null) => void,
79
82
  initialElement: DomEditSelection = makeElement(),
83
+ previewIframeRef?: React.RefObject<HTMLIFrameElement | null>,
80
84
  ) {
81
85
  const host = document.createElement("div");
82
86
  document.body.append(host);
@@ -89,6 +93,7 @@ function renderHook(
89
93
  onState: (s: ReturnType<typeof useColorGradingController>) => (latest = s),
90
94
  onSetAttributeLive,
91
95
  element,
96
+ previewIframeRef,
92
97
  }),
93
98
  );
94
99
  });
@@ -107,6 +112,43 @@ function renderHook(
107
112
  };
108
113
  }
109
114
 
115
+ type PreviewWindow = Window & {
116
+ __hf?: {
117
+ colorGrading?: {
118
+ renderPreviews?: ReturnType<typeof vi.fn>;
119
+ startPreviewPlayback?: ReturnType<typeof vi.fn>;
120
+ };
121
+ };
122
+ __player?: { play: ReturnType<typeof vi.fn> };
123
+ };
124
+
125
+ function createPreviewFrame() {
126
+ const iframe = document.body.appendChild(document.createElement("iframe"));
127
+ const contentWindow = iframe.contentWindow as PreviewWindow | null;
128
+ if (!contentWindow) throw new Error("expected iframe contentWindow");
129
+ return { contentWindow, iframe };
130
+ }
131
+
132
+ async function flushPreviewRequest() {
133
+ act(() => vi.advanceTimersByTime(0));
134
+ await act(async () => {
135
+ await Promise.resolve();
136
+ await Promise.resolve();
137
+ });
138
+ }
139
+
140
+ function colorGradingMessages(calls: ReadonlyArray<ReadonlyArray<unknown>>) {
141
+ return calls
142
+ .map(([message]) => message)
143
+ .filter(
144
+ (message): message is { action: string; grading: unknown } =>
145
+ typeof message === "object" &&
146
+ message !== null &&
147
+ "action" in message &&
148
+ message.action === "set-color-grading",
149
+ );
150
+ }
151
+
110
152
  describe("useColorGradingController", () => {
111
153
  it("starts with the neutral (inactive) grading and idle compare state", () => {
112
154
  const { root, getState } = renderHook(vi.fn());
@@ -115,6 +157,77 @@ describe("useColorGradingController", () => {
115
157
  act(() => root.unmount());
116
158
  });
117
159
 
160
+ it("requests one exact preset batch from the selected media runtime", async () => {
161
+ vi.useFakeTimers();
162
+ const devicePixelRatio = vi.spyOn(window, "devicePixelRatio", "get").mockReturnValue(2);
163
+ const { contentWindow, iframe } = createPreviewFrame();
164
+ const renderPreviews = vi.fn().mockResolvedValue({
165
+ width: 160,
166
+ height: 90,
167
+ images: [{ id: "bright-pop", dataUrl: "data:image/png;base64,bright" }],
168
+ });
169
+ contentWindow.__hf = { colorGrading: { renderPreviews } };
170
+ const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
171
+
172
+ act(() => getState().requestPresetPreviews());
173
+ await flushPreviewRequest();
174
+
175
+ expect(renderPreviews).toHaveBeenCalledTimes(1);
176
+ expect(renderPreviews.mock.calls[0]?.[1]).toHaveLength(18);
177
+ expect(renderPreviews.mock.calls[0]?.[2]).toEqual({ maxDimension: 320 });
178
+ expect(getState().presetPreviews).toEqual({
179
+ status: "ready",
180
+ images: { "bright-pop": "data:image/png;base64,bright" },
181
+ width: 160,
182
+ height: 90,
183
+ });
184
+ act(() => root.unmount());
185
+ devicePixelRatio.mockRestore();
186
+ vi.useRealTimers();
187
+ });
188
+
189
+ it("requests exact effect families and retains earlier family images", async () => {
190
+ vi.useFakeTimers();
191
+ const { contentWindow, iframe } = createPreviewFrame();
192
+ const renderPreviews = vi
193
+ .fn()
194
+ .mockImplementation(async (_target: unknown, candidates: Array<{ id: string }>) => ({
195
+ width: 160,
196
+ height: 90,
197
+ images: candidates.map(({ id }) => ({ id, dataUrl: `data:image/png;base64,${id}` })),
198
+ }));
199
+ contentWindow.__hf = { colorGrading: { renderPreviews } };
200
+ const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
201
+
202
+ act(() => getState().requestEffectPreviews(["blur", "pixelate", "bloom"]));
203
+ await flushPreviewRequest();
204
+
205
+ expect(renderPreviews).toHaveBeenCalledTimes(1);
206
+ expect(renderPreviews.mock.calls[0]?.[1].map(({ id }: { id: string }) => id)).toEqual([
207
+ "blur",
208
+ "pixelate",
209
+ "bloom",
210
+ ]);
211
+
212
+ act(() => getState().requestEffectPreviews(["kuwahara"]));
213
+ await flushPreviewRequest();
214
+
215
+ expect(renderPreviews).toHaveBeenCalledTimes(2);
216
+ expect(getState().effectPreviews).toEqual({
217
+ status: "ready",
218
+ images: {
219
+ blur: "data:image/png;base64,blur",
220
+ pixelate: "data:image/png;base64,pixelate",
221
+ bloom: "data:image/png;base64,bloom",
222
+ kuwahara: "data:image/png;base64,kuwahara",
223
+ },
224
+ width: 160,
225
+ height: 90,
226
+ });
227
+ act(() => root.unmount());
228
+ vi.useRealTimers();
229
+ });
230
+
118
231
  it("commitColorGrading updates grading state synchronously and schedules a debounced persist", async () => {
119
232
  vi.useFakeTimers();
120
233
  const onSetAttributeLive = vi.fn();
@@ -135,6 +248,86 @@ describe("useColorGradingController", () => {
135
248
  vi.useRealTimers();
136
249
  });
137
250
 
251
+ it("previews through the runtime only and restores the committed grade without persisting", () => {
252
+ vi.useFakeTimers();
253
+ const onSetAttributeLive = vi.fn();
254
+ const { contentWindow, iframe } = createPreviewFrame();
255
+ const postMessage = vi.spyOn(contentWindow, "postMessage");
256
+ const { root, getState } = renderHook(onSetAttributeLive, makeElement(), {
257
+ current: iframe,
258
+ });
259
+
260
+ act(() => getState().previewColorGrading(brightPopGrading()));
261
+ act(() => getState().previewColorGrading(null));
262
+ const gradingMessages = colorGradingMessages(postMessage.mock.calls);
263
+ expect(gradingMessages).toHaveLength(2);
264
+ expect(gradingMessages[0]?.grading).toMatchObject({ preset: "bright-pop" });
265
+ expect(gradingMessages[1]?.grading).toBeNull();
266
+ expect(getState().grading.preset).toBe("neutral");
267
+ act(() => vi.advanceTimersByTime(500));
268
+ expect(onSetAttributeLive).not.toHaveBeenCalled();
269
+
270
+ act(() => root.unmount());
271
+ vi.useRealTimers();
272
+ });
273
+
274
+ it("animates only the selected video card without starting the project player", async () => {
275
+ vi.useFakeTimers();
276
+ const { contentWindow, iframe } = createPreviewFrame();
277
+ const stopPlayback = vi.fn();
278
+ const renderPreviews = vi.fn().mockResolvedValue({
279
+ width: 320,
280
+ height: 180,
281
+ images: [{ id: "bright-pop", dataUrl: "data:image/png;base64,animated" }],
282
+ });
283
+ const startPreviewPlayback = vi.fn(() => stopPlayback);
284
+ contentWindow.__hf = { colorGrading: { renderPreviews, startPreviewPlayback } };
285
+ contentWindow.__player = { play: vi.fn() };
286
+ const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
287
+
288
+ act(() =>
289
+ getState().previewColorGrading(brightPopGrading(), {
290
+ animatedPreview: { kind: "presets", id: "bright-pop" },
291
+ }),
292
+ );
293
+ await act(async () => {
294
+ await Promise.resolve();
295
+ await Promise.resolve();
296
+ });
297
+ expect(startPreviewPlayback).toHaveBeenCalledTimes(1);
298
+ expect(renderPreviews).toHaveBeenCalledWith(
299
+ expect.anything(),
300
+ [{ id: "bright-pop", grading: expect.objectContaining({ preset: "bright-pop" }) }],
301
+ { maxDimension: 160, useMediaTime: true },
302
+ );
303
+ expect(contentWindow.__player.play).not.toHaveBeenCalled();
304
+ expect(getState().presetPreviews.images["bright-pop"]).toBe("data:image/png;base64,animated");
305
+
306
+ act(() => getState().previewColorGrading(null));
307
+ expect(stopPlayback).toHaveBeenCalledTimes(1);
308
+
309
+ act(() => root.unmount());
310
+ vi.useRealTimers();
311
+ });
312
+
313
+ it("restores a just-committed look even before React renders the new state", () => {
314
+ vi.useFakeTimers();
315
+ const { contentWindow, iframe } = createPreviewFrame();
316
+ const postMessage = vi.spyOn(contentWindow, "postMessage");
317
+ const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
318
+ const brightPop = brightPopGrading();
319
+
320
+ act(() => {
321
+ getState().commitColorGrading(brightPop);
322
+ getState().previewColorGrading(null);
323
+ });
324
+ const gradingMessages = colorGradingMessages(postMessage.mock.calls);
325
+ expect(gradingMessages.at(-1)?.grading).toMatchObject({ preset: "bright-pop" });
326
+
327
+ act(() => root.unmount());
328
+ vi.useRealTimers();
329
+ });
330
+
138
331
  it("reverts to the last confirmed-good grading via the real onSettled(false) signal (matches runDomEditCommit, which never rejects)", async () => {
139
332
  // The actual Studio commit runner (runDomEditCommit) catches persist
140
333
  // failures internally and always resolves — it reports outcome only
@@ -253,7 +446,7 @@ describe("useColorGradingController", () => {
253
446
  });
254
447
  },
255
448
  )
256
- // Edit B (warm-daylight): settles immediately and successfully.
449
+ // Edit B (clean-studio): settles immediately and successfully.
257
450
  .mockImplementationOnce(
258
451
  (_attr: string, _value: string | null, onSettled?: (ok: boolean) => void) => {
259
452
  onSettled?.(true);
@@ -272,13 +465,13 @@ describe("useColorGradingController", () => {
272
465
 
273
466
  // B commits on the SAME element before A's persist has settled.
274
467
  act(() => {
275
- getState().commitColorGrading(warmDaylightGrading());
468
+ getState().commitColorGrading(cleanStudioGrading());
276
469
  });
277
470
  act(() => {
278
471
  vi.advanceTimersByTime(400);
279
472
  });
280
473
  expect(onSetAttributeLive).toHaveBeenCalledTimes(2); // B's persist has already settled (mock resolves sync)
281
- expect(getState().grading.preset).toBe("warm-daylight");
474
+ expect(getState().grading.preset).toBe("clean-studio");
282
475
 
283
476
  // NOW A's stale persist finally settles as a FAILURE — must not revert
284
477
  // `grading` (which now correctly shows B's newer edit) back to the
@@ -292,20 +485,32 @@ describe("useColorGradingController", () => {
292
485
  await Promise.resolve();
293
486
  await Promise.resolve();
294
487
  });
295
- expect(getState().grading.preset).toBe("warm-daylight");
488
+ expect(getState().grading.preset).toBe("clean-studio");
296
489
  act(() => root.unmount());
297
490
  vi.useRealTimers();
298
491
  });
299
492
 
300
- it("resetGrading returns to the neutral preset", () => {
493
+ it("resetGrading resets Grade fields without clearing Effects or Palette", () => {
301
494
  const { root, getState } = renderHook(vi.fn());
495
+ const grading = normalizeHfColorGrading({
496
+ preset: "bright-pop",
497
+ lut: { src: "assets/luts/custom.cube", intensity: 0.6 },
498
+ effects: { pixelate: 0.5 },
499
+ palette: ["#112233", "#ffffff"],
500
+ });
501
+ if (!grading) throw new Error("expected grading to normalize");
302
502
  act(() => {
303
- getState().commitColorGrading(brightPopGrading());
503
+ getState().commitColorGrading(grading);
304
504
  });
305
505
  act(() => {
306
506
  getState().resetGrading();
307
507
  });
308
- expect(getState().grading.preset).toBe("neutral");
508
+ expect(getState().grading).toMatchObject({
509
+ preset: "neutral",
510
+ lut: null,
511
+ effects: { pixelate: 0.5 },
512
+ palette: ["#112233", "#ffffff"],
513
+ });
309
514
  act(() => root.unmount());
310
515
  });
311
516