@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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { ColorGradingCapturedFrame } from "./useColorGradingPreviews";
|
|
3
|
+
import {
|
|
4
|
+
type ColorGradingScopeAnalysis,
|
|
5
|
+
type ColorGradingScopeMode,
|
|
6
|
+
} from "./colorGradingFrameAnalysis";
|
|
7
|
+
import { useColorGradingScopes } from "./useColorGradingScopes";
|
|
8
|
+
import { RotateCw } from "../../icons/SystemIcons";
|
|
9
|
+
|
|
10
|
+
const MODES: Array<{ id: ColorGradingScopeMode; label: string }> = [
|
|
11
|
+
{ id: "histogram", label: "Histogram" },
|
|
12
|
+
{ id: "waveform", label: "Waveform" },
|
|
13
|
+
{ id: "parade", label: "RGB Parade" },
|
|
14
|
+
{ id: "vectorscope", label: "Vectorscope" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function densityAlpha(value: number, maximum: number): number {
|
|
18
|
+
if (value === 0 || maximum === 0) return 0;
|
|
19
|
+
return Math.min(1, Math.log2(value + 1) / Math.log2(maximum + 1));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function maximumDensity(values: Uint32Array): number {
|
|
23
|
+
let maximum = 0;
|
|
24
|
+
for (const value of values) maximum = Math.max(maximum, value);
|
|
25
|
+
return maximum;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function drawGrid(context: CanvasRenderingContext2D, width: number, height: number) {
|
|
29
|
+
context.fillStyle = "#090b0e";
|
|
30
|
+
context.fillRect(0, 0, width, height);
|
|
31
|
+
context.strokeStyle = "rgba(255,255,255,0.08)";
|
|
32
|
+
context.lineWidth = 1;
|
|
33
|
+
for (const fraction of [0.25, 0.5, 0.75]) {
|
|
34
|
+
context.beginPath();
|
|
35
|
+
context.moveTo(0, Math.round(height * fraction) + 0.5);
|
|
36
|
+
context.lineTo(width, Math.round(height * fraction) + 0.5);
|
|
37
|
+
context.stroke();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function drawHistogram(
|
|
42
|
+
context: CanvasRenderingContext2D,
|
|
43
|
+
analysis: ColorGradingScopeAnalysis,
|
|
44
|
+
width: number,
|
|
45
|
+
height: number,
|
|
46
|
+
) {
|
|
47
|
+
const maximum = maximumDensity(analysis.histogram);
|
|
48
|
+
context.beginPath();
|
|
49
|
+
context.moveTo(0, height);
|
|
50
|
+
analysis.histogram.forEach((count, index) => {
|
|
51
|
+
context.lineTo((index / 255) * width, height - densityAlpha(count, maximum) * height);
|
|
52
|
+
});
|
|
53
|
+
context.lineTo(width, height);
|
|
54
|
+
context.closePath();
|
|
55
|
+
context.fillStyle = "rgba(226, 232, 240, 0.2)";
|
|
56
|
+
context.fill();
|
|
57
|
+
context.strokeStyle = "rgba(226, 232, 240, 0.9)";
|
|
58
|
+
context.stroke();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function drawDensity(
|
|
62
|
+
context: CanvasRenderingContext2D,
|
|
63
|
+
density: Uint32Array,
|
|
64
|
+
columnOffset: number,
|
|
65
|
+
columnCount: number,
|
|
66
|
+
color: readonly [number, number, number],
|
|
67
|
+
xOffset: number,
|
|
68
|
+
outputWidth: number,
|
|
69
|
+
height: number,
|
|
70
|
+
) {
|
|
71
|
+
let maximum = 0;
|
|
72
|
+
const start = columnOffset * 256;
|
|
73
|
+
const end = (columnOffset + columnCount) * 256;
|
|
74
|
+
for (let index = start; index < end; index += 1) maximum = Math.max(maximum, density[index] ?? 0);
|
|
75
|
+
context.fillStyle = `rgb(${color.join(" ")})`;
|
|
76
|
+
for (let x = 0; x < columnCount; x += 1) {
|
|
77
|
+
for (let y = 0; y < 256; y += 1) {
|
|
78
|
+
const alpha = density[(columnOffset + x) * 256 + y] ?? 0;
|
|
79
|
+
if (!alpha) continue;
|
|
80
|
+
context.globalAlpha = densityAlpha(alpha, maximum);
|
|
81
|
+
context.fillRect(
|
|
82
|
+
xOffset + (x / columnCount) * outputWidth,
|
|
83
|
+
(y / 255) * height,
|
|
84
|
+
Math.max(1, outputWidth / columnCount),
|
|
85
|
+
Math.max(1, height / 256),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
context.globalAlpha = 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function drawVectorscope(
|
|
93
|
+
context: CanvasRenderingContext2D,
|
|
94
|
+
analysis: ColorGradingScopeAnalysis,
|
|
95
|
+
width: number,
|
|
96
|
+
height: number,
|
|
97
|
+
) {
|
|
98
|
+
const maximum = maximumDensity(analysis.vectorscope);
|
|
99
|
+
const centerX = width / 2;
|
|
100
|
+
const centerY = height / 2;
|
|
101
|
+
context.strokeStyle = "rgba(255,255,255,0.12)";
|
|
102
|
+
context.beginPath();
|
|
103
|
+
context.arc(centerX, centerY, Math.min(width, height) * 0.42, 0, Math.PI * 2);
|
|
104
|
+
context.stroke();
|
|
105
|
+
context.beginPath();
|
|
106
|
+
context.moveTo(centerX, 0);
|
|
107
|
+
context.lineTo(centerX, height);
|
|
108
|
+
context.moveTo(0, centerY);
|
|
109
|
+
context.lineTo(width, centerY);
|
|
110
|
+
context.stroke();
|
|
111
|
+
const skinAngle = (-123 * Math.PI) / 180;
|
|
112
|
+
context.strokeStyle = "rgba(251,191,36,0.3)";
|
|
113
|
+
context.beginPath();
|
|
114
|
+
context.moveTo(centerX, centerY);
|
|
115
|
+
context.lineTo(
|
|
116
|
+
centerX + Math.cos(skinAngle) * Math.min(width, height) * 0.45,
|
|
117
|
+
centerY + Math.sin(skinAngle) * Math.min(width, height) * 0.45,
|
|
118
|
+
);
|
|
119
|
+
context.stroke();
|
|
120
|
+
context.fillStyle = "rgb(110 231 183)";
|
|
121
|
+
for (let y = 0; y < 256; y += 1) {
|
|
122
|
+
for (let x = 0; x < 256; x += 1) {
|
|
123
|
+
const count = analysis.vectorscope[y * 256 + x] ?? 0;
|
|
124
|
+
if (!count) continue;
|
|
125
|
+
context.globalAlpha = densityAlpha(count, maximum);
|
|
126
|
+
context.fillRect(
|
|
127
|
+
(x / 255) * width,
|
|
128
|
+
(y / 255) * height,
|
|
129
|
+
Math.max(1, width / 256),
|
|
130
|
+
Math.max(1, height / 256),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
context.globalAlpha = 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function drawScope(
|
|
138
|
+
canvas: HTMLCanvasElement,
|
|
139
|
+
mode: ColorGradingScopeMode,
|
|
140
|
+
analysis: ColorGradingScopeAnalysis,
|
|
141
|
+
) {
|
|
142
|
+
const context = canvas.getContext("2d");
|
|
143
|
+
if (!context) return;
|
|
144
|
+
const { width, height } = canvas;
|
|
145
|
+
drawGrid(context, width, height);
|
|
146
|
+
if (mode === "histogram") {
|
|
147
|
+
drawHistogram(context, analysis, width, height);
|
|
148
|
+
} else if (mode === "waveform") {
|
|
149
|
+
drawDensity(context, analysis.waveform, 0, analysis.width, [226, 232, 240], 0, width, height);
|
|
150
|
+
} else if (mode === "parade") {
|
|
151
|
+
const laneWidth = width / 3;
|
|
152
|
+
(
|
|
153
|
+
[
|
|
154
|
+
[0, [248, 113, 113]],
|
|
155
|
+
[1, [74, 222, 128]],
|
|
156
|
+
[2, [96, 165, 250]],
|
|
157
|
+
] as const
|
|
158
|
+
).forEach(([channel, color]) => {
|
|
159
|
+
drawDensity(
|
|
160
|
+
context,
|
|
161
|
+
analysis.parade,
|
|
162
|
+
channel * analysis.width,
|
|
163
|
+
analysis.width,
|
|
164
|
+
color,
|
|
165
|
+
channel * laneWidth,
|
|
166
|
+
laneWidth,
|
|
167
|
+
height,
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
} else {
|
|
171
|
+
drawVectorscope(context, analysis, width, height);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function PropertyPanelColorScopes({
|
|
176
|
+
captureFrame,
|
|
177
|
+
refreshKey,
|
|
178
|
+
}: {
|
|
179
|
+
captureFrame: () => Promise<ColorGradingCapturedFrame | null>;
|
|
180
|
+
refreshKey: string;
|
|
181
|
+
}) {
|
|
182
|
+
const [open, setOpen] = useState(false);
|
|
183
|
+
const [mode, setMode] = useState<ColorGradingScopeMode>("waveform");
|
|
184
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
185
|
+
const { analysis, status, refresh } = useColorGradingScopes({
|
|
186
|
+
open,
|
|
187
|
+
captureFrame,
|
|
188
|
+
refreshKey,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
const canvas = canvasRef.current;
|
|
193
|
+
if (!canvas) return;
|
|
194
|
+
if (analysis) {
|
|
195
|
+
drawScope(canvas, mode, analysis);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const context = canvas.getContext("2d");
|
|
199
|
+
if (context) drawGrid(context, canvas.width, canvas.height);
|
|
200
|
+
}, [analysis, mode]);
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<div data-flat-grade-scopes="true" className="border-b border-panel-hairline pb-1.5">
|
|
204
|
+
<div className="flex min-h-7 items-center justify-between">
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={() => setOpen((value) => !value)}
|
|
208
|
+
className="flex min-h-7 flex-1 items-center justify-between text-left"
|
|
209
|
+
>
|
|
210
|
+
<span className="text-[11px] text-panel-text-2">Scopes</span>
|
|
211
|
+
<span className="text-[9px] text-panel-text-5">{open ? status : "Off"}</span>
|
|
212
|
+
</button>
|
|
213
|
+
<span className="flex items-center gap-2">
|
|
214
|
+
{open && (
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
aria-label="Refresh scopes"
|
|
218
|
+
title="Refresh scopes"
|
|
219
|
+
onClick={refresh}
|
|
220
|
+
className="p-1 text-panel-text-4 hover:text-panel-text-1"
|
|
221
|
+
>
|
|
222
|
+
<RotateCw size={10} />
|
|
223
|
+
</button>
|
|
224
|
+
)}
|
|
225
|
+
</span>
|
|
226
|
+
</div>
|
|
227
|
+
{open && (
|
|
228
|
+
<div className="space-y-1.5">
|
|
229
|
+
<div className="flex items-center gap-2 overflow-x-auto">
|
|
230
|
+
{MODES.map((candidate) => (
|
|
231
|
+
<button
|
|
232
|
+
key={candidate.id}
|
|
233
|
+
type="button"
|
|
234
|
+
aria-pressed={mode === candidate.id}
|
|
235
|
+
onClick={() => setMode(candidate.id)}
|
|
236
|
+
className={`whitespace-nowrap border-b-2 py-1 text-[9px] ${
|
|
237
|
+
mode === candidate.id
|
|
238
|
+
? "border-panel-accent text-panel-text-1"
|
|
239
|
+
: "border-transparent text-panel-text-4 hover:text-panel-text-2"
|
|
240
|
+
}`}
|
|
241
|
+
>
|
|
242
|
+
{candidate.label}
|
|
243
|
+
</button>
|
|
244
|
+
))}
|
|
245
|
+
</div>
|
|
246
|
+
<canvas
|
|
247
|
+
ref={canvasRef}
|
|
248
|
+
width={320}
|
|
249
|
+
height={144}
|
|
250
|
+
role="img"
|
|
251
|
+
aria-label={`${MODES.find((candidate) => candidate.id === mode)?.label ?? mode} scope, ${status}`}
|
|
252
|
+
className="block h-auto w-full border border-panel-hairline bg-black"
|
|
253
|
+
/>
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
|
|
7
|
+
import * as frameAnalysis from "./colorGradingFrameAnalysis";
|
|
8
|
+
import { PropertyPanelColorSecondary } from "./propertyPanelColorSecondary";
|
|
9
|
+
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
document.body.innerHTML = "";
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function normalizedSecondaries(count: number) {
|
|
18
|
+
const grading = normalizeHfColorGrading({
|
|
19
|
+
secondaries: Array.from({ length: count }, () => ({ key: {}, correction: {} })),
|
|
20
|
+
});
|
|
21
|
+
if (!grading) throw new Error("Expected normalized grading");
|
|
22
|
+
return grading.secondaries ?? [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function renderSecondary({
|
|
26
|
+
secondaries = normalizedSecondaries(1),
|
|
27
|
+
captureFrame = vi.fn().mockResolvedValue(null),
|
|
28
|
+
onCommit = vi.fn(),
|
|
29
|
+
} = {}) {
|
|
30
|
+
const host = document.body.appendChild(document.createElement("div"));
|
|
31
|
+
const root = createRoot(host);
|
|
32
|
+
act(() =>
|
|
33
|
+
root.render(
|
|
34
|
+
<PropertyPanelColorSecondary
|
|
35
|
+
secondaries={secondaries}
|
|
36
|
+
captureFrame={captureFrame}
|
|
37
|
+
onCommit={onCommit}
|
|
38
|
+
/>,
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
return { host, root, onCommit };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function captureSecondaryFrame(host: HTMLElement) {
|
|
45
|
+
const capture = Array.from(host.querySelectorAll("button")).find((button) =>
|
|
46
|
+
button.textContent?.includes("Sample color from frame"),
|
|
47
|
+
);
|
|
48
|
+
await act(async () => {
|
|
49
|
+
capture?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
50
|
+
await Promise.resolve();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("PropertyPanelColorSecondary", () => {
|
|
55
|
+
it("adds a normalized secondary and enforces the contract limit", () => {
|
|
56
|
+
const { host, root, onCommit } = renderSecondary({ secondaries: [] });
|
|
57
|
+
act(() => {
|
|
58
|
+
host
|
|
59
|
+
.querySelector<HTMLButtonElement>('button[title="Add color selection"]')
|
|
60
|
+
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
61
|
+
});
|
|
62
|
+
expect(onCommit.mock.calls[0]?.[0]).toHaveLength(1);
|
|
63
|
+
expect(onCommit.mock.calls[0]?.[0]?.[0]).toMatchObject({ enabled: true });
|
|
64
|
+
act(() => root.unmount());
|
|
65
|
+
|
|
66
|
+
const maximum = renderSecondary({ secondaries: normalizedSecondaries(4) });
|
|
67
|
+
expect(
|
|
68
|
+
maximum.host.querySelector<HTMLButtonElement>('button[title="Add secondary color selection"]')
|
|
69
|
+
?.disabled,
|
|
70
|
+
).toBe(true);
|
|
71
|
+
act(() => maximum.root.unmount());
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("bypasses a secondary without deleting its qualifier or correction", () => {
|
|
75
|
+
const secondaries = normalizedSecondaries(1).map((secondary) => ({
|
|
76
|
+
...secondary,
|
|
77
|
+
correction: { ...secondary.correction, luma: 0.2 },
|
|
78
|
+
}));
|
|
79
|
+
const { host, root, onCommit } = renderSecondary({ secondaries });
|
|
80
|
+
|
|
81
|
+
act(() => {
|
|
82
|
+
host
|
|
83
|
+
.querySelector<HTMLButtonElement>('[role="switch"][aria-label="Selection enabled"]')
|
|
84
|
+
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(onCommit.mock.calls[0]?.[0]?.[0]).toMatchObject({
|
|
88
|
+
enabled: false,
|
|
89
|
+
correction: { luma: 0.2 },
|
|
90
|
+
key: secondaries[0]?.key,
|
|
91
|
+
});
|
|
92
|
+
act(() => root.unmount());
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("keeps saturation and luma ranges strictly ordered", () => {
|
|
96
|
+
const { host, root, onCommit } = renderSecondary();
|
|
97
|
+
|
|
98
|
+
act(() => {
|
|
99
|
+
host
|
|
100
|
+
.querySelector<HTMLElement>('[role="slider"][aria-label="Saturation min"]')
|
|
101
|
+
?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
|
|
102
|
+
});
|
|
103
|
+
expect(onCommit.mock.calls.at(-1)?.[0]?.[0]?.key.saturation).toMatchObject({
|
|
104
|
+
min: 0.99,
|
|
105
|
+
max: 1,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
act(() => {
|
|
109
|
+
host
|
|
110
|
+
.querySelector<HTMLElement>('[role="slider"][aria-label="Luma max"]')
|
|
111
|
+
?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
|
|
112
|
+
});
|
|
113
|
+
expect(onCommit.mock.calls.at(-1)?.[0]?.[0]?.key.luma).toMatchObject({
|
|
114
|
+
min: 0,
|
|
115
|
+
max: 0.01,
|
|
116
|
+
});
|
|
117
|
+
act(() => root.unmount());
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("preserves legal fractional hue centers at the wrap boundary", () => {
|
|
121
|
+
const grading = normalizeHfColorGrading({
|
|
122
|
+
secondaries: [
|
|
123
|
+
{
|
|
124
|
+
key: { hue: { center: 359.7, range: 20 } },
|
|
125
|
+
correction: {},
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
if (!grading?.secondaries) throw new Error("Expected normalized secondaries");
|
|
130
|
+
const { host, root } = renderSecondary({ secondaries: grading.secondaries });
|
|
131
|
+
const hue = host.querySelector<HTMLElement>('[role="slider"][aria-label="Hue"]');
|
|
132
|
+
|
|
133
|
+
expect(Number(hue?.getAttribute("aria-valuenow"))).toBeCloseTo(359.7);
|
|
134
|
+
expect(hue?.getAttribute("aria-valuemax")).toBe("359.99");
|
|
135
|
+
act(() => root.unmount());
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("shows a useful error when frame capture is unavailable", async () => {
|
|
139
|
+
const { host, root, onCommit } = renderSecondary({
|
|
140
|
+
captureFrame: vi.fn().mockRejectedValue(new Error("capture unavailable")),
|
|
141
|
+
});
|
|
142
|
+
await captureSecondaryFrame(host);
|
|
143
|
+
|
|
144
|
+
expect(host.querySelector('[role="alert"]')?.textContent).toContain(
|
|
145
|
+
"Preview frame unavailable",
|
|
146
|
+
);
|
|
147
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
148
|
+
act(() => root.unmount());
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("decodes a captured frame once and samples its center from the keyboard", async () => {
|
|
152
|
+
const pixels = new Uint8ClampedArray(4 * 4 * 4);
|
|
153
|
+
for (let offset = 0; offset < pixels.length; offset += 4) {
|
|
154
|
+
pixels[offset] = 255;
|
|
155
|
+
pixels[offset + 3] = 255;
|
|
156
|
+
}
|
|
157
|
+
const decode = vi.spyOn(frameAnalysis, "readColorGradingFramePixels").mockResolvedValue(pixels);
|
|
158
|
+
const { host, root, onCommit } = renderSecondary({
|
|
159
|
+
captureFrame: vi.fn().mockResolvedValue({
|
|
160
|
+
dataUrl: "data:image/png;base64,",
|
|
161
|
+
width: 4,
|
|
162
|
+
height: 4,
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
await captureSecondaryFrame(host);
|
|
166
|
+
|
|
167
|
+
await act(async () => {
|
|
168
|
+
host
|
|
169
|
+
.querySelector<HTMLButtonElement>('[aria-label="Sample color from captured frame"]')
|
|
170
|
+
?.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 }));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(decode).toHaveBeenCalledOnce();
|
|
174
|
+
expect(onCommit.mock.calls[0]?.[0]?.[0]?.key.hue.center).toBeCloseTo(0);
|
|
175
|
+
const matteToggle = Array.from(
|
|
176
|
+
host.querySelectorAll<HTMLButtonElement>('button[aria-pressed="true"]'),
|
|
177
|
+
).find((button) => button.textContent?.includes("Selection matte"));
|
|
178
|
+
expect(matteToggle).toBeDefined();
|
|
179
|
+
act(() => root.unmount());
|
|
180
|
+
});
|
|
181
|
+
});
|