@hyperframes/studio 0.7.72 → 0.7.74

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 (35) hide show
  1. package/dist/assets/{hyperframes-player-Bjf2HPzR.js → hyperframes-player-wiJqS2i-.js} +1 -1
  2. package/dist/assets/index-B37rWXo4.css +1 -0
  3. package/dist/assets/{index-Ct_pxETK.js → index-BXepgTJb.js} +1 -1
  4. package/dist/assets/index-CBDJuOGW.js +428 -0
  5. package/dist/assets/{index-Ch1hbJ3e.js → index-DGVNG1dd.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +4203 -2281
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  11. package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
  12. package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
  13. package/src/components/editor/propertyPanelColorCurveGraph.tsx +549 -0
  14. package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
  15. package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
  16. package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
  17. package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
  18. package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
  19. package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
  20. package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
  21. package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
  22. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
  23. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +168 -3
  24. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +184 -243
  25. package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
  26. package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
  27. package/src/components/editor/useColorGradingController.test.ts +133 -17
  28. package/src/components/editor/useColorGradingController.ts +11 -2
  29. package/src/components/editor/useColorGradingPreviews.ts +54 -11
  30. package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
  31. package/src/components/editor/useColorGradingScopes.ts +62 -0
  32. package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
  33. package/src/icons/SystemIcons.tsx +4 -0
  34. package/dist/assets/index-BgZMle9I.css +0 -1
  35. package/dist/assets/index-DCyWLHnx.js +0 -428
@@ -0,0 +1,116 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { clampNumber } from "../../utils/studioHelpers";
3
+
4
+ export function GradingNumberField({
5
+ label,
6
+ ariaLabel = label,
7
+ value,
8
+ min,
9
+ max,
10
+ disabled,
11
+ formatValue = String,
12
+ labelClassName = "min-w-0",
13
+ labelTextClassName = "block",
14
+ inputClassName = "w-full",
15
+ onBegin,
16
+ onPreview,
17
+ onSettle,
18
+ onCancel,
19
+ }: {
20
+ label: string;
21
+ ariaLabel?: string;
22
+ value: number;
23
+ min: number;
24
+ max: number;
25
+ disabled?: boolean;
26
+ formatValue?: (value: number) => string;
27
+ labelClassName?: string;
28
+ labelTextClassName?: string;
29
+ inputClassName?: string;
30
+ onBegin: () => void;
31
+ onPreview: (value: number) => void;
32
+ onSettle: () => void;
33
+ onCancel: () => void;
34
+ }) {
35
+ const [draft, setDraft] = useState(() => formatValue(value));
36
+ const focusedRef = useRef(false);
37
+ const cancelBlurRef = useRef(false);
38
+ const baselineRef = useRef(value);
39
+ const dirtyRef = useRef(false);
40
+
41
+ useEffect(() => {
42
+ if (!focusedRef.current) setDraft(formatValue(value));
43
+ }, [formatValue, value]);
44
+
45
+ const settle = () => {
46
+ focusedRef.current = false;
47
+ if (cancelBlurRef.current) {
48
+ cancelBlurRef.current = false;
49
+ return;
50
+ }
51
+ const parsed = Number(draft);
52
+ if (draft.trim() === "" || !Number.isFinite(parsed)) {
53
+ setDraft(formatValue(value));
54
+ if (dirtyRef.current) onCancel();
55
+ dirtyRef.current = false;
56
+ return;
57
+ }
58
+ const next = clampNumber(parsed, min, max);
59
+ setDraft(formatValue(next));
60
+ if (!dirtyRef.current || Object.is(next, baselineRef.current)) {
61
+ if (dirtyRef.current) onCancel();
62
+ dirtyRef.current = false;
63
+ return;
64
+ }
65
+ onPreview(next);
66
+ onSettle();
67
+ dirtyRef.current = false;
68
+ };
69
+
70
+ return (
71
+ <label className={labelClassName}>
72
+ <span className={labelTextClassName}>{label}</span>
73
+ <input
74
+ type="text"
75
+ inputMode="decimal"
76
+ aria-label={ariaLabel}
77
+ value={draft}
78
+ disabled={disabled}
79
+ onFocus={() => {
80
+ focusedRef.current = true;
81
+ baselineRef.current = value;
82
+ dirtyRef.current = false;
83
+ setDraft(formatValue(value));
84
+ }}
85
+ onChange={(event) => {
86
+ const next = event.target.value;
87
+ setDraft(next);
88
+ if (next.trim() === "") return;
89
+ const parsed = Number(next);
90
+ if (!Number.isFinite(parsed)) return;
91
+ const clamped = clampNumber(parsed, min, max);
92
+ if (!Object.is(clamped, baselineRef.current) && !dirtyRef.current) {
93
+ dirtyRef.current = true;
94
+ onBegin();
95
+ }
96
+ if (dirtyRef.current) onPreview(clamped);
97
+ }}
98
+ onBlur={settle}
99
+ onKeyDown={(event) => {
100
+ if (event.key === "Escape") {
101
+ event.preventDefault();
102
+ cancelBlurRef.current = true;
103
+ focusedRef.current = false;
104
+ setDraft(formatValue(value));
105
+ if (dirtyRef.current) onCancel();
106
+ dirtyRef.current = false;
107
+ event.currentTarget.blur();
108
+ } else if (event.key === "Enter") {
109
+ event.currentTarget.blur();
110
+ }
111
+ }}
112
+ className={`border-b border-panel-border-input/50 bg-transparent py-0.5 text-right font-mono text-[9px] text-panel-text-2 outline-none focus:border-panel-accent disabled:opacity-40 ${inputClassName}`}
113
+ />
114
+ </label>
115
+ );
116
+ }
@@ -56,22 +56,30 @@ function makeElement(overrides: Partial<DomEditSelection> = {}): DomEditSelectio
56
56
  } as DomEditSelection;
