@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
|
@@ -23,6 +23,15 @@ import {
|
|
|
23
23
|
FlatColorGradingAccessory,
|
|
24
24
|
FlatColorGradingSection,
|
|
25
25
|
} from "./propertyPanelFlatColorGradingSection";
|
|
26
|
+
import {
|
|
27
|
+
activeColorGradingEffectCount,
|
|
28
|
+
FlatEffectsAccessory,
|
|
29
|
+
FlatEffectsSection,
|
|
30
|
+
} from "./propertyPanelFlatEffectsSection";
|
|
31
|
+
import {
|
|
32
|
+
deriveMediaOverlayPlacement,
|
|
33
|
+
FlatOverlaysSection,
|
|
34
|
+
} from "./propertyPanelFlatOverlaysSection";
|
|
26
35
|
|
|
27
36
|
type EditingSections = ReturnType<typeof resolveEditingSections>;
|
|
28
37
|
|
|
@@ -34,11 +43,7 @@ type FlatGroupDescriptor = {
|
|
|
34
43
|
content: ReactNode;
|
|
35
44
|
};
|
|
36
45
|
|
|
37
|
-
//
|
|
38
|
-
// satisfy FlatMotionSection's required-callback shape when the effect list is
|
|
39
|
-
// gated off (showEffects === false, so none of these are ever invoked). Keeps
|
|
40
|
-
// the gated-off path free of `!` non-null assertions — the real, narrowed
|
|
41
|
-
// handlers flow through only when the double-gate below passes.
|
|
46
|
+
// Required callback shape for the gated-off Motion effect list.
|
|
42
47
|
const EMPTY_GSAP_EFFECT_HANDLERS = {
|
|
43
48
|
onAddAnimation: () => {},
|
|
44
49
|
onUpdateProperty: () => {},
|
|
@@ -48,15 +53,7 @@ const EMPTY_GSAP_EFFECT_HANDLERS = {
|
|
|
48
53
|
onRemoveProperty: () => {},
|
|
49
54
|
};
|
|
50
55
|
|
|
51
|
-
/**
|
|
52
|
-
* The flat "Ledger" inspector shell (design_handoff_studio_inspector).
|
|
53
|
-
*
|
|
54
|
-
* Extracted from PropertyPanel so that file stays under the 600-LOC gate
|
|
55
|
-
* (same one-directional-import precedent as FlatTextSection). Rendered only
|
|
56
|
-
* when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open group state.
|
|
57
|
-
*
|
|
58
|
-
* The Text/Style/Layout/Motion/Media/Grade groups share the one-open accordion.
|
|
59
|
-
*/
|
|
56
|
+
/** The flat inspector shell with one shared open-group state. */
|
|
60
57
|
// fallow-ignore-next-line complexity
|
|
61
58
|
export function PropertyPanelFlat({
|
|
62
59
|
element,
|
|
@@ -93,6 +90,7 @@ export function PropertyPanelFlat({
|
|
|
93
90
|
onAskAgent,
|
|
94
91
|
onToggleElementHidden,
|
|
95
92
|
onImportAssets,
|
|
93
|
+
onAddMediaOverlay,
|
|
96
94
|
onImportFonts,
|
|
97
95
|
recordingState,
|
|
98
96
|
recordingDuration,
|
|
@@ -113,10 +111,7 @@ export function PropertyPanelFlat({
|
|
|
113
111
|
currentTime,
|
|
114
112
|
animIdForProp,
|
|
115
113
|
gsapRuntimeValues,
|
|
116
|
-
//
|
|
117
|
-
// (non-flat) panel, but the flat path recomputes its own basis below via
|
|
118
|
-
// deriveElementTiming so it agrees with Motion's Timing row — ignore the
|
|
119
|
-
// parent's naive `elDuration ?? 1` fallback.
|
|
114
|
+
// The flat path derives timing consistently with its Motion section.
|
|
120
115
|
elStart: _elStart,
|
|
121
116
|
elDuration: _elDuration,
|
|
122
117
|
onCommitAnimatedProperty,
|
|
@@ -164,6 +159,7 @@ export function PropertyPanelFlat({
|
|
|
164
159
|
| "onAskAgent"
|
|
165
160
|
| "onToggleElementHidden"
|
|
166
161
|
| "onImportAssets"
|
|
162
|
+
| "onAddMediaOverlay"
|
|
167
163
|
| "onImportFonts"
|
|
168
164
|
| "fontAssets"
|
|
169
165
|
| "gsapAnimations"
|
|
@@ -187,9 +183,6 @@ export function PropertyPanelFlat({
|
|
|
187
183
|
| "recordingDuration"
|
|
188
184
|
| "onToggleRecording"
|
|
189
185
|
> &
|
|
190
|
-
// Layout-group values (Plan 3a Task 5). All are derived locals or handlers in
|
|
191
|
-
// PropertyPanel; compose their exact shapes from FlatLayoutSection's own props
|
|
192
|
-
// via Pick so a signature change there propagates here instead of drifting.
|
|
193
186
|
Pick<
|
|
194
187
|
Parameters<typeof FlatLayoutSection>[0],
|
|
195
188
|
| "displayX"
|
|
@@ -227,12 +220,7 @@ export function PropertyPanelFlat({
|
|
|
227
220
|
onCopyElementInfo: () => void;
|
|
228
221
|
currentTime: number;
|
|
229
222
|
}) {
|
|
230
|
-
//
|
|
231
|
-
// (Text if text-editable, else Style if style-editable, else none open) so a
|
|
232
|
-
// style-only element doesn't start with everything collapsed. Only runs on
|
|
233
|
-
// mount — PropertyPanel.tsx keys <PropertyPanelFlat> by element identity so
|
|
234
|
-
// switching the selection re-mounts this component and re-derives the
|
|
235
|
-
// default instead of preserving stale state across unrelated elements.
|
|
223
|
+
// PropertyPanel keys this component by selection, so the default is per element.
|
|
236
224
|
const [openGroupId, setOpenGroupId] = useState<string>(() =>
|
|
237
225
|
isTextEditableSelection(element)
|
|
238
226
|
? "text"
|
|
@@ -243,33 +231,16 @@ export function PropertyPanelFlat({
|
|
|
243
231
|
: "layout",
|
|
244
232
|
);
|
|
245
233
|
|
|
246
|
-
//
|
|
247
|
-
// their header/body gets the fast entrance animation (hf-flat-group-enter)
|
|
248
|
-
// and no one else's does. Deliberately NOT derived from remounting alone:
|
|
249
|
-
// FlatGroupHeader instances are keyed by group id and React normally
|
|
250
|
-
// preserves them across re-renders, but toggling a non-adjacent group still
|
|
251
|
-
// shifts the untouched collapsed siblings between the before/after-open
|
|
252
|
-
// slices below, and Chromium restarts a CSS animation on that kind of
|
|
253
|
-
// position shift even though nothing about the sibling actually changed.
|
|
254
|
-
// Gating on these ids (cleared shortly after the 120ms CSS animation
|
|
255
|
-
// finishes) keeps the animation scoped to only the groups that actually
|
|
256
|
-
// just toggled. Two ids, not one: the clicked (newly-opening/closing) group
|
|
257
|
-
// AND whichever group was open immediately before the click and got
|
|
258
|
-
// implicitly closed by it — both freshly-mounted headers need to animate.
|
|
234
|
+
// Animate only the groups that changed during this toggle cycle.
|
|
259
235
|
const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
|
|
260
236
|
const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
237
|
+
const panelBodyRef = useRef<HTMLDivElement>(null);
|
|
261
238
|
useEffect(() => {
|
|
262
239
|
return () => {
|
|
263
240
|
if (justToggledTimeoutRef.current) clearTimeout(justToggledTimeoutRef.current);
|
|
264
241
|
};
|
|
265
242
|
}, []);
|
|
266
243
|
|
|
267
|
-
// Grade group state. Called unconditionally (React rules-of-hooks) even when
|
|
268
|
-
// sections.colorGrading is false — unlike the legacy ColorGradingSection,
|
|
269
|
-
// which is only mounted when the section is active, PropertyPanelFlat is not
|
|
270
|
-
// remounted per-section so the hook must run every render. Shares one state
|
|
271
|
-
// object between the group's header accessory (compare/status/reset) and its
|
|
272
|
-
// body (the FlatColorGradingSection controls).
|
|
273
244
|
const colorGradingController = useColorGradingController({
|
|
274
245
|
projectId,
|
|
275
246
|
element,
|
|
@@ -281,9 +252,7 @@ export function PropertyPanelFlat({
|
|
|
281
252
|
const isTextEditable = isTextEditableSelection(element);
|
|
282
253
|
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
|
|
283
254
|
const toggleOpen = (groupId: string) => {
|
|
284
|
-
|
|
285
|
-
// openGroupId), so the group that's about to be implicitly closed can be
|
|
286
|
-
// tracked too — not just the one the user clicked.
|
|
255
|
+
const isOpening = openGroupId !== groupId;
|
|
287
256
|
const previousOpenGroupId = openGroupId;
|
|
288
257
|
setOpenGroupId((current) => (current === groupId ? "" : groupId));
|
|
289
258
|
const implicitlyClosedId =
|
|
@@ -291,34 +260,20 @@ export function PropertyPanelFlat({
|
|
|
291
260
|
setJustToggledIds(implicitlyClosedId ? [groupId, implicitlyClosedId] : [groupId]);
|
|
292
261
|
if (justToggledTimeoutRef.current) clearTimeout(justToggledTimeoutRef.current);
|
|
293
262
|
justToggledTimeoutRef.current = setTimeout(() => setJustToggledIds([]), 200);
|
|
263
|
+
if (isOpening) {
|
|
264
|
+
requestAnimationFrame(() =>
|
|
265
|
+
panelBodyRef.current
|
|
266
|
+
?.querySelector<HTMLElement>('[data-flat-group-open="true"]')
|
|
267
|
+
?.scrollIntoView?.({ block: "start" }),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
294
270
|
};
|
|
295
|
-
// Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) —
|
|
296
|
-
// must agree with Motion's Timing row (FlatTimingRow), which infers the
|
|
297
|
-
// range from animations when there's no explicit data-duration. Computed
|
|
298
|
-
// here (not threaded from PropertyPanel) both to keep that file under its
|
|
299
|
-
// 600-LOC gate and because element/gsapAnimations are already in scope.
|
|
300
271
|
const { start: elStart, duration: elDuration } = deriveElementTiming(element, gsapAnimations);
|
|
301
|
-
// Trivial percentage→time seek, derived here rather than threaded from
|
|
302
|
-
// PropertyPanel (keeps that file under its 600-LOC gate).
|
|
303
272
|
const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);
|
|
304
|
-
//
|
|
305
|
-
// seekFromKfPct above — recomputed here (not threaded as `currentPct` from
|
|
306
|
-
// PropertyPanel, which still derives it against its own naive basis for the
|
|
307
|
-
// legacy panel) so KeyframeNavigation's diamond active-state and prev/next
|
|
308
|
-
// arrow targeting agree with where a keyframe click actually seeks to
|
|
309
|
-
// (follow-up fix to 684ec4e87, which corrected the seek basis but left this
|
|
310
|
-
// one still naive).
|
|
273
|
+
// Use the same timing basis for seeking and active keyframe state.
|
|
311
274
|
const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0;
|
|
312
275
|
|
|
313
|
-
//
|
|
314
|
-
// • Timing (sections.timing) shows via resolveEditingSections, same as today.
|
|
315
|
-
// • The effect-card list shows only when STUDIO_GSAP_PANEL_ENABLED is on AND
|
|
316
|
-
// all five edit handlers are present (identical to PropertyPanel's legacy
|
|
317
|
-
// `<GsapAnimationSection>` guard).
|
|
318
|
-
// Computing the narrowed handler bundle inside the `&&`-guarded ternary lets
|
|
319
|
-
// TypeScript prove each handler non-undefined without a `!` assertion; the
|
|
320
|
-
// noop bundle only fills the type when the gate is off (never invoked, since
|
|
321
|
-
// FlatMotionSection guards every call behind showEffects).
|
|
276
|
+
// Match the legacy Motion gate while preserving TypeScript narrowing.
|
|
322
277
|
const showMotionTiming = Boolean(sections.timing);
|
|
323
278
|
const gsapEffectHandlers =
|
|
324
279
|
STUDIO_GSAP_PANEL_ENABLED &&
|
|
@@ -347,9 +302,6 @@ export function PropertyPanelFlat({
|
|
|
347
302
|
const showMotionEffects = gsapEffectHandlers !== null;
|
|
348
303
|
const showMotionGroup = showMotionTiming || showMotionEffects;
|
|
349
304
|
|
|
350
|
-
// Ordered group descriptors — one per FlatGroup this panel renders, gated by
|
|
351
|
-
// the same conditions the inline JSX used. Split below into before-open/
|
|
352
|
-
// open/after-open regions for the one-open accordion.
|
|
353
305
|
const groups: FlatGroupDescriptor[] = [];
|
|
354
306
|
if (isTextEditable) {
|
|
355
307
|
groups.push({
|
|
@@ -372,8 +324,6 @@ export function PropertyPanelFlat({
|
|
|
372
324
|
});
|
|
373
325
|
}
|
|
374
326
|
if (showEditableSections) {
|
|
375
|
-
// Number.isFinite guard (not `|| 1`): opacity 0 is a real value — an
|
|
376
|
-
// invisible element must summarize as 0%, not 100%.
|
|
377
327
|
const opacityValue = parseFloat(styles.opacity ?? "1");
|
|
378
328
|
const opacityPct = Math.round((Number.isFinite(opacityValue) ? opacityValue : 1) * 100);
|
|
379
329
|
groups.push({
|
|
@@ -398,8 +348,6 @@ export function PropertyPanelFlat({
|
|
|
398
348
|
groups.push({
|
|
399
349
|
id: "layout",
|
|
400
350
|
title: "Layout",
|
|
401
|
-
// No scrub accessory: FlatRow/CommitField has no pointer-drag scrubbing
|
|
402
|
-
// (wheel/arrow keys only) — advertising "drag values to scrub" here lies.
|
|
403
351
|
summary: `${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`,
|
|
404
352
|
content: (
|
|
405
353
|
<FlatLayoutSection
|
|
@@ -470,15 +418,57 @@ export function PropertyPanelFlat({
|
|
|
470
418
|
assets={assets}
|
|
471
419
|
onImportAssets={onImportAssets}
|
|
472
420
|
onCommitColorGrading={colorGradingController.commitColorGrading}
|
|
421
|
+
onPreviewColorGrading={colorGradingController.previewColorGrading}
|
|
473
422
|
applyScope={colorGradingController.applyScope}
|
|
474
423
|
applyBusy={colorGradingController.applyBusy}
|
|
475
424
|
onSetApplyScope={colorGradingController.setApplyScope}
|
|
476
425
|
onApplyToScope={() => void colorGradingController.applyToScope()}
|
|
477
426
|
onApplyScopeAvailable={Boolean(onApplyColorGradingScope)}
|
|
478
427
|
mediaMetadata={colorGradingController.mediaMetadata}
|
|
428
|
+
presetPreviews={colorGradingController.presetPreviews}
|
|
429
|
+
onRequestPresetPreviews={colorGradingController.requestPresetPreviews}
|
|
430
|
+
captureGradedFrame={colorGradingController.captureGradedFrame}
|
|
431
|
+
/>
|
|
432
|
+
),
|
|
433
|
+
});
|
|
434
|
+
const activeEffects = activeColorGradingEffectCount(colorGradingController.grading);
|
|
435
|
+
const effectsProps = {
|
|
436
|
+
grading: colorGradingController.grading,
|
|
437
|
+
onCommitColorGrading: colorGradingController.commitColorGrading,
|
|
438
|
+
};
|
|
439
|
+
groups.push({
|
|
440
|
+
id: "effects",
|
|
441
|
+
title: "Effects",
|
|
442
|
+
accessory: <FlatEffectsAccessory {...effectsProps} />,
|
|
443
|
+
summary: activeEffects ? `${activeEffects} active` : "none",
|
|
444
|
+
content: (
|
|
445
|
+
<FlatEffectsSection
|
|
446
|
+
{...effectsProps}
|
|
447
|
+
previews={colorGradingController.effectPreviews}
|
|
448
|
+
presetPreviews={colorGradingController.presetPreviews}
|
|
449
|
+
onPreviewColorGrading={colorGradingController.previewColorGrading}
|
|
450
|
+
onRequestEffectPreviews={colorGradingController.requestEffectPreviews}
|
|
451
|
+
onRequestPresetPreviews={colorGradingController.requestPresetPreviews}
|
|
479
452
|
/>
|
|
480
453
|
),
|
|
481
454
|
});
|
|
455
|
+
if (onAddMediaOverlay) {
|
|
456
|
+
groups.push({
|
|
457
|
+
id: "overlays",
|
|
458
|
+
title: "Overlays",
|
|
459
|
+
summary: "add layer",
|
|
460
|
+
content: (
|
|
461
|
+
<FlatOverlaysSection
|
|
462
|
+
onAddOverlay={(blockName) =>
|
|
463
|
+
onAddMediaOverlay(
|
|
464
|
+
blockName,
|
|
465
|
+
deriveMediaOverlayPlacement(element, { start: elStart, duration: elDuration }),
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
/>
|
|
469
|
+
),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
482
472
|
}
|
|
483
473
|
if (sections.media) {
|
|
484
474
|
groups.push({
|
|
@@ -499,13 +489,6 @@ export function PropertyPanelFlat({
|
|
|
499
489
|
});
|
|
500
490
|
}
|
|
501
491
|
|
|
502
|
-
// Fixed-headers + scrollable-open-section layout (design_handoff
|
|
503
|
-
// scrollable-open-section, replaces the prior sticky-stacking mechanism):
|
|
504
|
-
// collapsed headers before/after the open group render in normal document
|
|
505
|
-
// flow and never move. Only the open group's own body content scrolls, in
|
|
506
|
-
// a dedicated region between the two fixed header stacks. When no group is
|
|
507
|
-
// open, every group is just a collapsed header — there's no scrollable
|
|
508
|
-
// middle region at all, since nothing is expanded.
|
|
509
492
|
const openIndex = groups.findIndex((g) => g.id === openGroupId);
|
|
510
493
|
const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex);
|
|
511
494
|
const openGroup = openIndex === -1 ? null : groups[openIndex];
|
|
@@ -532,7 +515,11 @@ export function PropertyPanelFlat({
|
|
|
532
515
|
showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
|
|
533
516
|
/>
|
|
534
517
|
</DesignPanelInputProvider>
|
|
535
|
-
<div
|
|
518
|
+
<div
|
|
519
|
+
ref={panelBodyRef}
|
|
520
|
+
data-flat-panel-body="true"
|
|
521
|
+
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
|
|
522
|
+
>
|
|
536
523
|
{beforeOpen.map((g) => (
|
|
537
524
|
<DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
|
|
538
525
|
<FlatGroupHeader
|
|
@@ -546,7 +533,7 @@ export function PropertyPanelFlat({
|
|
|
546
533
|
))}
|
|
547
534
|
{openGroup && (
|
|
548
535
|
<DesignPanelInputProvider section={slugifyDesignInput(openGroup.title)}>
|
|
549
|
-
<div data-flat-group-open="true" className="flex min-h-
|
|
536
|
+
<div data-flat-group-open="true" className="flex min-h-[180px] flex-none flex-col">
|
|
550
537
|
<FlatGroupHeader
|
|
551
538
|
title={openGroup.title}
|
|
552
539
|
isOpen
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
normalizeHfColorGrading,
|
|
4
|
+
type HfColorGradingSecondary,
|
|
5
|
+
} from "@hyperframes/core/color-grading";
|
|
6
|
+
import {
|
|
7
|
+
analyzeColorGradingFrame,
|
|
8
|
+
buildColorGradingSecondaryMatte,
|
|
9
|
+
colorGradingSecondaryMask,
|
|
10
|
+
sampleColorGradingSecondary,
|
|
11
|
+
} from "./colorGradingFrameAnalysis";
|
|
12
|
+
|
|
13
|
+
function pixels(...rgba: number[]): Uint8ClampedArray {
|
|
14
|
+
return new Uint8ClampedArray(rgba);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function secondaryKey(key: HfColorGradingSecondary["key"]) {
|
|
18
|
+
const secondary = normalizeHfColorGrading({
|
|
19
|
+
secondaries: [{ key, correction: {} }],
|
|
20
|
+
})?.secondaries?.[0];
|
|
21
|
+
if (!secondary) throw new Error("Expected a normalized secondary");
|
|
22
|
+
return secondary.key;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("colorGradingFrameAnalysis", () => {
|
|
26
|
+
it("places black and white at the Rec.709 scope endpoints", () => {
|
|
27
|
+
const result = analyzeColorGradingFrame(pixels(0, 0, 0, 255, 255, 255, 255, 255), 2, 1);
|
|
28
|
+
|
|
29
|
+
expect(result.histogram[0]).toBe(255);
|
|
30
|
+
expect(result.histogram[255]).toBe(255);
|
|
31
|
+
expect(result.waveform[255]).toBe(255);
|
|
32
|
+
expect(result.waveform[256]).toBe(255);
|
|
33
|
+
expect(result.parade.reduce((sum, value) => sum + value, 0)).toBe(6 * 255);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("alpha-weights all scopes and excludes transparent pixels", () => {
|
|
37
|
+
const result = analyzeColorGradingFrame(pixels(0, 0, 0, 0, 255, 255, 255, 64), 2, 1);
|
|
38
|
+
|
|
39
|
+
expect(result.histogram.reduce((sum, value) => sum + value, 0)).toBe(64);
|
|
40
|
+
expect(result.waveform.reduce((sum, value) => sum + value, 0)).toBe(64);
|
|
41
|
+
expect(result.parade.reduce((sum, value) => sum + value, 0)).toBe(3 * 64);
|
|
42
|
+
expect(result.vectorscope.reduce((sum, value) => sum + value, 0)).toBe(64);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("averages hue circularly when an eyedropper sample crosses red", () => {
|
|
46
|
+
const sampled = sampleColorGradingSecondary(
|
|
47
|
+
pixels(255, 0, 10, 255, 255, 10, 0, 255),
|
|
48
|
+
2,
|
|
49
|
+
1,
|
|
50
|
+
0.5,
|
|
51
|
+
0,
|
|
52
|
+
1,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(sampled).not.toBeNull();
|
|
56
|
+
if (!sampled) throw new Error("Expected a sample");
|
|
57
|
+
expect(Math.min(sampled.hue.center, 360 - sampled.hue.center)).toBeLessThan(3);
|
|
58
|
+
expect(sampled.hue.range).toBe(18);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not invent a hue qualifier for a neutral sample", () => {
|
|
62
|
+
const sampled = sampleColorGradingSecondary(pixels(128, 128, 128, 255), 1, 1, 0, 0);
|
|
63
|
+
|
|
64
|
+
expect(sampled?.hue).toEqual({ center: 0, range: 180, softness: 0 });
|
|
65
|
+
expect(sampled?.saturation.max).toBeCloseTo(0.2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("matches the runtime softness on saturation and luma feather edges", () => {
|
|
69
|
+
const saturationKey = secondaryKey({
|
|
70
|
+
hue: { center: 0, range: 180, softness: 0 },
|
|
71
|
+
saturation: { min: 0.5, max: 1, softness: 0.4 },
|
|
72
|
+
luma: { min: 0, max: 1, softness: 0 },
|
|
73
|
+
});
|
|
74
|
+
const lumaKey = secondaryKey({
|
|
75
|
+
hue: { center: 0, range: 180, softness: 0 },
|
|
76
|
+
saturation: { min: 0, max: 1, softness: 0 },
|
|
77
|
+
luma: { min: 0.5, max: 1, softness: 0.4 },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(colorGradingSecondaryMask(255, 204, 204, saturationKey)).toBeCloseTo(0.15625, 5);
|
|
81
|
+
expect(colorGradingSecondaryMask(51, 51, 51, lumaKey)).toBeCloseTo(0.15625, 5);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("builds an alpha-preserving black-and-white selection matte", () => {
|
|
85
|
+
const key = secondaryKey({
|
|
86
|
+
hue: { center: 0, range: 15, softness: 0 },
|
|
87
|
+
saturation: { min: 0.5, max: 1, softness: 0 },
|
|
88
|
+
luma: { min: 0, max: 1, softness: 0 },
|
|
89
|
+
});
|
|
90
|
+
const matte = buildColorGradingSecondaryMatte(
|
|
91
|
+
pixels(255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 0, 0),
|
|
92
|
+
3,
|
|
93
|
+
1,
|
|
94
|
+
key,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect([...matte.slice(0, 4)]).toEqual([255, 255, 255, 255]);
|
|
98
|
+
expect([...matte.slice(4, 8)]).toEqual([0, 0, 0, 128]);
|
|
99
|
+
expect([...matte.slice(8, 12)]).toEqual([0, 0, 0, 0]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import {
|
|
2
|
+
calculateHfColorGradingSecondaryMask,
|
|
3
|
+
type NormalizedHfColorGradingSecondary,
|
|
4
|
+
} from "@hyperframes/core/color-grading";
|
|
5
|
+
import { clampNumber } from "../../utils/studioHelpers";
|
|
6
|
+
|
|
7
|
+
const BYTE_MAX = 255;
|
|
8
|
+
const SCOPE_SIZE = 256;
|
|
9
|
+
|
|
10
|
+
export type ColorGradingScopeMode = "histogram" | "waveform" | "parade" | "vectorscope";
|
|
11
|
+
|
|
12
|
+
export interface ColorGradingScopeAnalysis {
|
|
13
|
+
width: number;
|
|
14
|
+
histogram: Uint32Array;
|
|
15
|
+
waveform: Uint32Array;
|
|
16
|
+
parade: Uint32Array;
|
|
17
|
+
vectorscope: Uint32Array;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readColorGradingFramePixels(frame: {
|
|
21
|
+
dataUrl: string;
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
}): Promise<Uint8ClampedArray | null> {
|
|
25
|
+
const image = new Image();
|
|
26
|
+
image.src = frame.dataUrl;
|
|
27
|
+
await image.decode();
|
|
28
|
+
const canvas = document.createElement("canvas");
|
|
29
|
+
canvas.width = frame.width;
|
|
30
|
+
canvas.height = frame.height;
|
|
31
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
32
|
+
if (!context) return null;
|
|
33
|
+
context.drawImage(image, 0, 0, frame.width, frame.height);
|
|
34
|
+
return context.getImageData(0, 0, frame.width, frame.height).data;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rec709Luma(red: number, green: number, blue: number): number {
|
|
38
|
+
return red * 0.2126 + green * 0.7152 + blue * 0.0722;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rgbToHsv(red: number, green: number, blue: number) {
|
|
42
|
+
const max = Math.max(red, green, blue);
|
|
43
|
+
const min = Math.min(red, green, blue);
|
|
44
|
+
const chroma = max - min;
|
|
45
|
+
let hue = 0;
|
|
46
|
+
if (chroma > 0) {
|
|
47
|
+
if (max === red) hue = ((green - blue) / chroma) % 6;
|
|
48
|
+
else if (max === green) hue = (blue - red) / chroma + 2;
|
|
49
|
+
else hue = (red - green) / chroma + 4;
|
|
50
|
+
hue = (hue * 60 + 360) % 360;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
hue,
|
|
54
|
+
saturation: max === 0 ? 0 : chroma / max,
|
|
55
|
+
value: max,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function colorGradingSecondaryMask(
|
|
60
|
+
red: number,
|
|
61
|
+
green: number,
|
|
62
|
+
blue: number,
|
|
63
|
+
key: NormalizedHfColorGradingSecondary["key"],
|
|
64
|
+
): number {
|
|
65
|
+
const hsv = rgbToHsv(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX);
|
|
66
|
+
return calculateHfColorGradingSecondaryMask(
|
|
67
|
+
hsv.hue,
|
|
68
|
+
hsv.saturation,
|
|
69
|
+
rec709Luma(red / BYTE_MAX, green / BYTE_MAX, blue / BYTE_MAX),
|
|
70
|
+
key,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildColorGradingSecondaryMatte(
|
|
75
|
+
pixels: Uint8ClampedArray,
|
|
76
|
+
width: number,
|
|
77
|
+
height: number,
|
|
78
|
+
key: NormalizedHfColorGradingSecondary["key"],
|
|
79
|
+
): Uint8ClampedArray {
|
|
80
|
+
const matte = new Uint8ClampedArray(width * height * 4);
|
|
81
|
+
for (let offset = 0; offset < pixels.length; offset += 4) {
|
|
82
|
+
const alpha = pixels[offset + 3] ?? 0;
|
|
83
|
+
const mask =
|
|
84
|
+
alpha === 0
|
|
85
|
+
? 0
|
|
86
|
+
: colorGradingSecondaryMask(
|
|
87
|
+
pixels[offset] ?? 0,
|
|
88
|
+
pixels[offset + 1] ?? 0,
|
|
89
|
+
pixels[offset + 2] ?? 0,
|
|
90
|
+
key,
|
|
91
|
+
);
|
|
92
|
+
const value = Math.round(mask * BYTE_MAX);
|
|
93
|
+
matte[offset] = value;
|
|
94
|
+
matte[offset + 1] = value;
|
|
95
|
+
matte[offset + 2] = value;
|
|
96
|
+
matte[offset + 3] = alpha;
|
|
97
|
+
}
|
|
98
|
+
return matte;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function pixelOffset(width: number, x: number, y: number): number {
|
|
102
|
+
return (y * width + x) * 4;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function byteIndex(value: number): number {
|
|
106
|
+
return Math.round(clampNumber(value, 0, 1) * BYTE_MAX);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function analyzeColorGradingFrame(
|
|
110
|
+
pixels: Uint8ClampedArray,
|
|
111
|
+
width: number,
|
|
112
|
+
height: number,
|
|
113
|
+
): ColorGradingScopeAnalysis {
|
|
114
|
+
const histogram = new Uint32Array(SCOPE_SIZE);
|
|
115
|
+
const waveform = new Uint32Array(width * SCOPE_SIZE);
|
|
116
|
+
const parade = new Uint32Array(width * SCOPE_SIZE * 3);
|
|
117
|
+
const vectorscope = new Uint32Array(SCOPE_SIZE * SCOPE_SIZE);
|
|
118
|
+
|
|
119
|
+
for (let y = 0; y < height; y += 1) {
|
|
120
|
+
for (let x = 0; x < width; x += 1) {
|
|
121
|
+
const offset = pixelOffset(width, x, y);
|
|
122
|
+
const alpha = pixels[offset + 3] ?? 0;
|
|
123
|
+
if (alpha === 0) continue;
|
|
124
|
+
const red = (pixels[offset] ?? 0) / BYTE_MAX;
|
|
125
|
+
const green = (pixels[offset + 1] ?? 0) / BYTE_MAX;
|
|
126
|
+
const blue = (pixels[offset + 2] ?? 0) / BYTE_MAX;
|
|
127
|
+
const normalizedLuma = rec709Luma(red, green, blue);
|
|
128
|
+
const luma = byteIndex(normalizedLuma);
|
|
129
|
+
histogram[luma] += alpha;
|
|
130
|
+
waveform[x * SCOPE_SIZE + (BYTE_MAX - luma)] += alpha;
|
|
131
|
+
parade[x * SCOPE_SIZE + (BYTE_MAX - byteIndex(red))] += alpha;
|
|
132
|
+
parade[width * SCOPE_SIZE + x * SCOPE_SIZE + (BYTE_MAX - byteIndex(green))] += alpha;
|
|
133
|
+
parade[width * SCOPE_SIZE * 2 + x * SCOPE_SIZE + (BYTE_MAX - byteIndex(blue))] += alpha;
|
|
134
|
+
|
|
135
|
+
const chromaBlue = (blue - normalizedLuma) / 1.8556;
|
|
136
|
+
const chromaRed = (red - normalizedLuma) / 1.5748;
|
|
137
|
+
const vectorX = byteIndex(chromaBlue + 0.5);
|
|
138
|
+
const vectorY = BYTE_MAX - byteIndex(chromaRed + 0.5);
|
|
139
|
+
vectorscope[vectorY * SCOPE_SIZE + vectorX] += alpha;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { width, histogram, waveform, parade, vectorscope };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function sampleColorGradingSecondary(
|
|
147
|
+
pixels: Uint8ClampedArray,
|
|
148
|
+
width: number,
|
|
149
|
+
height: number,
|
|
150
|
+
x: number,
|
|
151
|
+
y: number,
|
|
152
|
+
radius = 2,
|
|
153
|
+
): NormalizedHfColorGradingSecondary["key"] | null {
|
|
154
|
+
const minX = clampNumber(Math.floor(x) - radius, 0, width - 1);
|
|
155
|
+
const maxX = clampNumber(Math.floor(x) + radius, 0, width - 1);
|
|
156
|
+
const minY = clampNumber(Math.floor(y) - radius, 0, height - 1);
|
|
157
|
+
const maxY = clampNumber(Math.floor(y) + radius, 0, height - 1);
|
|
158
|
+
let hueX = 0;
|
|
159
|
+
let hueY = 0;
|
|
160
|
+
let saturation = 0;
|
|
161
|
+
let luma = 0;
|
|
162
|
+
let weight = 0;
|
|
163
|
+
|
|
164
|
+
for (let sampleY = minY; sampleY <= maxY; sampleY += 1) {
|
|
165
|
+
for (let sampleX = minX; sampleX <= maxX; sampleX += 1) {
|
|
166
|
+
const offset = pixelOffset(width, sampleX, sampleY);
|
|
167
|
+
const alpha = (pixels[offset + 3] ?? 0) / BYTE_MAX;
|
|
168
|
+
if (alpha === 0) continue;
|
|
169
|
+
const red = (pixels[offset] ?? 0) / BYTE_MAX;
|
|
170
|
+
const green = (pixels[offset + 1] ?? 0) / BYTE_MAX;
|
|
171
|
+
const blue = (pixels[offset + 2] ?? 0) / BYTE_MAX;
|
|
172
|
+
const hsv = rgbToHsv(red, green, blue);
|
|
173
|
+
const hueRadians = (hsv.hue * Math.PI) / 180;
|
|
174
|
+
const hueWeight = hsv.saturation * alpha;
|
|
175
|
+
hueX += Math.cos(hueRadians) * hueWeight;
|
|
176
|
+
hueY += Math.sin(hueRadians) * hueWeight;
|
|
177
|
+
saturation += hsv.saturation * alpha;
|
|
178
|
+
luma += rec709Luma(red, green, blue) * alpha;
|
|
179
|
+
weight += alpha;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (weight === 0) return null;
|
|
184
|
+
const averageHue = ((Math.atan2(hueY, hueX) * 180) / Math.PI + 360) % 360;
|
|
185
|
+
const averageSaturation = saturation / weight;
|
|
186
|
+
const averageLuma = luma / weight;
|
|
187
|
+
const neutralSample = averageSaturation < 0.02;
|
|
188
|
+
return {
|
|
189
|
+
hue: neutralSample
|
|
190
|
+
? { center: 0, range: 180, softness: 0 }
|
|
191
|
+
: { center: averageHue, range: 18, softness: 12 },
|
|
192
|
+
saturation: {
|
|
193
|
+
min: clampNumber(averageSaturation - 0.2, 0, 1),
|
|
194
|
+
max: clampNumber(averageSaturation + 0.2, 0, 1),
|
|
195
|
+
softness: 0.1,
|
|
196
|
+
},
|
|
197
|
+
luma: {
|
|
198
|
+
min: clampNumber(averageLuma - 0.2, 0, 1),
|
|
199
|
+
max: clampNumber(averageLuma + 0.2, 0, 1),
|
|
200
|
+
softness: 0.1,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -5,15 +5,15 @@ describe("patchMediaColorGradingInHtml", () => {
|
|
|
5
5
|
it("adds color grading to video and image tags only", () => {
|
|
6
6
|
const { html, count } = patchMediaColorGradingInHtml(
|
|
7
7
|
`<div><video id="v"></video><img id="i" /><audio id="a"></audio></div>`,
|
|
8
|
-
`{"preset":"
|
|
8
|
+
`{"preset":"clean-studio"}`,
|
|
9
9
|
);
|
|
10
10
|
|
|
11
11
|
expect(count).toBe(2);
|
|
12
12
|
expect(html).toContain(
|
|
13
|
-
`video id="v" data-color-grading="{"preset":"
|
|
13
|
+
`video id="v" data-color-grading="{"preset":"clean-studio"}"`,
|
|
14
14
|
);
|
|
15
15
|
expect(html).toContain(
|
|
16
|
-
`img id="i" data-color-grading="{"preset":"
|
|
16
|
+
`img id="i" data-color-grading="{"preset":"clean-studio"}"`,
|
|
17
17
|
);
|
|
18
18
|
expect(html).toContain(`<audio id="a"></audio>`);
|
|
19
19
|
});
|