@hyperframes/studio 0.7.70 → 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 (62) hide show
  1. package/dist/assets/{hyperframes-player-DpKeYLEo.js → hyperframes-player-Bjf2HPzR.js} +1 -1
  2. package/dist/assets/index-BgZMle9I.css +1 -0
  3. package/dist/assets/{index-DXjAllII.js → index-Ch1hbJ3e.js} +1 -1
  4. package/dist/assets/{index-CbX-xoud.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.test.tsx +5 -5
  36. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -0
  37. package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
  38. package/src/components/editor/propertyPanelTypes.ts +13 -0
  39. package/src/components/editor/useColorGradingController.test.ts +235 -30
  40. package/src/components/editor/useColorGradingController.ts +46 -88
  41. package/src/components/editor/useColorGradingPreviews.ts +307 -0
  42. package/src/components/nle/NLEContext.tsx +1 -1
  43. package/src/contexts/PanelLayoutContext.tsx +3 -6
  44. package/src/hooks/useBlockCatalog.ts +31 -13
  45. package/src/hooks/useBlockHandlers.ts +22 -0
  46. package/src/hooks/useGsapScriptCommits.test.tsx +15 -0
  47. package/src/hooks/useGsapScriptCommits.ts +14 -1
  48. package/src/hooks/usePanelLayout.test.ts +68 -1
  49. package/src/hooks/usePanelLayout.ts +72 -34
  50. package/src/player/components/PlayerControls.tsx +68 -229
  51. package/src/player/components/ShortcutsPanel.tsx +2 -4
  52. package/src/player/components/SpeedMenu.tsx +1 -2
  53. package/src/telemetry/events.test.ts +9 -0
  54. package/src/telemetry/events.ts +5 -0
  55. package/src/utils/blockInstaller.test.ts +67 -0
  56. package/src/utils/blockInstaller.ts +162 -109
  57. package/src/utils/studioUiPreferences.test.ts +5 -0
  58. package/src/utils/studioUiPreferences.ts +8 -0
  59. package/dist/assets/index-CAeU317U.js +0 -428
  60. package/dist/assets/index-Ceyz8Qt2.css +0 -1
  61. package/src/player/components/PlayerControls.test.ts +0 -20
  62. 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
+ }
@@ -1159,20 +1159,20 @@ describe("FlatSelectRow — label/value options", () => {
1159
1159
  const { host, root } = renderInto(
1160
1160
  <FlatSelectRow
1161
1161
  label="Preset"
1162
- value="natural-lift"
1162
+ value="clean-studio"
1163
1163
  options={[
1164
1164
  { value: "neutral", label: "Neutral" },
1165
- { value: "natural-lift", label: "Natural Lift" },
1166
- { value: "fresh-pop", label: "Fresh Pop" },
1165
+ { value: "clean-studio", label: "Clean Studio" },
1166
+ { value: "bright-pop", label: "Bright Pop" },
1167
1167
  ]}
1168
1168
  tier="explicitCustom"
1169
1169
  onChange={vi.fn()}
1170
1170
  />,
1171
1171
  );
1172
1172
  const select = host.querySelector("select");
1173
- expect(select?.value).toBe("natural-lift");
1173
+ expect(select?.value).toBe("clean-studio");
1174
1174
  const options = Array.from(host.querySelectorAll("option")).map((o) => o.textContent);
1175
- expect(options).toEqual(["Neutral", "Natural Lift", "Fresh Pop"]);
1175
+ expect(options).toEqual(["Neutral", "Clean Studio", "Bright Pop"]);
1176
1176
  act(() => root.unmount());
1177
1177
  });
1178
1178
 
@@ -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>;
@@ -7,15 +7,15 @@ import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
7
7
  import { useColorGradingController } from "./useColorGradingController";
8
8
  import type { DomEditSelection } from "./domEditing";
9
9
 