57
57
  }
58
58
 
59
+ type ApplyScope = (
60
+ scope: "source-file" | "project",
61
+ value: string | null,
62
+ ) => Promise<{ changedFiles: number; changedElements: number }>;
63
+
59
64
  function HookHost({
60
65
  onState,
61
66
  onSetAttributeLive,
62
67
  element,
63
68
  previewIframeRef,
69
+ onApplyScope,
64
70
  }: {
65
71
  onState: (state: ReturnType<typeof useColorGradingController>) => void;
66
72
  onSetAttributeLive: (attr: string, value: string | null) => void;
67
73
  element: DomEditSelection;
68
74
  previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
75
+ onApplyScope?: ApplyScope;
69
76
  }) {
70
77
  const state = useColorGradingController({
71
78
  projectId: "proj",
72
79
  element,
73
80
  previewIframeRef,
74
81
  onSetAttributeLive,
82
+ onApplyScope,
75
83
  });
76
84
  onState(state);
77
85
  return null;
@@ -81,6 +89,7 @@ function renderHook(
81
89
  onSetAttributeLive: (attr: string, value: string | null) => void,
82
90
  initialElement: DomEditSelection = makeElement(),
83
91
  previewIframeRef?: React.RefObject<HTMLIFrameElement | null>,
92
+ onApplyScope?: ApplyScope,
84
93
  ) {
85
94
  const host = document.createElement("div");
86
95
  document.body.append(host);
@@ -94,6 +103,7 @@ function renderHook(
94
103
  onSetAttributeLive,
95
104
  element,
96
105
  previewIframeRef,
106
+ onApplyScope,
97
107
  }),
98
108
  );
99
109
  });
@@ -129,6 +139,21 @@ function createPreviewFrame() {
129
139
  return { contentWindow, iframe };
130
140
  }
131
141
 
