@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.
- package/dist/assets/{hyperframes-player-C1X90psg.js → hyperframes-player-DGlzhf_s.js} +1 -1
- package/dist/assets/index-B37rWXo4.css +1 -0
- package/dist/assets/{index-DzxDHPR1.js → index-CbVTOJ-E.js} +1 -1
- package/dist/assets/{index-nY4lLBqM.js → index-CcA5WVJv.js} +1 -1
- package/dist/assets/index-DXcLI2wu.js +428 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.html +2 -2
- package/dist/index.js +7762 -4261
- 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 +75 -88
- package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
- package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
- 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/propertyPanelColorCurveGraph.tsx +549 -0
- package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
- package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
- package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
- package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
- package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
- package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
- package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
- package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
- package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +239 -41
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +260 -300
- 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.tsx +2 -0
- package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
- package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
- package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
- package/src/components/editor/propertyPanelTypes.ts +13 -0
- package/src/components/editor/useColorGradingController.test.ts +331 -10
- package/src/components/editor/useColorGradingController.ts +57 -90
- package/src/components/editor/useColorGradingPreviews.ts +350 -0
- package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
- package/src/components/editor/useColorGradingScopes.ts +62 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
- 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/icons/SystemIcons.tsx +4 -0
- 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-CSCh2Vrp.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
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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 =
|
|
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 =
|
|
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: () =>
|
|
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
|
+
}
|