10
- function freshPopGrading() {
11
- const next = normalizeHfColorGrading({ preset: "fresh-pop", intensity: 1 });
12
- if (!next) throw new Error("expected fresh-pop preset to normalize");
10
+ function brightPopGrading() {
11
+ const next = normalizeHfColorGrading({ preset: "bright-pop", intensity: 1 });
12
+ if (!next) throw new Error("expected bright-pop preset to normalize");
13
13
  return next;
14
14
  }
15
15
 
16
- function naturalLiftGrading() {
17
- const next = normalizeHfColorGrading({ preset: "natural-lift", intensity: 1 });
18
- if (!next) throw new Error("expected natural-lift 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,14 +157,85 @@ 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();
121
234
  const { root, getState } = renderHook(onSetAttributeLive);
122
235
  act(() => {
123
- getState().commitColorGrading(freshPopGrading());
236
+ getState().commitColorGrading(brightPopGrading());
124
237
  });
125
- expect(getState().grading.preset).toBe("fresh-pop");
238
+ expect(getState().grading.preset).toBe("bright-pop");
126
239
  expect(onSetAttributeLive).not.toHaveBeenCalled();
127
240
  act(() => {
128
241
  vi.advanceTimersByTime(400);
@@ -130,7 +243,87 @@ describe("useColorGradingController", () => {
130
243
  expect(onSetAttributeLive).toHaveBeenCalledTimes(1);
131
244
  const [attr, value] = onSetAttributeLive.mock.calls[0] as [string, string];
132
245
  expect(attr).toBe("color-grading");
133
- expect(value).toContain("fresh-pop");
246
+ expect(value).toContain("bright-pop");
247
+ act(() => root.unmount());
248
+ vi.useRealTimers();
249
+ });
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
+
134
327
  act(() => root.unmount());
135
328
  vi.useRealTimers();
136
329
  });
@@ -149,9 +342,9 @@ describe("useColorGradingController", () => {
149
342
  );
150
343
  const { root, getState } = renderHook(onSetAttributeLive);
151
344
  act(() => {
152
- getState().commitColorGrading(freshPopGrading());
345
+ getState().commitColorGrading(brightPopGrading());
153
346
  });
154
- expect(getState().grading.preset).toBe("fresh-pop");
347
+ expect(getState().grading.preset).toBe("bright-pop");
155
348
  act(() => {
156
349
  vi.advanceTimersByTime(400);
157
350
  });
@@ -170,9 +363,9 @@ describe("useColorGradingController", () => {
170
363
  const onSetAttributeLive = vi.fn().mockRejectedValue(new Error("disk full"));
171
364
  const { root, getState } = renderHook(onSetAttributeLive);
172
365
  act(() => {
173
- getState().commitColorGrading(freshPopGrading());
366
+ getState().commitColorGrading(brightPopGrading());
174
367
  });
175
- expect(getState().grading.preset).toBe("fresh-pop");
368
+ expect(getState().grading.preset).toBe("bright-pop");
176
369
  act(() => {
177
370
  vi.advanceTimersByTime(400);
178
371
  });
@@ -182,7 +375,7 @@ describe("useColorGradingController", () => {
182
375
  await Promise.resolve();
183
376
  });
184
377
  // Reverted to "neutral" (the last confirmed-good value, from before this
185
- // commit) instead of permanently showing "fresh-pop" as if it had saved.
378
+ // commit) instead of permanently showing "bright-pop" as if it had saved.
186
379
  expect(getState().grading.preset).toBe("neutral");
187
380
  expect(getState().runtimeStatus.state).toBe("unavailable");
188
381
  act(() => root.unmount());
@@ -206,7 +399,7 @@ describe("useColorGradingController", () => {
206
399
  makeElement({ id: "s1-bg" }),
207
400
  );
208
401
  act(() => {
209
- getState().commitColorGrading(freshPopGrading());
402
+ getState().commitColorGrading(brightPopGrading());
210
403
  });
211
404
  // Let the debounce fire while still on s1-bg — the persist call is now
212
405
  // genuinely in flight (its promise won't settle until resolveA() below).
@@ -243,7 +436,7 @@ describe("useColorGradingController", () => {
243
436
  let capturedOnSettledA: ((ok: boolean) => void) | undefined;
244
437
  const onSetAttributeLive = vi
245
438
  .fn()
246
- // Edit A (fresh-pop): captures its onSettled and never resolves until
439
+ // Edit A (bright-pop): captures its onSettled and never resolves until
247
440
  // resolveA() is called below — simulates a slow persist.
248
441
  .mockImplementationOnce(
249
442
  (_attr: string, _value: string | null, onSettled?: (ok: boolean) => void) => {
@@ -253,7 +446,7 @@ describe("useColorGradingController", () => {
253
446
  });
254
447
  },
255
448
  )
256
- // Edit B (natural-lift): 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);
@@ -263,7 +456,7 @@ describe("useColorGradingController", () => {
263
456
  const { root, getState } = renderHook(onSetAttributeLive);
264
457
 
265
458
  act(() => {
266
- getState().commitColorGrading(freshPopGrading());
459
+ getState().commitColorGrading(brightPopGrading());
267
460
  });
268
461
  act(() => {
269
462
  vi.advanceTimersByTime(400);
@@ -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(naturalLiftGrading());
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("natural-lift");
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("natural-lift");
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(freshPopGrading());
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
 
@@ -315,9 +520,9 @@ describe("useColorGradingController", () => {
315
520
  makeElement({ id: "s1-bg" }),
316
521
  );
317
522
  act(() => {
318
- getState().commitColorGrading(freshPopGrading());
523
+ getState().commitColorGrading(brightPopGrading());
319
524
  });
320
- expect(getState().grading.preset).toBe("fresh-pop");
525
+ expect(getState().grading.preset).toBe("bright-pop");
321
526
  // A different element, with no persisted grading of its own — without a
322
527
  // reset, this hook (unlike the legacy component it was extracted from,
323
528
  // which remounts via a `key={selectionIdentityKey}`) would keep showing
@@ -337,9 +542,9 @@ describe("useColorGradingController", () => {
337
542
  makeElement({ id: "bg", sourceFile: "index.html" }),
338
543
  );
339
544
  act(() => {
340
- getState().commitColorGrading(freshPopGrading());
545
+ getState().commitColorGrading(brightPopGrading());
341
546
  });
342
- expect(getState().grading.preset).toBe("fresh-pop");
547
+ expect(getState().grading.preset).toBe("bright-pop");
343
548
  rerenderWithElement(makeElement({ id: "bg", sourceFile: "sub-comp.html" }));
344
549
  expect(getState().grading.preset).toBe("neutral");
345
550
  act(() => root.unmount());
@@ -353,7 +558,7 @@ describe("useColorGradingController", () => {
353
558
  makeElement({ id: "s1-bg" }),
354
559
  );
355
560
  act(() => {
356
- getState().commitColorGrading(freshPopGrading());
561
+ getState().commitColorGrading(brightPopGrading());
357
562
  });
358
563
  // Switch selection before the 350ms debounce fires — the in-flight edit
359
564
  // must be written immediately (targeting the OUTGOING element's own
@@ -366,7 +571,7 @@ describe("useColorGradingController", () => {
366
571
  expect(onSetAttributeLive).toHaveBeenCalledTimes(1);
367
572
  const [attr, value] = onSetAttributeLive.mock.calls[0] as [string, string];
368
573
  expect(attr).toBe("color-grading");
369
- expect(value).toContain("fresh-pop");
574
+ expect(value).toContain("bright-pop");
370
575
  // And it must not ALSO fire again once the (now-cleared) original timer
371
576
  // window would have elapsed.
372
577
  act(() => {