@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.
- package/dist/assets/{hyperframes-player-DpKeYLEo.js → hyperframes-player-Bjf2HPzR.js} +1 -1
- package/dist/assets/index-BgZMle9I.css +1 -0
- package/dist/assets/{index-DXjAllII.js → index-Ch1hbJ3e.js} +1 -1
- package/dist/assets/{index-CbX-xoud.js → index-Ct_pxETK.js} +1 -1
- package/dist/assets/index-DCyWLHnx.js +428 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.html +2 -2
- package/dist/index.js +5383 -3804
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -4
- package/src/components/StudioLeftSidebar.tsx +2 -3
- package/src/components/StudioRightPanel.tsx +10 -4
- package/src/components/editor/AnimationCard.test.tsx +100 -0
- package/src/components/editor/AnimationCard.tsx +5 -2
- package/src/components/editor/EaseCurveSection.test.tsx +348 -0
- package/src/components/editor/EaseCurveSection.tsx +444 -237
- package/src/components/editor/EaseParamFields.test.tsx +188 -0
- package/src/components/editor/EaseParamFields.tsx +246 -0
- package/src/components/editor/PropertyPanel.test.tsx +9 -7
- package/src/components/editor/PropertyPanelFlat.tsx +74 -88
- package/src/components/editor/colorGradingScopePatch.test.ts +3 -3
- package/src/components/editor/easeCurveSvg.tsx +65 -0
- package/src/components/editor/easePresetLibrary.test.ts +114 -0
- package/src/components/editor/easePresetLibrary.ts +49 -0
- package/src/components/editor/gsapAnimationConstants.ts +15 -13
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +73 -40
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +85 -66
- package/src/components/editor/propertyPanelFlatEffectControl.tsx +96 -0
- package/src/components/editor/propertyPanelFlatEffectSpecs.ts +305 -0
- package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +331 -0
- package/src/components/editor/propertyPanelFlatEffectsSection.tsx +503 -0
- package/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx +113 -0
- package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +108 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +5 -5
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -0
- package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
- package/src/components/editor/propertyPanelTypes.ts +13 -0
- package/src/components/editor/useColorGradingController.test.ts +235 -30
- package/src/components/editor/useColorGradingController.ts +46 -88
- package/src/components/editor/useColorGradingPreviews.ts +307 -0
- package/src/components/nle/NLEContext.tsx +1 -1
- package/src/contexts/PanelLayoutContext.tsx +3 -6
- package/src/hooks/useBlockCatalog.ts +31 -13
- package/src/hooks/useBlockHandlers.ts +22 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +15 -0
- package/src/hooks/useGsapScriptCommits.ts +14 -1
- package/src/hooks/usePanelLayout.test.ts +68 -1
- package/src/hooks/usePanelLayout.ts +72 -34
- package/src/player/components/PlayerControls.tsx +68 -229
- package/src/player/components/ShortcutsPanel.tsx +2 -4
- package/src/player/components/SpeedMenu.tsx +1 -2
- package/src/telemetry/events.test.ts +9 -0
- package/src/telemetry/events.ts +5 -0
- package/src/utils/blockInstaller.test.ts +67 -0
- package/src/utils/blockInstaller.ts +162 -109
- package/src/utils/studioUiPreferences.test.ts +5 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/dist/assets/index-CAeU317U.js +0 -428
- package/dist/assets/index-Ceyz8Qt2.css +0 -1
- package/src/player/components/PlayerControls.test.ts +0 -20
- 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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
390
|
-
//
|
|
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: () =>
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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;
|