@hyperframes/studio 0.7.71 → 0.7.73

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 (79) hide show
  1. package/dist/assets/{hyperframes-player-C1X90psg.js → hyperframes-player-DGlzhf_s.js} +1 -1
  2. package/dist/assets/index-B37rWXo4.css +1 -0
  3. package/dist/assets/{index-DzxDHPR1.js → index-CbVTOJ-E.js} +1 -1
  4. package/dist/assets/{index-nY4lLBqM.js → index-CcA5WVJv.js} +1 -1
  5. package/dist/assets/index-DXcLI2wu.js +428 -0
  6. package/dist/index.d.ts +8 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7762 -4261
  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 +75 -88
  22. package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
  23. package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
  24. package/src/components/editor/colorGradingScopePatch.test.ts +3 -3
  25. package/src/components/editor/easeCurveSvg.tsx +65 -0
  26. package/src/components/editor/easePresetLibrary.test.ts +114 -0
  27. package/src/components/editor/easePresetLibrary.ts +49 -0
  28. package/src/components/editor/gsapAnimationConstants.ts +15 -13
  29. package/src/components/editor/propertyPanelColorCurveGraph.tsx +549 -0
  30. package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
  31. package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
  32. package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
  33. package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
  34. package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
  35. package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
  36. package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
  37. package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
  38. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
  39. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +239 -41
  40. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +260 -300
  41. package/src/components/editor/propertyPanelFlatEffectControl.tsx +96 -0
  42. package/src/components/editor/propertyPanelFlatEffectSpecs.ts +305 -0
  43. package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +331 -0
  44. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +503 -0
  45. package/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx +113 -0
  46. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +108 -0
  47. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -0
  48. package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
  49. package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
  50. package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
  51. package/src/components/editor/propertyPanelTypes.ts +13 -0
  52. package/src/components/editor/useColorGradingController.test.ts +331 -10
  53. package/src/components/editor/useColorGradingController.ts +57 -90
  54. package/src/components/editor/useColorGradingPreviews.ts +350 -0
  55. package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
  56. package/src/components/editor/useColorGradingScopes.ts +62 -0
  57. package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
  58. package/src/components/nle/NLEContext.tsx +1 -1
  59. package/src/contexts/PanelLayoutContext.tsx +3 -6
  60. package/src/hooks/useBlockCatalog.ts +31 -13
  61. package/src/hooks/useBlockHandlers.ts +22 -0
  62. package/src/hooks/useGsapScriptCommits.test.tsx +15 -0
  63. package/src/hooks/useGsapScriptCommits.ts +14 -1
  64. package/src/hooks/usePanelLayout.test.ts +68 -1
  65. package/src/hooks/usePanelLayout.ts +72 -34
  66. package/src/icons/SystemIcons.tsx +4 -0
  67. package/src/player/components/PlayerControls.tsx +68 -229
  68. package/src/player/components/ShortcutsPanel.tsx +2 -4
  69. package/src/player/components/SpeedMenu.tsx +1 -2
  70. package/src/telemetry/events.test.ts +9 -0
  71. package/src/telemetry/events.ts +5 -0
  72. package/src/utils/blockInstaller.test.ts +67 -0
  73. package/src/utils/blockInstaller.ts +162 -109
  74. package/src/utils/studioUiPreferences.test.ts +5 -0
  75. package/src/utils/studioUiPreferences.ts +8 -0
  76. package/dist/assets/index-CSCh2Vrp.js +0 -428
  77. package/dist/assets/index-Ceyz8Qt2.css +0 -1
  78. package/src/player/components/PlayerControls.test.ts +0 -20
  79. package/src/player/components/useSeekBarDrag.ts +0 -169
@@ -1,9 +1,11 @@
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,
8
+ type HfColorGradingActiveEffectKey,
7
9
  type HfColorGradingTarget,
8
10
  type NormalizedHfColorGrading,
9
11
  } from "@hyperframes/core/color-grading";
