@hyperframes/studio 0.7.72 → 0.7.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/assets/{hyperframes-player-Bjf2HPzR.js → hyperframes-player-wiJqS2i-.js} +1 -1
  2. package/dist/assets/index-B37rWXo4.css +1 -0
  3. package/dist/assets/{index-Ct_pxETK.js → index-BXepgTJb.js} +1 -1
  4. package/dist/assets/index-CBDJuOGW.js +428 -0
  5. package/dist/assets/{index-Ch1hbJ3e.js → index-DGVNG1dd.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +4203 -2281
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  11. package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
  12. package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
  13. package/src/components/editor/propertyPanelColorCurveGraph.tsx +549 -0
  14. package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
  15. package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
  16. package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
  17. package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
  18. package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
  19. package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
  20. package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
  21. package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
  22. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
  23. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +168 -3
  24. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +184 -243
  25. package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
  26. package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
  27. package/src/components/editor/useColorGradingController.test.ts +133 -17
  28. package/src/components/editor/useColorGradingController.ts +11 -2
  29. package/src/components/editor/useColorGradingPreviews.ts +54 -11
  30. package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
  31. package/src/components/editor/useColorGradingScopes.ts +62 -0
  32. package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
  33. package/src/icons/SystemIcons.tsx +4 -0
  34. package/dist/assets/index-BgZMle9I.css +0 -1
  35. package/dist/assets/index-DCyWLHnx.js +0 -428
@@ -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
+ });