@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
@@ -4,6 +4,7 @@ import {
4
4
  isHfColorGradingActive,
5
5
  normalizeHfColorGrading,
6
6
  serializeHfColorGrading,
7
+ type HfColorGradingActiveEffectKey,
7
8
  type HfColorGradingTarget,
8
9
  type NormalizedHfColorGrading,
9
10
  } from "@hyperframes/core/color-grading";
@@ -18,6 +19,13 @@ import {
18
19
  acceptStudioRuntimeMessage,
19
20
  postRuntimeControlMessage,
20
21
  } from "../../player/lib/runtimeProtocol";
22
+ import {
23
+ useColorGradingPreviews,
24
+ type ColorGradingPresetPreviews,
25
+ type ColorGradingPreviewOptions,
26
+ } from "./useColorGradingPreviews";
27
+
28
+ export type { ColorGradingPresetPreviews, ColorGradingPreviewOptions };
21
29
 
22
30
  const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, "");
23
31
  const RUNTIME_STATUS_REFRESH_DELAYS = [50, 250, 1000, 2500] as const;
@@ -152,7 +160,15 @@ export interface ColorGradingControllerState {
152
160
  applyBusy: boolean;
153
161
  runtimeStatus: RuntimeColorGradingStatus;
154
162
  mediaMetadata: MediaMetadata | null;
163
+ presetPreviews: ColorGradingPresetPreviews;
164
+ effectPreviews: ColorGradingPresetPreviews;
165
+ requestPresetPreviews: () => void;
166
+ requestEffectPreviews: (effects: readonly HfColorGradingActiveEffectKey[]) => void;
155
167
  commitColorGrading: (next: NormalizedHfColorGrading) => void;
168
+ previewColorGrading: (
169
+ next: NormalizedHfColorGrading | null,
170
+ options?: ColorGradingPreviewOptions,
171
+ ) => void;
156
172
  commitCompare: (enabled: boolean) => void;
157
173
  setApplyScope: (scope: "source-file" | "project") => void;
158
174
  applyToScope: () => Promise<void>;
@@ -195,20 +211,11 @@ export function useColorGradingController({
195
211
  const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
196
212
  const pendingPersistValueRef = useRef<string | null | undefined>(undefined);
197
213
  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.
214
+ // Capture the edited element because selection can change before persistence.
201
215
  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.
216
+ // Keep a confirmed value so an optimistic edit can be reverted on failure.
206
217
  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.
218
+ // Prevent an older same-element write from settling over a newer edit.
212
219
  const gradingVersionRef = useRef(0);
213
220
  const statusTimersRef = useRef<number[]>([]);
214
221
  const latestGradingRef = useRef(grading);
@@ -216,22 +223,7 @@ export function useColorGradingController({
216
223
  latestGradingRef.current = grading;
217
224
  compareEnabledRef.current = compareEnabled;
218
225
 
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.
226
+ // Reset derived state during render to avoid flashing the previous selection.
235
227
  const identityKey = selectionIdentityKey(element);
236
228
  const identityKeyRef = useRef(identityKey);
237
229
  if (identityKeyRef.current !== identityKey) {
@@ -248,22 +240,9 @@ export function useColorGradingController({
248
240
  setMediaMetadata(null);
249
241
  }
250
242
 
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.
243
+ // Flush a pending write through the outgoing selection's committed callback.
261
244
  useEffect(() => {
262
245
  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
246
  for (const timer of statusTimersRef.current) clearTimeout(timer);
268
247
  statusTimersRef.current = [];
269
248
  if (persistTimerRef.current) {
@@ -316,11 +295,7 @@ export function useColorGradingController({
316
295
  })
317
296
  .then((result) => {
318
297
  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.
298
+ // Cache only successful responses so transient failures remain retryable.
324
299
  if (!result.ok) {
325
300
  setMediaMetadata(null);
326
301
  return;
@@ -329,8 +304,6 @@ export function useColorGradingController({
329
304
  setMediaMetadata(result.metadata);
330
305
  })
331
306
  .catch(() => {
332
- // Same reasoning as the non-OK branch above: don't cache a network-
333
- // level fetch failure either.
334
307
  if (!controller.signal.aborted) setMediaMetadata(null);
335
308
  });
336
309
  return () => controller.abort();
@@ -364,13 +337,6 @@ export function useColorGradingController({
364
337
  onSettled?: (ok: boolean) => void,
365
338
  ) => void | Promise<void>,
366
339
  ) => {
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
340
  const applySettled = (ok: boolean) => {
375
341
  if (identityKeyRef.current !== attemptIdentityKey) return;
376
342
  if (!isLatestAttempt()) return;
@@ -378,25 +344,13 @@ export function useColorGradingController({
378
344
  confirmedGradingRef.current = attemptedGrading;
379
345
  return;
380
346
  }
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
347
  const reverted = confirmedGradingRef.current;
385
348
  latestGradingRef.current = reverted;
386
349
  setGrading(reverted);
387
350
  setRuntimeStatus({ state: "unavailable", message: "Save failed — reverted" });
388
351
  };
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.
352
+ // The callback is the primary result signal; the rejection path supports
353
+ // other setAttributeLive implementations.
400
354
  const result = setAttributeLive(COLOR_GRADING_DATA_KEY, value ?? null, applySettled);
401
355
  return trackStudioPendingEdit(
402
356
  Promise.resolve(result).then(
@@ -416,20 +370,10 @@ export function useColorGradingController({
416
370
  if (pendingPersistValueRef.current === undefined) return undefined;
417
371
  const value = pendingPersistValueRef.current;
418
372
  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
373
  const attemptIdentityKey = pendingPersistIdentityRef.current ?? identityKeyRef.current;
423
374
  pendingPersistValueRef.current = undefined;
424
375
  pendingPersistGradingRef.current = null;
425
376
  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
377
  const isLatestAttempt = bumpDomEditCommitVersion(gradingVersionRef);
434
378
  return persistColorGradingValue(
435
379
  value,
@@ -458,6 +402,14 @@ export function useColorGradingController({
458
402
  },
459
403
  [previewIframeRef, target],
460
404
  );
405
+ const previewController = useColorGradingPreviews({
406
+ grading,
407
+ gradingRef: latestGradingRef,
408
+ identityKey,
409
+ target,
410
+ previewIframeRef,
411
+ postColorGrading,
412
+ });
461
413
 
462
414
  const postCompare = useCallback(
463
415
  (enabled: boolean) => {
@@ -509,6 +461,7 @@ export function useColorGradingController({
509
461
 
510
462
  const commitColorGrading = useCallback(
511
463
  (nextGrading: NormalizedHfColorGrading) => {
464
+ latestGradingRef.current = nextGrading;
512
465
  setGrading(nextGrading);
513
466
  setRuntimeStatus({ state: "pending", message: "Updating shader" });
514
467
  postColorGrading(nextGrading);
@@ -524,14 +477,7 @@ export function useColorGradingController({
524
477
  : null;
525
478
  pendingPersistGradingRef.current = nextGrading;
526
479
  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
480
  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
481
  const isLatestAttempt = bumpDomEditCommitVersion(gradingVersionRef);
536
482
  persistTimerRef.current = setTimeout(() => {
537
483
  const value = pendingPersistValueRef.current;
@@ -587,10 +533,22 @@ export function useColorGradingController({
587
533
  applyBusy,
588
534
  runtimeStatus,
589
535
  mediaMetadata,
536
+ presetPreviews: previewController.presetPreviews,
537
+ effectPreviews: previewController.effectPreviews,
538
+ requestPresetPreviews: previewController.requestPresetPreviews,
539
+ requestEffectPreviews: previewController.requestEffectPreviews,
590
540
  commitColorGrading,
541
+ previewColorGrading: previewController.previewColorGrading,
591
542
  commitCompare,
592
543
  setApplyScope,
593
544
  applyToScope,
594
- resetGrading: () => commitColorGrading(defaultColorGrading()),
545
+ resetGrading: () => {
546
+ const neutral = defaultColorGrading();
547
+ commitColorGrading({
548
+ ...neutral,
549
+ effects: latestGradingRef.current.effects,
550
+ palette: latestGradingRef.current.palette,
551
+ });
552
+ },
595
553
  };
596
554
  }
@@ -0,0 +1,307 @@
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
+ type ColorGradingPreviewKind = "presets" | "effects";
20
+
21
+ export interface ColorGradingPreviewOptions {
22
+ animatedPreview: { kind: ColorGradingPreviewKind; id: string };
23
+ }
24
+
25
+ type RuntimeColorGradingPreview = {
26
+ renderPreviews: (
27
+ target: HfColorGradingTarget,
28
+ candidates: Array<{ id: string; grading: unknown }>,
29
+ options: { maxDimension: number; useMediaTime?: boolean },
30
+ ) => Promise<{
31
+ width: number;
32
+ height: number;
33
+ images: Array<{ id: string; dataUrl: string | null }>;
34
+ } | null>;
35
+ startPreviewPlayback?: (target: HfColorGradingTarget) => (() => void) | null;
36
+ };
37
+
38
+ type PreviewRequest = {
39
+ kind: ColorGradingPreviewKind;
40
+ version: number;
41
+ effects?: readonly HfColorGradingActiveEffectKey[];
42
+ };
43
+
44
+ const emptyPreviews = (): Record<ColorGradingPreviewKind, ColorGradingPresetPreviews> => ({
45
+ presets: { status: "idle", images: {}, width: 16, height: 9 },
46
+ effects: { status: "idle", images: {}, width: 16, height: 9 },
47
+ });
48
+
49
+ function readRuntime(
50
+ iframe: HTMLIFrameElement | null | undefined,
51
+ ): RuntimeColorGradingPreview | null {
52
+ try {
53
+ const runtime = (
54
+ iframe?.contentWindow as
55
+ | (Window & { __hf?: { colorGrading?: Partial<RuntimeColorGradingPreview> } })
56
+ | null
57
+ | undefined
58
+ )?.__hf?.colorGrading;
59
+ return runtime?.renderPreviews
60
+ ? {
61
+ renderPreviews: runtime.renderPreviews,
62
+ startPreviewPlayback: runtime.startPreviewPlayback,
63
+ }
64
+ : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function previewMaxDimension(): number {
71
+ return Math.min(320, Math.ceil(160 * Math.max(1, window.devicePixelRatio || 1)));
72
+ }
73
+
74
+ function toPreviewColorGrading(grading: NormalizedHfColorGrading): unknown {
75
+ const { enabled: _enabled, ...previewGrading } = grading;
76
+ return previewGrading;
77
+ }
78
+
79
+ function previewCandidates(
80
+ request: PreviewRequest,
81
+ lut: NormalizedHfColorGrading["lut"],
82
+ ): Array<{ id: string; grading: unknown }> {
83
+ if (request.kind === "presets") {
84
+ 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 };
87
+ });
88
+ }
89
+ return (request.effects ?? HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS).map((effect) => {
90
+ const resolved = normalizeHfColorGrading({
91
+ effects: HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS[effect],
92
+ });
93
+ return { id: effect, grading: resolved ? toPreviewColorGrading(resolved) : null };
94
+ });
95
+ }
96
+
97
+ async function renderRequestedPreviews(
98
+ runtime: RuntimeColorGradingPreview,
99
+ target: HfColorGradingTarget,
100
+ request: PreviewRequest,
101
+ lut: NormalizedHfColorGrading["lut"],
102
+ ) {
103
+ const batch = await runtime.renderPreviews(target, previewCandidates(request, lut), {
104
+ maxDimension: previewMaxDimension(),
105
+ });
106
+ if (!batch) return null;
107
+ const images = Object.fromEntries(
108
+ batch.images.flatMap((image) => (image.dataUrl ? [[image.id, image.dataUrl]] : [])),
109
+ );
110
+ return Object.keys(images).length ? { ...batch, images } : null;
111
+ }
112
+
113
+ export function useColorGradingPreviews({
114
+ grading,
115
+ gradingRef,
116
+ identityKey,
117
+ target,
118
+ previewIframeRef,
119
+ postColorGrading,
120
+ }: {
121
+ grading: NormalizedHfColorGrading;
122
+ gradingRef: RefObject<NormalizedHfColorGrading>;
123
+ identityKey: string;
124
+ target: HfColorGradingTarget;
125
+ previewIframeRef?: RefObject<HTMLIFrameElement | null>;
126
+ postColorGrading: (grading: NormalizedHfColorGrading) => void;
127
+ }) {
128
+ const [state, setState] = useState(() => ({
129
+ identityKey,
130
+ previews: emptyPreviews(),
131
+ }));
132
+ const [request, setRequest] = useState<PreviewRequest | null>(null);
133
+ const animatedRef = useRef<{ stopPlayback: () => void; timer: number | null } | null>(null);
134
+ if (state.identityKey !== identityKey) {
135
+ setState({ identityKey, previews: emptyPreviews() });
136
+ setRequest(null);
137
+ }
138
+ const previews = state.identityKey === identityKey ? state.previews : emptyPreviews();
139
+
140
+ useEffect(() => {
141
+ if (!request) return;
142
+ const { kind } = request;
143
+ const iframe = previewIframeRef?.current;
144
+ if (!iframe) {
145
+ setState((current) => ({
146
+ ...current,
147
+ previews: {
148
+ ...current.previews,
149
+ [kind]: { status: "unavailable", images: {}, width: 16, height: 9 },
150
+ },
151
+ }));
152
+ return;
153
+ }
154
+ let cancelled = false;
155
+ let complete = false;
156
+ let inFlight = false;
157
+ const timers: number[] = [];
158
+ setState((current) => ({
159
+ ...current,
160
+ previews: {
161
+ ...current.previews,
162
+ [kind]: { ...current.previews[kind], status: "loading" },
163
+ },
164
+ }));
165
+
166
+ const attempt = async () => {
167
+ if (cancelled || complete || inFlight) return;
168
+ const runtime = readRuntime(iframe);
169
+ if (!runtime) return;
170
+ inFlight = true;
171
+ try {
172
+ const result = await renderRequestedPreviews(runtime, target, request, grading.lut);
173
+ if (cancelled || !result) return;
174
+ complete = true;
175
+ setState((current) => ({
176
+ ...current,
177
+ previews: {
178
+ ...current.previews,
179
+ [kind]: {
180
+ status: "ready",
181
+ images:
182
+ kind === "effects"
183
+ ? { ...current.previews[kind].images, ...result.images }
184
+ : result.images,
185
+ width: result.width,
186
+ height: result.height,
187
+ },
188
+ },
189
+ }));
190
+ } catch {
191
+ // Timed retries handle runtime and media readiness races.
192
+ } finally {
193
+ inFlight = false;
194
+ }
195
+ };
196
+ const onMessage = (event: MessageEvent) => {
197
+ if (event.source !== iframe.contentWindow) return;
198
+ const data = event.data as { source?: unknown; type?: unknown } | null;
199
+ if (data?.source === "hf-preview" && data.type === "ready") void attempt();
200
+ };
201
+ iframe.addEventListener("load", attempt);
202
+ window.addEventListener("message", onMessage);
203
+ for (const delay of [0, 100, 350, 1000]) {
204
+ timers.push(window.setTimeout(() => void attempt(), delay));
205
+ }
206
+ timers.push(
207
+ window.setTimeout(() => {
208
+ if (cancelled || complete) return;
209
+ setState((current) => ({
210
+ ...current,
211
+ previews: {
212
+ ...current.previews,
213
+ [kind]: { status: "unavailable", images: {}, width: 16, height: 9 },
214
+ },
215
+ }));
216
+ }, 1600),
217
+ );
218
+ return () => {
219
+ cancelled = true;
220
+ for (const timer of timers) window.clearTimeout(timer);
221
+ iframe.removeEventListener("load", attempt);
222
+ window.removeEventListener("message", onMessage);
223
+ };
224
+ }, [grading.lut, previewIframeRef, request, target]);
225
+
226
+ const stopAnimatedPreview = useCallback(() => {
227
+ const session = animatedRef.current;
228
+ if (!session) return;
229
+ animatedRef.current = null;
230
+ if (session.timer !== null) window.clearTimeout(session.timer);
231
+ session.stopPlayback();
232
+ }, []);
233
+
234
+ const previewColorGrading = useCallback(
235
+ (next: NormalizedHfColorGrading | null, options?: ColorGradingPreviewOptions) => {
236
+ stopAnimatedPreview();
237
+ postColorGrading(next ?? gradingRef.current);
238
+ if (!next || !options) return;
239
+ const runtime = readRuntime(previewIframeRef?.current);
240
+ const stopPlayback = runtime?.startPreviewPlayback?.(target);
241
+ if (!runtime || !stopPlayback) return;
242
+ const session = { stopPlayback, timer: null as number | null };
243
+ animatedRef.current = session;
244
+ const renderFrame = async () => {
245
+ try {
246
+ const batch = await runtime.renderPreviews(
247
+ target,
248
+ [{ id: options.animatedPreview.id, grading: toPreviewColorGrading(next) }],
249
+ { maxDimension: previewMaxDimension(), useMediaTime: true },
250
+ );
251
+ const image = batch?.images[0]?.dataUrl;
252
+ if (animatedRef.current !== session || !batch || !image) return;
253
+ setState((current) => ({
254
+ ...current,
255
+ previews: {
256
+ ...current.previews,
257
+ [options.animatedPreview.kind]: {
258
+ ...current.previews[options.animatedPreview.kind],
259
+ status: "ready",
260
+ images: {
261
+ ...current.previews[options.animatedPreview.kind].images,
262
+ [options.animatedPreview.id]: image,
263
+ },
264
+ width: batch.width,
265
+ height: batch.height,
266
+ },
267
+ },
268
+ }));
269
+ } catch {
270
+ // A later frame can recover after media/runtime readiness races.
271
+ } finally {
272
+ if (animatedRef.current === session) {
273
+ session.timer = window.setTimeout(() => void renderFrame(), 100);
274
+ }
275
+ }
276
+ };
277
+ void renderFrame();
278
+ },
279
+ [gradingRef, postColorGrading, previewIframeRef, stopAnimatedPreview, target],
280
+ );
281
+
282
+ useEffect(() => stopAnimatedPreview, [identityKey, stopAnimatedPreview]);
283
+
284
+ const requestPreviews = useCallback(
285
+ (kind: ColorGradingPreviewKind, effects?: readonly HfColorGradingActiveEffectKey[]) => {
286
+ setRequest((current) => ({
287
+ kind,
288
+ version: (current?.version ?? 0) + 1,
289
+ effects,
290
+ }));
291
+ },
292
+ [],
293
+ );
294
+ const requestPresetPreviews = useCallback(() => requestPreviews("presets"), [requestPreviews]);
295
+ const requestEffectPreviews = useCallback(
296
+ (effects: readonly HfColorGradingActiveEffectKey[]) => requestPreviews("effects", effects),
297
+ [requestPreviews],
298
+ );
299
+
300
+ return {
301
+ presetPreviews: previews.presets,
302
+ effectPreviews: previews.effects,
303
+ requestPresetPreviews,
304
+ requestEffectPreviews,
305
+ previewColorGrading,
306
+ };
307
+ }
@@ -19,7 +19,7 @@ import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
19
19
 
20
20
  // Timeline gets a generous default height so the preview isn't oversized and the
21
21
  // tracks have room to breathe (CapCut-style). Users can still drag the divider.
22
- const DEFAULT_TIMELINE_H = 340;
22
+ const DEFAULT_TIMELINE_H = 360;
23
23
 
24
24
  export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
25
25
  return compositionLoading;
@@ -14,9 +14,8 @@ export function usePanelLayoutContext(): PanelLayoutValue {
14
14
  export function PanelLayoutProvider({
15
15
  value: {
16
16
  leftWidth,
17
- setLeftWidth,
18
17
  rightWidth,
19
- setRightWidth,
18
+ adjustPanelWidth,
20
19
  leftCollapsed,
21
20
  setLeftCollapsed,
22
21
  rightCollapsed,
@@ -39,9 +38,8 @@ export function PanelLayoutProvider({
39
38
  const stable = useMemo<PanelLayoutValue>(
40
39
  () => ({
41
40
  leftWidth,
42
- setLeftWidth,
43
41
  rightWidth,
44
- setRightWidth,
42
+ adjustPanelWidth,
45
43
  leftCollapsed,
46
44
  setLeftCollapsed,
47
45
  rightCollapsed,
@@ -58,9 +56,8 @@ export function PanelLayoutProvider({
58
56
  }),
59
57
  [
60
58
  leftWidth,
61
- setLeftWidth,
62
59
  rightWidth,
63
- setRightWidth,
60
+ adjustPanelWidth,
64
61
  leftCollapsed,
65
62
  setLeftCollapsed,
66
63
  rightCollapsed,
@@ -6,33 +6,51 @@ import {
6
6
  resolveBlockCategory,
7
7
  } from "../utils/blockCategories";
8
8
 
9
- export type CatalogItem = RegistryItem & {
9
+ type CatalogItem = RegistryItem & {
10
10
  category: BlockCategory;
11
11
  };
12
12
 
13
+ let catalogCache: CatalogItem[] | null = null;
14
+ let catalogRequest: Promise<CatalogItem[]> | null = null;
15
+
16
+ function loadCatalog(): Promise<CatalogItem[]> {
17
+ catalogRequest ??= fetch("/api/registry/blocks")
18
+ .then((response) => {
19
+ if (!response.ok) throw new Error("Failed to load catalog");
20
+ return response.json() as Promise<RegistryItem[]>;
21
+ })
22
+ .then((items) => {
23
+ catalogCache = items
24
+ .map((item) => ({ ...item, category: resolveBlockCategory(item.tags) }))
25
+ .sort((a, b) => {
26
+ const aIndex = BLOCK_CATEGORIES.findIndex((category) => category.id === a.category);
27
+ const bIndex = BLOCK_CATEGORIES.findIndex((category) => category.id === b.category);
28
+ return (aIndex === -1 ? 99 : aIndex) - (bIndex === -1 ? 99 : bIndex);
29
+ });
30
+ return catalogCache;
31
+ })
32
+ .catch((error: unknown) => {
33
+ catalogRequest = null;
34
+ throw error;
35
+ });
36
+ return catalogRequest;
37
+ }
38
+
13
39
  export function useBlockCatalog() {
14
- const [blocks, setBlocks] = useState<CatalogItem[]>([]);
15
- const [loading, setLoading] = useState(true);
40
+ const [blocks, setBlocks] = useState<CatalogItem[]>(() => catalogCache ?? []);
41
+ const [loading, setLoading] = useState(catalogCache === null);
16
42
  const [error, setError] = useState<string | null>(null);
17
43
  const [search, setSearch] = useState("");
18
44
  const [category, setCategory] = useState<BlockCategory | null>(null);
19
45
 
20
46
  // fallow-ignore-next-line complexity
21
47
  useEffect(() => {
48
+ if (catalogCache) return;
22
49
  let cancelled = false;
23
50
  (async () => {
24
51
  try {
25
- const res = await fetch("/api/registry/blocks");
26
- if (!res.ok) throw new Error("Failed to load catalog");
27
- const data = (await res.json()) as RegistryItem[];
52
+ const items = await loadCatalog();
28
53
  if (cancelled) return;
29
- const items = data
30
- .map((b) => ({ ...b, category: resolveBlockCategory(b.tags) }))
31
- .sort((a, b) => {
32
- const ia = BLOCK_CATEGORIES.findIndex((c) => c.id === a.category);
33
- const ib = BLOCK_CATEGORIES.findIndex((c) => c.id === b.category);
34
- return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
35
- });
36
54
  setBlocks(items);
37
55
  } catch (err) {
38
56
  if (cancelled) return;