@@ -18,6 +20,14 @@ import {
18
20
  acceptStudioRuntimeMessage,
19
21
  postRuntimeControlMessage,
20
22
  } from "../../player/lib/runtimeProtocol";
23
+ import {
24
+ useColorGradingPreviews,
25
+ type ColorGradingCapturedFrame,
26
+ type ColorGradingPresetPreviews,
27
+ type ColorGradingPreviewOptions,
28
+ } from "./useColorGradingPreviews";
29
+
30
+ export type { ColorGradingPresetPreviews, ColorGradingPreviewOptions };
21
31
 
22
32
  const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, "");
23
33
  const RUNTIME_STATUS_REFRESH_DELAYS = [50, 250, 1000, 2500] as const;
@@ -152,7 +162,18 @@ export interface ColorGradingControllerState {
152
162
  applyBusy: boolean;
153
163
  runtimeStatus: RuntimeColorGradingStatus;
154
164
  mediaMetadata: MediaMetadata | null;
165
+ presetPreviews: ColorGradingPresetPreviews;
166
+ effectPreviews: ColorGradingPresetPreviews;
167
+ requestPresetPreviews: () => void;
168
+ requestEffectPreviews: (effects: readonly HfColorGradingActiveEffectKey[]) => void;
155
169
  commitColorGrading: (next: NormalizedHfColorGrading) => void;
170
+ previewColorGrading: (
171
+ next: NormalizedHfColorGrading | null,
172
+ options?: ColorGradingPreviewOptions,
173
+ ) => void;
174
+ captureGradedFrame: (options?: {
175
+ grading?: NormalizedHfColorGrading;
176
+ }) => Promise<ColorGradingCapturedFrame | null>;
156
177
  commitCompare: (enabled: boolean) => void;
157
178
  setApplyScope: (scope: "source-file" | "project") => void;
158
179
  applyToScope: () => Promise<void>;
@@ -195,20 +216,11 @@ export function useColorGradingController({
195
216
  const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
196
217
  const pendingPersistValueRef = useRef<string | null | undefined>(undefined);
197
218
  const pendingPersistGradingRef = useRef<NormalizedHfColorGrading | null>(null);
198
- // Identity the pending edit was made FOR, snapshotted at schedule time —
199
- // read by a global flush instead of identityKeyRef.current, which may no
200
- // longer describe this edit's element by the time the flush runs.
219
+ // Capture the edited element because selection can change before persistence.
201
220
  const pendingPersistIdentityRef = useRef<string | null>(null);
202
- // The last grading value actually confirmed saved distinct from `grading`
203
- // (the optimistic value shown immediately on commit). A rejected persist
204
- // reverts to this instead of leaving the UI permanently showing a value
205
- // that was never written.
221
+ // Keep a confirmed value so an optimistic edit can be reverted on failure.
206
222
  const confirmedGradingRef = useRef(grading);
207
- // Monotonic per-commit version guards against TWO edits on the SAME
208
- // element racing (not just a selection change). If edit A's persist is
209
- // still in flight when edit B commits, A's eventual settle must not stamp
210
- // confirmedGradingRef with its now-superseded value or revert `grading`
211
- // out from under B's newer optimistic state.
223
+ // Prevent an older same-element write from settling over a newer edit.
212
224
  const gradingVersionRef = useRef(0);
213
225
  const statusTimersRef = useRef<number[]>([]);
214
226
  const latestGradingRef = useRef(grading);
@@ -216,22 +228,7 @@ export function useColorGradingController({
216
228
  latestGradingRef.current = grading;
217
229
  compareEnabledRef.current = compareEnabled;
218
230
 
219
- // Reset all per-element STATE when the selection changes to a different
220
- // element — unlike the legacy ColorGradingSection (remounted via a
221
- // `key={selectionIdentityKey(element)}` from its parent), this hook is
222
- // called unconditionally on every render, so nothing naturally remounts it.
223
- // Without this, switching selection reuses the previous element's grading/
224
- // compare/mediaMetadata state. Adjusting state during render (comparing
225
- // against a ref) is React's documented pattern for STATE updates
226
- // specifically — it resolves in the same render pass instead of flashing
227
- // stale state for one frame, and is safe to repeat if React discards and
228
- // re-runs this render, since every value here is a pure function of
229
- // `element`. It must NOT be used for side effects or for consuming
230
- // shared mutable state (like the pending-persist timer/value) — a
231
- // discarded render would have already consumed them with no corresponding
232
- // effect ever running to compensate. That part happens below, in an
233
- // effect's cleanup, which is guaranteed to run only for a render that
234
- // actually committed.
231
+ // Reset derived state during render to avoid flashing the previous selection.
235
232
  const identityKey = selectionIdentityKey(element);
236
233
  const identityKeyRef = useRef(identityKey);
237
234
  if (identityKeyRef.current !== identityKey) {
@@ -248,22 +245,9 @@ export function useColorGradingController({
248
245
  setMediaMetadata(null);
249
246
  }
250
247
 
251
- // Flushes never discards a still-pending edit when selection moves to
252
- // a different element, and cancels the debounce timer. Implemented as an
253
- // effect CLEANUP (not the render-phase block above, and not a queued ref
254
- // consumed by a separate effect): a cleanup only ever runs for the
255
- // specific effect instance that actually committed for `identityKey`, so
256
- // there's no window where a discarded/interrupted render could have
257
- // already consumed pendingPersistValueRef without this ever running to
258
- // compensate. The cleanup's closure captures onSetAttributeLive/target
259
- // bound to the OUTGOING identity, since it runs before the next effect
260
- // instance (for the NEW identity) is established.
248
+ // Flush a pending write through the outgoing selection's committed callback.
261
249
  useEffect(() => {
262
250
  return () => {
263
- // Stale runtime-status timers scheduled for the OUTGOING element must
264
- // not fire after this: readRuntimeColorGradingStatus closes over
265
- // `target`, so an old timer firing post-switch would stamp the NEW
266
- // element's runtimeStatus with the OLD element's answer.
267
251
  for (const timer of statusTimersRef.current) clearTimeout(timer);
268
252
  statusTimersRef.current = [];
269
253
  if (persistTimerRef.current) {
@@ -316,11 +300,7 @@ export function useColorGradingController({
316
300
  })
317
301
  .then((result) => {
318
302
  if (controller.signal.aborted) return;
319
- // Only cache a definitive answer from a successful response — a non-OK
320
- // status is a transient/server failure, not a stable "no metadata"
321
- // result, and caching it would suppress the HDR banner for this asset
322
- // for the page's whole lifetime. Leave the key absent so the next
323
- // selection retries.
303
+ // Cache only successful responses so transient failures remain retryable.
324
304
  if (!result.ok) {
325
305
  setMediaMetadata(null);
326
306
  return;
@@ -329,8 +309,6 @@ export function useColorGradingController({
329
309
  setMediaMetadata(result.metadata);
330
310
  })
331
311
  .catch(() => {
332
- // Same reasoning as the non-OK branch above: don't cache a network-
333
- // level fetch failure either.
334
312
  if (!controller.signal.aborted) setMediaMetadata(null);
335
313
  });
336
314
  return () => controller.abort();
@@ -364,13 +342,6 @@ export function useColorGradingController({
364
342
  onSettled?: (ok: boolean) => void,
365
343
  ) => void | Promise<void>,
366
344
  ) => {
367
- // Two guards, not one: identity (selection moved to a DIFFERENT
368
- // element — that element already got its own confirmedGradingRef
369
- // baseline from the reset block) and version (a NEWER edit landed on
370
- // the SAME element — e.g. the user dragged Exposure, then Contrast,
371
- // before Exposure's persist settled; Exposure settling afterward must
372
- // not stamp confirmedGradingRef with its now-superseded value or
373
- // revert `grading` out from under Contrast's newer optimistic state).
374
345
  const applySettled = (ok: boolean) => {
375
346
  if (identityKeyRef.current !== attemptIdentityKey) return;
376
347
  if (!isLatestAttempt()) return;
@@ -378,25 +349,13 @@ export function useColorGradingController({
378
349
  confirmedGradingRef.current = attemptedGrading;
379
350
  return;
380
351
  }
381
- // Persist failed — the optimistic grading was never actually saved.
382
- // Revert to the last confirmed-good value instead of leaving the
383
- // control showing an unsaved state as if it succeeded.
384
352
  const reverted = confirmedGradingRef.current;
385
353
  latestGradingRef.current = reverted;
386
354
  setGrading(reverted);
387
355
  setRuntimeStatus({ state: "unavailable", message: "Save failed — reverted" });
388
356
  };
389
- // `onSettled` is the real signal the underlying commit runner
390
- // (runDomEditCommit) intentionally swallows persist failures so a
391
- // caller `await`-ing this promise never sees a rejection; a rejection
392
- // handler here alone would be dead code against the actual Studio
393
- // callback. The `.catch` below is a fallback for any OTHER
394
- // implementation of onSetAttributeLive that rejects instead.
395
- // `setAttributeLive` is passed in by the caller rather than read from a
396
- // ref: a debounced persist for element A must call the callback that
397
- // was live when A's edit was SCHEDULED, not whatever the ref holds by
398
- // the time the timer fires — a "latest" ref would let a stale timer
399
- // wrongly target a since-selected element B's callback.
357
+ // The callback is the primary result signal; the rejection path supports
358
+ // other setAttributeLive implementations.
400
359
  const result = setAttributeLive(COLOR_GRADING_DATA_KEY, value ?? null, applySettled);
401
360
  return trackStudioPendingEdit(
402
361
  Promise.resolve(result).then(
@@ -416,20 +375,10 @@ export function useColorGradingController({
416
375
  if (pendingPersistValueRef.current === undefined) return undefined;
417
376
  const value = pendingPersistValueRef.current;
418
377
  const attemptedGrading = pendingPersistGradingRef.current ?? latestGradingRef.current;
419
- // Snapshotted at schedule time, not identityKeyRef.current read fresh
420
- // here — an async flush could otherwise tag the attempt with whatever
421
- // identity is "current" by then, not the one this edit was made for.
422
378
  const attemptIdentityKey = pendingPersistIdentityRef.current ?? identityKeyRef.current;
423
379
  pendingPersistValueRef.current = undefined;
424
380
  pendingPersistGradingRef.current = null;
425
381
  pendingPersistIdentityRef.current = null;
426
- // A flush cancels the pending debounce timer above, so this becomes the
427
- // one-and-only in-flight attempt for this element — bump the version so
428
- // it still registers as "the latest attempt" against the same guard a
429
- // regular debounced commit uses, instead of unconditionally claiming
430
- // that title. Without this, a newer edit landing after the flush starts
431
- // but before it settles would have its own eventual settle silently
432
- // lose the version race to this unconditionally-"latest" flush.
433
382
  const isLatestAttempt = bumpDomEditCommitVersion(gradingVersionRef);
434
383
  return persistColorGradingValue(
435
384
  value,
@@ -458,6 +407,14 @@ export function useColorGradingController({
458
407
  },
459
408
  [previewIframeRef, target],
460
409
  );
410
+ const previewController = useColorGradingPreviews({
411
+ grading,
412
+ gradingRef: latestGradingRef,
413
+ identityKey,
414
+ target,
415
+ previewIframeRef,
416
+ postColorGrading,
417
+ });
461
418
 
462
419
  const postCompare = useCallback(
463
420
  (enabled: boolean) => {
@@ -509,6 +466,7 @@ export function useColorGradingController({
509
466
 
510
467
  const commitColorGrading = useCallback(
511
468
  (nextGrading: NormalizedHfColorGrading) => {
469
+ latestGradingRef.current = nextGrading;
512
470
  setGrading(nextGrading);
513
471
  setRuntimeStatus({ state: "pending", message: "Updating shader" });
514
472
  postColorGrading(nextGrading);
@@ -519,19 +477,12 @@ export function useColorGradingController({
519
477
  }
520
478
  scheduleRuntimeStatusRefresh();
521
479
  if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
522
- pendingPersistValueRef.current = isHfColorGradingActive(nextGrading)
480
+ pendingPersistValueRef.current = hasHfColorGradingAuthoredValues(nextGrading)
523
481
  ? serializeHfColorGrading(nextGrading)
524
482
  : null;
525
483
  pendingPersistGradingRef.current = nextGrading;
526
484
  pendingPersistIdentityRef.current = identityKeyRef.current;
527
- // Captured now (edit time), not read fresh inside the timer — the
528
- // timer fires 350ms later and may run after selection has already
529
- // moved on, at which point identityKeyRef.current would no longer
530
- // describe the element this edit was actually made for.
531
485
  const attemptIdentityKey = identityKeyRef.current;
532
- // Bumps a monotonic version and hands back a checker bound to THIS
533
- // specific commit — reused from the same primitive the DOM-attribute
534
- // commit runner uses for the identical same-target-rapid-edits race.
535
486
  const isLatestAttempt = bumpDomEditCommitVersion(gradingVersionRef);
536
487
  persistTimerRef.current = setTimeout(() => {
537
488
  const value = pendingPersistValueRef.current;
@@ -573,7 +524,9 @@ export function useColorGradingController({
573
524
  if (!onApplyScope || applyBusy) return;
574
525
  setApplyBusy(true);
575
526
  try {
576
- const value = isHfColorGradingActive(grading) ? serializeHfColorGrading(grading) : null;
527
+ const value = hasHfColorGradingAuthoredValues(grading)
528
+ ? serializeHfColorGrading(grading)
529
+ : null;
577
530
  await onApplyScope(applyScope, value);
578
531
  } finally {
579
532
  setApplyBusy(false);
@@ -587,10 +540,24 @@ export function useColorGradingController({
587
540
  applyBusy,
588
541
  runtimeStatus,
589
542
  mediaMetadata,
543
+ presetPreviews: previewController.presetPreviews,
544
+ effectPreviews: previewController.effectPreviews,
545
+ requestPresetPreviews: previewController.requestPresetPreviews,
546
+ requestEffectPreviews: previewController.requestEffectPreviews,
590
547
  commitColorGrading,
548
+ previewColorGrading: previewController.previewColorGrading,
549
+ captureGradedFrame: previewController.captureGradedFrame,
591
550
  commitCompare,
592
551
  setApplyScope,
593
552
  applyToScope,
594
- resetGrading: () => commitColorGrading(defaultColorGrading()),
553
+ resetGrading: () => {
554
+ const neutral = defaultColorGrading();
555
+ commitColorGrading({
556
+ ...neutral,
557
+ lut: latestGradingRef.current.lut,
558
+ effects: latestGradingRef.current.effects,
559
+ palette: latestGradingRef.current.palette,
560
+ });
561
+ },
595
562
  };
596
563
  }
@@ -0,0 +1,350 @@
1
+ import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
2
+ import {
3
+ HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS,
4
+ HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS,
5
+ HF_COLOR_GRADING_PRESETS,
6
+ normalizeHfColorGrading,
7
+ type HfColorGradingActiveEffectKey,
8
+ type HfColorGradingTarget,
9
+ type NormalizedHfColorGrading,
10
+ } from "@hyperframes/core/color-grading";
11
+
12
+ export interface ColorGradingPresetPreviews {
13
+ status: "idle" | "loading" | "ready" | "unavailable";
14
+ images: Record<string, string>;
15
+ width: number;
16
+ height: number;
17
+ }
18
+
19
+ export interface ColorGradingCapturedFrame {
20
+ dataUrl: string;
21
+ width: number;
22
+ height: number;
23
+ }
24
+
25
+ type ColorGradingPreviewKind = "presets" | "effects";
26
+
27
+ export interface ColorGradingPreviewOptions {
28
+ animatedPreview: { kind: ColorGradingPreviewKind; id: string };
29
+ }
30
+
31
+ type RuntimeColorGradingPreview = {
32
+ renderPreviews: (
33
+ target: HfColorGradingTarget,
34
+ candidates: Array<{ id: string; grading: unknown }>,
35
+ options: { maxDimension: number; useMediaTime?: boolean },
36
+ ) => Promise<{
37
+ width: number;
38
+ height: number;
39
+ images: Array<{ id: string; dataUrl: string | null }>;
40
+ } | null>;
41
+ startPreviewPlayback?: (target: HfColorGradingTarget) => (() => void) | null;
42
+ };
43
+
44
+ type PreviewRequest = {
45
+ kind: ColorGradingPreviewKind;
46
+ version: number;
47
+ effects?: readonly HfColorGradingActiveEffectKey[];
48
+ };
49
+
50
+ const emptyPreviews = (): Record<ColorGradingPreviewKind, ColorGradingPresetPreviews> => ({
51
+ presets: { status: "idle", images: {}, width: 16, height: 9 },
52
+ effects: { status: "idle", images: {}, width: 16, height: 9 },
53
+ });
54
+
55
+ function readRuntime(
56
+ iframe: HTMLIFrameElement | null | undefined,
57
+ ): RuntimeColorGradingPreview | null {
58
+ try {
59
+ const runtime = (
60
+ iframe?.contentWindow as
61
+ | (Window & { __hf?: { colorGrading?: Partial<RuntimeColorGradingPreview> } })
62
+ | null
63
+ | undefined
64
+ )?.__hf?.colorGrading;
65
+ return runtime?.renderPreviews
66
+ ? {
67
+ renderPreviews: runtime.renderPreviews,
68
+ startPreviewPlayback: runtime.startPreviewPlayback,
69
+ }
70
+ : null;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function previewMaxDimension(): number {
77
+ return Math.min(320, Math.ceil(160 * Math.max(1, window.devicePixelRatio || 1)));
78
+ }
79
+
80
+ function toPreviewColorGrading(grading: NormalizedHfColorGrading): unknown {
81
+ const { enabled: _enabled, ...previewGrading } = grading;
82
+ return previewGrading;
83
+ }
84
+
85
+ function previewCandidates(
86
+ request: PreviewRequest,
87
+ grading: Pick<NormalizedHfColorGrading, "effects" | "lut" | "palette">,
88
+ ): Array<{ id: string; grading: unknown }> {
89
+ if (request.kind === "presets") {
90
+ return HF_COLOR_GRADING_PRESETS.map((preset) => {
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
+ };
102
+ });
103
+ }
104
+ return (request.effects ?? HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS).map((effect) => {
105
+ const resolved = normalizeHfColorGrading({
106
+ effects: HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS[effect],
107
+ });
108
+ return { id: effect, grading: resolved ? toPreviewColorGrading(resolved) : null };
109
+ });
110
+ }
111
+
112
+ async function renderRequestedPreviews(
113
+ runtime: RuntimeColorGradingPreview,
114
+ target: HfColorGradingTarget,
115
+ request: PreviewRequest,
116
+ grading: Pick<NormalizedHfColorGrading, "effects" | "lut" | "palette">,
117
+ ) {
118
+ const candidates = previewCandidates(request, grading);
119
+ const batch = await runtime.renderPreviews(target, candidates, {
120
+ maxDimension: previewMaxDimension(),
121
+ });
122
+ if (!batch) return null;
123
+ const images = Object.fromEntries(
124
+ batch.images.flatMap((image) => (image.dataUrl ? [[image.id, image.dataUrl]] : [])),
125
+ );
126
+ return Object.keys(images).length
127
+ ? { ...batch, images, complete: Object.keys(images).length === candidates.length }
128
+ : null;
129
+ }
130
+
131
+ export function useColorGradingPreviews({
132
+ grading,
133
+ gradingRef,
134
+ identityKey,
135
+ target,
136
+ previewIframeRef,
137
+ postColorGrading,
138
+ }: {
139
+ grading: NormalizedHfColorGrading;
140
+ gradingRef: RefObject<NormalizedHfColorGrading>;
141
+ identityKey: string;
142
+ target: HfColorGradingTarget;
143
+ previewIframeRef?: RefObject<HTMLIFrameElement | null>;
144
+ postColorGrading: (grading: NormalizedHfColorGrading) => void;
145
+ }) {
146
+ const [state, setState] = useState(() => ({
147
+ identityKey,
148
+ previews: emptyPreviews(),
149
+ }));
150
+ const [request, setRequest] = useState<PreviewRequest | null>(null);
151
+ const animatedRef = useRef<{ stopPlayback: () => void; timer: number | null } | null>(null);
152
+ if (state.identityKey !== identityKey) {
153
+ setState({ identityKey, previews: emptyPreviews() });
154
+ setRequest(null);
155
+ }
156
+ const previews = state.identityKey === identityKey ? state.previews : emptyPreviews();
157
+
158
+ useEffect(() => {
159
+ if (!request) return;
160
+ const { kind } = request;
161
+ const iframe = previewIframeRef?.current;
162
+ if (!iframe) {
163
+ setState((current) => ({
164
+ ...current,
165
+ previews: {
166
+ ...current.previews,
167
+ [kind]: { status: "unavailable", images: {}, width: 16, height: 9 },
168
+ },
169
+ }));
170
+ return;
171
+ }
172
+ let cancelled = false;
173
+ let complete = false;
174
+ let inFlight = false;
175
+ let timedOut = false;
176
+ const timers: number[] = [];
177
+ setState((current) => ({
178
+ ...current,
179
+ previews: {
180
+ ...current.previews,
181
+ [kind]: { ...current.previews[kind], status: "loading" },
182
+ },
183
+ }));
184
+
185
+ const attempt = async () => {
186
+ if (cancelled || complete || inFlight) return;
187
+ const runtime = readRuntime(iframe);
188
+ if (!runtime) return;
189
+ inFlight = true;
190
+ try {
191
+ const result = await renderRequestedPreviews(runtime, target, request, {
192
+ effects: grading.effects,
193
+ lut: grading.lut,
194
+ palette: grading.palette,
195
+ });
196
+ if (cancelled || !result) return;
197
+ complete = result.complete;
198
+ setState((current) => ({
199
+ ...current,
200
+ previews: {
201
+ ...current.previews,
202
+ [kind]: {
203
+ status: result.complete ? "ready" : timedOut ? "unavailable" : "loading",
204
+ images:
205
+ kind === "effects"
206
+ ? { ...current.previews[kind].images, ...result.images }
207
+ : result.images,
208
+ width: result.width,
209
+ height: result.height,
210
+ },
211
+ },
212
+ }));
213
+ } catch {
214
+ // Timed retries handle runtime and media readiness races.
215
+ } finally {
216
+ inFlight = false;
217
+ }
218
+ };
219
+ const onMessage = (event: MessageEvent) => {
220
+ if (event.source !== iframe.contentWindow) return;
221
+ const data = event.data as { source?: unknown; type?: unknown } | null;
222
+ if (data?.source === "hf-preview" && data.type === "ready") void attempt();
223
+ };
224
+ iframe.addEventListener("load", attempt);
225
+ window.addEventListener("message", onMessage);
226
+ for (const delay of [0, 100, 350, 1000]) {
227
+ timers.push(window.setTimeout(() => void attempt(), delay));
228
+ }
229
+ timers.push(
230
+ window.setTimeout(() => {
231
+ if (cancelled || complete) return;
232
+ timedOut = true;
233
+ setState((current) => ({
234
+ ...current,
235
+ previews: {
236
+ ...current.previews,
237
+ [kind]: { ...current.previews[kind], status: "unavailable" },
238
+ },
239
+ }));
240
+ }, 1600),
241
+ );
242
+ return () => {
243
+ cancelled = true;
244
+ for (const timer of timers) window.clearTimeout(timer);
245
+ iframe.removeEventListener("load", attempt);
246
+ window.removeEventListener("message", onMessage);
247
+ };
248
+ }, [grading.effects, grading.lut, grading.palette, previewIframeRef, request, target]);
249
+
250
+ const stopAnimatedPreview = useCallback(() => {
251
+ const session = animatedRef.current;
252
+ if (!session) return;
253
+ animatedRef.current = null;
254
+ if (session.timer !== null) window.clearTimeout(session.timer);
255
+ session.stopPlayback();
256
+ }, []);
257
+
258
+ const previewColorGrading = useCallback(
259
+ (next: NormalizedHfColorGrading | null, options?: ColorGradingPreviewOptions) => {
260
+ stopAnimatedPreview();
261
+ postColorGrading(next ?? gradingRef.current);
262
+ if (!next || !options) return;
263
+ const runtime = readRuntime(previewIframeRef?.current);
264
+ const stopPlayback = runtime?.startPreviewPlayback?.(target);
265
+ if (!runtime || !stopPlayback) return;
266
+ const session = { stopPlayback, timer: null as number | null };
267
+ animatedRef.current = session;
268
+ const renderFrame = async () => {
269
+ try {
270
+ const batch = await runtime.renderPreviews(
271
+ target,
272
+ [{ id: options.animatedPreview.id, grading: toPreviewColorGrading(next) }],
273
+ { maxDimension: previewMaxDimension(), useMediaTime: true },
274
+ );
275
+ const image = batch?.images[0]?.dataUrl;
276
+ if (animatedRef.current !== session || !batch || !image) return;
277
+ setState((current) => ({
278
+ ...current,
279
+ previews: {
280
+ ...current.previews,
281
+ [options.animatedPreview.kind]: {
282
+ ...current.previews[options.animatedPreview.kind],
283
+ status: "ready",
284
+ images: {
285
+ ...current.previews[options.animatedPreview.kind].images,
286
+ [options.animatedPreview.id]: image,
287
+ },
288
+ width: batch.width,
289
+ height: batch.height,
290
+ },
291
+ },
292
+ }));
293
+ } catch {
294
+ // A later frame can recover after media/runtime readiness races.
295
+ } finally {
296
+ if (animatedRef.current === session) {
297
+ session.timer = window.setTimeout(() => void renderFrame(), 100);
298
+ }
299
+ }
300
+ };
301
+ void renderFrame();
302
+ },
303
+ [gradingRef, postColorGrading, previewIframeRef, stopAnimatedPreview, target],
304
+ );
305
+
306
+ useEffect(() => stopAnimatedPreview, [identityKey, stopAnimatedPreview]);
307
+
308
+ const requestPreviews = useCallback(
309
+ (kind: ColorGradingPreviewKind, effects?: readonly HfColorGradingActiveEffectKey[]) => {
310
+ setRequest((current) => ({
311
+ kind,
312
+ version: (current?.version ?? 0) + 1,
313
+ effects,
314
+ }));
315
+ },
316
+ [],
317
+ );
318
+ const requestPresetPreviews = useCallback(() => requestPreviews("presets"), [requestPreviews]);
319
+ const requestEffectPreviews = useCallback(
320
+ (effects: readonly HfColorGradingActiveEffectKey[]) => requestPreviews("effects", effects),
321
+ [requestPreviews],
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
+ );
341
+
342
+ return {
343
+ presetPreviews: previews.presets,
344
+ effectPreviews: previews.effects,
345
+ requestPresetPreviews,
346
+ requestEffectPreviews,
347
+ previewColorGrading,
348
+ captureGradedFrame,
349
+ };
350
+ }