142
+ function installPreviewRenderer(contentWindow: PreviewWindow) {
143
+ const renderPreviews = vi
144
+ .fn()
145
+ .mockImplementation(async (_target: unknown, candidates: Array<{ id: string }>) => ({
146
+ width: 160,
147
+ height: 90,
148
+ images: candidates.map(({ id }) => ({
149
+ id,
150
+ dataUrl: `data:image/png;base64,${id}`,
151
+ })),
152
+ }));
153
+ contentWindow.__hf = { colorGrading: { renderPreviews } };
154
+ return renderPreviews;
155
+ }
156
+
132
157
  async function flushPreviewRequest() {
133
158
  act(() => vi.advanceTimersByTime(0));
134
159
  await act(async () => {
@@ -161,23 +186,37 @@ describe("useColorGradingController", () => {
161
186
  vi.useFakeTimers();
162
187
  const devicePixelRatio = vi.spyOn(window, "devicePixelRatio", "get").mockReturnValue(2);
163
188
  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" }],
189
+ const renderPreviews = installPreviewRenderer(contentWindow);
190
+ const initialElement = makeElement({
191
+ dataAttributes: {
192
+ "color-grading": JSON.stringify({
193
+ wheels: { shadows: { hue: 210, amount: 0.2 } },
194
+ effects: { pixelate: 0.4 },
195
+ palette: ["#112233", "#ffffff"],
196
+ }),
197
+ },
168
198
  });
169
- contentWindow.__hf = { colorGrading: { renderPreviews } };
170
- const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
199
+ const { root, getState } = renderHook(vi.fn(), initialElement, { current: iframe });
171
200
 
172
201
  act(() => getState().requestPresetPreviews());
173
202
  await flushPreviewRequest();
174
203
 
175
204
  expect(renderPreviews).toHaveBeenCalledTimes(1);
176
205
  expect(renderPreviews.mock.calls[0]?.[1]).toHaveLength(18);
206
+ expect(renderPreviews.mock.calls[0]?.[1]).toContainEqual({
207
+ id: "bright-pop",
208
+ grading: expect.objectContaining({
209
+ wheels: expect.objectContaining({
210
+ shadows: expect.objectContaining({ amount: 0 }),
211
+ }),
212
+ effects: expect.objectContaining({ pixelate: 0.4 }),
213
+ palette: ["#112233", "#ffffff"],
214
+ }),
215
+ });
177
216
  expect(renderPreviews.mock.calls[0]?.[2]).toEqual({ maxDimension: 320 });
178
217
  expect(getState().presetPreviews).toEqual({
179
218
  status: "ready",
180
- images: { "bright-pop": "data:image/png;base64,bright" },
219
+ images: expect.objectContaining({ "bright-pop": "data:image/png;base64,bright-pop" }),
181
220
  width: 160,
182
221
  height: 90,
183
222
  });
@@ -186,19 +225,96 @@ describe("useColorGradingController", () => {
186
225
  vi.useRealTimers();
187
226
  });
188
227
 
189
- it("requests exact effect families and retains earlier family images", async () => {
228
+ it("retains partial preset previews and exposes retry after the batch times out", async () => {
190
229
  vi.useFakeTimers();
191
230
  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
- }));
231
+ const renderPreviews = vi.fn().mockResolvedValue({
232
+ width: 160,
233
+ height: 90,
234
+ images: [{ id: "bright-pop", dataUrl: "data:image/png;base64,bright" }],
235
+ });
199
236
  contentWindow.__hf = { colorGrading: { renderPreviews } };
200
237
  const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
201
238
 
239
+ act(() => getState().requestPresetPreviews());
240
+ await flushPreviewRequest();
241
+ expect(getState().presetPreviews).toMatchObject({
242
+ status: "loading",
243
+ images: { "bright-pop": "data:image/png;base64,bright" },
244
+ });
245
+
246
+ await act(async () => {
247
+ await vi.advanceTimersByTimeAsync(1700);
248
+ });
249
+ expect(getState().presetPreviews).toMatchObject({
250
+ status: "unavailable",
251
+ images: { "bright-pop": "data:image/png;base64,bright" },
252
+ });
253
+ expect(renderPreviews.mock.calls.length).toBeGreaterThan(1);
254
+ act(() => root.unmount());
255
+ vi.useRealTimers();
256
+ });
257
+
258
+ it("persists a disabled secondary even though it does not render pixels", () => {
259
+ vi.useFakeTimers();
260
+ const onSetAttributeLive = vi.fn();
261
+ const grading = normalizeHfColorGrading({
262
+ secondaries: [
263
+ {
264
+ enabled: false,
265
+ key: { hue: { center: 215, range: 25 } },
266
+ correction: { saturation: 0.15 },
267
+ },
268
+ ],
269
+ });
270
+ if (!grading) throw new Error("expected secondary grading");
271
+ const { root, getState } = renderHook(onSetAttributeLive);
272
+
273
+ act(() => getState().commitColorGrading(grading));
274
+ act(() => vi.advanceTimersByTime(400));
275
+
276
+ expect(onSetAttributeLive.mock.calls[0]?.[0]).toBe("color-grading");
277
+ expect(onSetAttributeLive.mock.calls[0]?.[1]).toContain('"enabled":false');
278
+ act(() => root.unmount());
279
+ vi.useRealTimers();
280
+ });
281
+
282
+ it("copies a disabled authored secondary to a broader scope", async () => {
283
+ const onApplyScope = vi.fn<ApplyScope>().mockResolvedValue({
284
+ changedFiles: 1,
285
+ changedElements: 1,
286
+ });
287
+ const grading = {
288
+ secondaries: [
289
+ {
290
+ enabled: false,
291
+ key: { hue: { center: 215, range: 25 } },
292
+ correction: { saturation: 0.15 },
293
+ },
294
+ ],
295
+ };
296
+ const { root, getState } = renderHook(
297
+ vi.fn(),
298
+ makeElement({ dataAttributes: { "color-grading": JSON.stringify(grading) } }),
299
+ undefined,
300
+ onApplyScope,
301
+ );
302
+
303
+ await act(async () => getState().applyToScope());
304
+
305
+ expect(onApplyScope).toHaveBeenCalledWith(
306
+ "source-file",
307
+ expect.stringContaining('"enabled":false'),
308
+ );
309
+ act(() => root.unmount());
310
+ });
311
+
312
+ it("requests exact effect families and retains earlier family images", async () => {
313
+ vi.useFakeTimers();
314
+ const { contentWindow, iframe } = createPreviewFrame();
315
+ const renderPreviews = installPreviewRenderer(contentWindow);
316
+ const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
317
+
202
318
  act(() => getState().requestEffectPreviews(["blur", "pixelate", "bloom"]));
203
319
  await flushPreviewRequest();
204
320
 
@@ -490,7 +606,7 @@ describe("useColorGradingController", () => {
490
606
  vi.useRealTimers();
491
607
  });
492
608
 
493
- it("resetGrading resets Grade fields without clearing Effects or Palette", () => {
609
+ it("resetGrading resets Grade fields without clearing LUT, Effects, or Palette", () => {
494
610
  const { root, getState } = renderHook(vi.fn());
495
611
  const grading = normalizeHfColorGrading({
496
612
  preset: "bright-pop",
@@ -507,7 +623,7 @@ describe("useColorGradingController", () => {
507
623
  });
508
624
  expect(getState().grading).toMatchObject({
509
625
  preset: "neutral",
510
- lut: null,
626
+ lut: { src: "assets/luts/custom.cube", intensity: 0.6 },
511
627
  effects: { pixelate: 0.5 },
512
628
  palette: ["#112233", "#ffffff"],
513
629
  });
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
2
2
  import {
3
3
  HF_COLOR_GRADING_ATTR,
4
+ hasHfColorGradingAuthoredValues,
4
5
  isHfColorGradingActive,
5
6
  normalizeHfColorGrading,
6
7
  serializeHfColorGrading,
@@ -21,6 +22,7 @@ import {
21
22
  } from "../../player/lib/runtimeProtocol";
22
23
  import {
23
24
  useColorGradingPreviews,
25
+ type ColorGradingCapturedFrame,
24
26
  type ColorGradingPresetPreviews,
25
27
  type ColorGradingPreviewOptions,
26
28
  } from "./useColorGradingPreviews";
@@ -169,6 +171,9 @@ export interface ColorGradingControllerState {
169
171
  next: NormalizedHfColorGrading | null,
170
172
  options?: ColorGradingPreviewOptions,
171
173
  ) => void;
174
+ captureGradedFrame: (options?: {
175
+ grading?: NormalizedHfColorGrading;
176
+ }) => Promise<ColorGradingCapturedFrame | null>;
172
177
  commitCompare: (enabled: boolean) => void;
173
178
  setApplyScope: (scope: "source-file" | "project") => void;
174
179
  applyToScope: () => Promise<void>;
@@ -472,7 +477,7 @@ export function useColorGradingController({
472
477
  }
473
478
  scheduleRuntimeStatusRefresh();
474
479
  if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
475
- pendingPersistValueRef.current = isHfColorGradingActive(nextGrading)
480
+ pendingPersistValueRef.current = hasHfColorGradingAuthoredValues(nextGrading)
476
481
  ? serializeHfColorGrading(nextGrading)
477
482
  : null;
478
483
  pendingPersistGradingRef.current = nextGrading;
@@ -519,7 +524,9 @@ export function useColorGradingController({
519
524
  if (!onApplyScope || applyBusy) return;
520
525
  setApplyBusy(true);
521
526
  try {
522
- const value = isHfColorGradingActive(grading) ? serializeHfColorGrading(grading) : null;
527
+ const value = hasHfColorGradingAuthoredValues(grading)
528
+ ? serializeHfColorGrading(grading)
529
+ : null;
523
530
  await onApplyScope(applyScope, value);
524
531
  } finally {
525
532
  setApplyBusy(false);
@@ -539,6 +546,7 @@ export function useColorGradingController({
539
546
  requestEffectPreviews: previewController.requestEffectPreviews,
540
547
  commitColorGrading,
541
548
  previewColorGrading: previewController.previewColorGrading,
549
+ captureGradedFrame: previewController.captureGradedFrame,
542
550
  commitCompare,
543
551
  setApplyScope,
544
552
  applyToScope,
@@ -546,6 +554,7 @@ export function useColorGradingController({
546
554
  const neutral = defaultColorGrading();
547
555
  commitColorGrading({
548
556
  ...neutral,
557
+ lut: latestGradingRef.current.lut,
549
558
  effects: latestGradingRef.current.effects,
550
559
  palette: latestGradingRef.current.palette,
551
560
  });
@@ -16,6 +16,12 @@ export interface ColorGradingPresetPreviews {
16
16
  height: number;
17
17
  }
18
18
 
19
+ export interface ColorGradingCapturedFrame {
20
+ dataUrl: string;
21
+ width: number;
22
+ height: number;
23
+ }
24
+
19
25
  type ColorGradingPreviewKind = "presets" | "effects";
20
26
 
21
27
  export interface ColorGradingPreviewOptions {
@@ -78,12 +84,21 @@ function toPreviewColorGrading(grading: NormalizedHfColorGrading): unknown {
78
84
 
79
85
  function previewCandidates(
80
86
  request: PreviewRequest,
81
- lut: NormalizedHfColorGrading["lut"],
87
+ grading: Pick<NormalizedHfColorGrading, "effects" | "lut" | "palette">,
82
88
  ): Array<{ id: string; grading: unknown }> {
83
89
  if (request.kind === "presets") {
84
90
  return HF_COLOR_GRADING_PRESETS.map((preset) => {
85
- const resolved = normalizeHfColorGrading({ preset: preset.id, lut });
86
- return { id: preset.id, grading: resolved ? toPreviewColorGrading(resolved) : null };
91
+ const resolved = normalizeHfColorGrading({ preset: preset.id, lut: grading.lut });
92
+ return {
93
+ id: preset.id,
94
+ grading: resolved
95
+ ? toPreviewColorGrading({
96
+ ...resolved,
97
+ effects: grading.effects,
98
+ palette: grading.palette,
99
+ })
100
+ : null,
101
+ };
87
102
  });
88
103
  }
89
104
  return (request.effects ?? HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS).map((effect) => {
@@ -98,16 +113,19 @@ async function renderRequestedPreviews(
98
113
  runtime: RuntimeColorGradingPreview,
99
114
  target: HfColorGradingTarget,
100
115
  request: PreviewRequest,
101
- lut: NormalizedHfColorGrading["lut"],
116
+ grading: Pick<NormalizedHfColorGrading, "effects" | "lut" | "palette">,
102
117
  ) {
103
- const batch = await runtime.renderPreviews(target, previewCandidates(request, lut), {
118
+ const candidates = previewCandidates(request, grading);
119
+ const batch = await runtime.renderPreviews(target, candidates, {
104
120
  maxDimension: previewMaxDimension(),
105
121
  });
106
122
  if (!batch) return null;
107
123
  const images = Object.fromEntries(
108
124
  batch.images.flatMap((image) => (image.dataUrl ? [[image.id, image.dataUrl]] : [])),
109
125
  );
110
- return Object.keys(images).length ? { ...batch, images } : null;
126
+ return Object.keys(images).length
127
+ ? { ...batch, images, complete: Object.keys(images).length === candidates.length }
128
+ : null;
111
129
  }
112
130
 
113
131
  export function useColorGradingPreviews({
@@ -154,6 +172,7 @@ export function useColorGradingPreviews({
154
172
  let cancelled = false;
155
173
  let complete = false;
156
174
  let inFlight = false;
175
+ let timedOut = false;
157
176
  const timers: number[] = [];
158
177
  setState((current) => ({
159
178
  ...current,
@@ -169,15 +188,19 @@ export function useColorGradingPreviews({
169
188
  if (!runtime) return;
170
189
  inFlight = true;
171
190
  try {
172
- const result = await renderRequestedPreviews(runtime, target, request, grading.lut);
191
+ const result = await renderRequestedPreviews(runtime, target, request, {
192
+ effects: grading.effects,
193
+ lut: grading.lut,
194
+ palette: grading.palette,
195
+ });
173
196
  if (cancelled || !result) return;
174
- complete = true;
197
+ complete = result.complete;
175
198
  setState((current) => ({
176
199
  ...current,
177
200
  previews: {
178
201
  ...current.previews,
179
202
  [kind]: {
180
- status: "ready",
203
+ status: result.complete ? "ready" : timedOut ? "unavailable" : "loading",
181
204
  images:
182
205
  kind === "effects"
183
206
  ? { ...current.previews[kind].images, ...result.images }
@@ -206,11 +229,12 @@ export function useColorGradingPreviews({
206
229
  timers.push(
207
230
  window.setTimeout(() => {
208
231
  if (cancelled || complete) return;
232
+ timedOut = true;
209
233
  setState((current) => ({
210
234
  ...current,
211
235
  previews: {
212
236
  ...current.previews,
213
- [kind]: { status: "unavailable", images: {}, width: 16, height: 9 },
237
+ [kind]: { ...current.previews[kind], status: "unavailable" },
214
238
  },
215
239
  }));
216
240
  }, 1600),
@@ -221,7 +245,7 @@ export function useColorGradingPreviews({
221
245
  iframe.removeEventListener("load", attempt);
222
246
  window.removeEventListener("message", onMessage);
223
247
  };
224
- }, [grading.lut, previewIframeRef, request, target]);
248
+ }, [grading.effects, grading.lut, grading.palette, previewIframeRef, request, target]);
225
249
 
226
250
  const stopAnimatedPreview = useCallback(() => {
227
251
  const session = animatedRef.current;
@@ -296,6 +320,24 @@ export function useColorGradingPreviews({
296
320
  (effects: readonly HfColorGradingActiveEffectKey[]) => requestPreviews("effects", effects),
297
321
  [requestPreviews],
298
322
  );
323
+ const captureGradedFrame = useCallback(
324
+ async ({
325
+ grading: requestedGrading = gradingRef.current,
326
+ }: {
327
+ grading?: NormalizedHfColorGrading;
328
+ } = {}): Promise<ColorGradingCapturedFrame | null> => {
329
+ const runtime = readRuntime(previewIframeRef?.current);
330
+ if (!runtime) return null;
331
+ const batch = await runtime.renderPreviews(
332
+ target,
333
+ [{ id: "capture", grading: toPreviewColorGrading(requestedGrading) }],
334
+ { maxDimension: 320, useMediaTime: true },
335
+ );
336
+ const dataUrl = batch?.images[0]?.dataUrl;
337
+ return batch && dataUrl ? { dataUrl, width: batch.width, height: batch.height } : null;
338
+ },
339
+ [gradingRef, previewIframeRef, target],
340
+ );
299
341
 
300
342
  return {
301
343
  presetPreviews: previews.presets,
@@ -303,5 +345,6 @@ export function useColorGradingPreviews({
303
345
  requestPresetPreviews,
304
346
  requestEffectPreviews,
305
347
  previewColorGrading,
348
+ captureGradedFrame,
306
349
  };
307
350
  }