@hyperframes/studio 0.6.107 → 0.6.108

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 (37) hide show
  1. package/dist/assets/{hyperframes-player-DgsMQSvV.js → hyperframes-player-67pq7USK.js} +2 -2
  2. package/dist/assets/index-BVqybwMG.css +1 -0
  3. package/dist/assets/index-BgbVhDYd.js +296 -0
  4. package/dist/assets/{index-zIK7PWvk.js → index-Btg-jqxS.js} +1 -1
  5. package/dist/assets/{index-P52mbTRb.js → index-tn9M43-U.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/package.json +5 -5
  8. package/src/App.tsx +1 -0
  9. package/src/components/StudioRightPanel.tsx +161 -75
  10. package/src/components/editor/PropertyPanel.tsx +26 -1
  11. package/src/components/editor/domEditOverlayGeometry.ts +2 -11
  12. package/src/components/editor/domEditingDom.ts +21 -0
  13. package/src/components/editor/domEditingElement.ts +2 -11
  14. package/src/components/editor/manualEditingAvailability.test.ts +12 -0
  15. package/src/components/editor/manualEditingAvailability.ts +6 -0
  16. package/src/components/editor/propertyPanelColorGradingControls.tsx +475 -0
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +355 -0
  18. package/src/components/editor/propertyPanelHelpers.ts +2 -1
  19. package/src/components/editor/propertyPanelPrimitives.tsx +32 -37
  20. package/src/contexts/DomEditContext.tsx +4 -0
  21. package/src/contexts/PanelLayoutContext.tsx +6 -0
  22. package/src/hooks/useDomEditCommits.ts +7 -0
  23. package/src/hooks/useDomEditSession.ts +2 -2
  24. package/src/hooks/useDomEditTextCommits.ts +82 -26
  25. package/src/hooks/useDomEditWiring.ts +1 -0
  26. package/src/hooks/useDomSelection.ts +2 -10
  27. package/src/hooks/usePanelLayout.ts +26 -1
  28. package/src/hooks/useStudioContextValue.ts +8 -3
  29. package/src/icons/SystemIcons.tsx +4 -0
  30. package/src/player/lib/timelineDOM.test.ts +36 -0
  31. package/src/player/lib/timelineDOM.ts +2 -0
  32. package/src/player/lib/timelineElementHelpers.ts +18 -1
  33. package/src/styles/studio.css +81 -0
  34. package/src/utils/mediaTypes.ts +1 -0
  35. package/src/utils/studioHelpers.ts +6 -0
  36. package/dist/assets/index-BrhJl2JY.css +0 -1
  37. package/dist/assets/index-CVHV-S-B.js +0 -296
@@ -0,0 +1,475 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ HF_COLOR_GRADING_PRESETS,
4
+ normalizeHfColorGrading,
5
+ type HfColorGradingAdjustKey,
6
+ type NormalizedHfColorGrading,
7
+ } from "@hyperframes/core/color-grading";
8
+ import { Minus, Plus, RotateCcw } from "../../icons/SystemIcons";
9
+ import { LUT_EXT } from "../../utils/mediaTypes";
10
+ import { LABEL } from "./propertyPanelHelpers";
11
+
12
+ const LUT_UPLOAD_DIR = "assets/luts";
13
+ const SLIDER_THUMB_SIZE = 10;
14
+ const SLIDER_THUMB_RADIUS = SLIDER_THUMB_SIZE / 2;
15
+
16
+ const SLIDERS: Array<{
17
+ key: HfColorGradingAdjustKey;
18
+ label: string;
19
+ min: number;
20
+ max: number;
21
+ step: number;
22
+ scale: number;
23
+ suffix: string;
24
+ }> = [
25
+ { key: "exposure", label: "Exposure", min: -200, max: 200, step: 5, scale: 100, suffix: "" },
26
+ { key: "contrast", label: "Contrast", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
27
+ {
28
+ key: "highlights",
29
+ label: "Highlights",
30
+ min: -100,
31
+ max: 100,
32
+ step: 1,
33
+ scale: 100,
34
+ suffix: "%",
35
+ },
36
+ { key: "shadows", label: "Shadows", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
37
+ { key: "whites", label: "Whites", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
38
+ { key: "blacks", label: "Blacks", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
39
+ { key: "temperature", label: "Warmth", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
40
+ { key: "tint", label: "Tint", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
41
+ { key: "saturation", label: "Saturation", min: -100, max: 100, step: 1, scale: 100, suffix: "%" },
42
+ ];
43
+
44
+ function clampNumber(value: number, min: number, max: number): number {
45
+ if (!Number.isFinite(value)) return min;
46
+ return Math.min(max, Math.max(min, value));
47
+ }
48
+
49
+ function formatNumericInput(value: number, scale: number): string {
50
+ const scaled = value / scale;
51
+ return scale === 100 ? scaled.toFixed(2) : String(Math.round(scaled));
52
+ }
53
+
54
+ function parseNumericInput(value: string, scale: number): number | null {
55
+ const parsed = Number(value);
56
+ if (!Number.isFinite(parsed)) return null;
57
+ return parsed * scale;
58
+ }
59
+
60
+ function tickPercent(value: number, min: number, max: number): number {
61
+ if (max <= min) return 0;
62
+ return ((value - min) / (max - min)) * 100;
63
+ }
64
+
65
+ function ColorGradingSliderControl({
66
+ label,
67
+ value,
68
+ min,
69
+ max,
70
+ step,
71
+ neutral = min,
72
+ scale = 1,
73
+ suffix = "",
74
+ displayValue,
75
+ disabled,
76
+ onCommit,
77
+ onReset,
78
+ }: {
79
+ label: string;
80
+ value: number;
81
+ min: number;
82
+ max: number;
83
+ step: number;
84
+ neutral?: number;
85
+ scale?: number;
86
+ suffix?: string;
87
+ displayValue: string;
88
+ disabled?: boolean;
89
+ onCommit: (nextValue: number) => void;
90
+ onReset?: () => void;
91
+ }) {
92
+ const [draftState, setDraftState] = useState<{ value: number; source: number } | null>(null);
93
+ const [inputDraft, setInputDraft] = useState<{ value: string; source: number } | null>(null);
94
+ const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
95
+ const valueRef = useRef(value);
96
+ valueRef.current = value;
97
+
98
+ useEffect(
99
+ () => () => {
100
+ if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
101
+ },
102
+ [],
103
+ );
104
+
105
+ const clampDraft = useCallback(
106
+ (nextValue: number) => clampNumber(nextValue, min, max),
107
+ [max, min],
108
+ );
109
+
110
+ const setLocalDraft = useCallback(
111
+ (nextValue: number) => {
112
+ const clamped = clampDraft(nextValue);
113
+ const source = valueRef.current;
114
+ setDraftState({ value: clamped, source });
115
+ setInputDraft({ value: formatNumericInput(clamped, scale), source });
116
+ return clamped;
117
+ },
118
+ [clampDraft, scale],
119
+ );
120
+
121
+ const commitDraft = useCallback(
122
+ (nextValue: number) => {
123
+ const clamped = setLocalDraft(nextValue);
124
+ if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
125
+ if (clamped !== valueRef.current) onCommit(clamped);
126
+ },
127
+ [onCommit, setLocalDraft],
128
+ );
129
+
130
+ const scheduleCommit = useCallback(
131
+ (nextValue: number) => {
132
+ const clamped = setLocalDraft(nextValue);
133
+ if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
134
+ commitTimerRef.current = setTimeout(() => {
135
+ if (clamped !== valueRef.current) onCommit(clamped);
136
+ }, 40);
137
+ },
138
+ [onCommit, setLocalDraft],
139
+ );
140
+
141
+ const draft = draftState?.source === value ? draftState.value : value;
142
+ const inputValue =
143
+ inputDraft?.source === value ? inputDraft.value : formatNumericInput(draft, scale);
144
+
145
+ const commitInputDraft = useCallback(() => {
146
+ const parsed = parseNumericInput(inputValue, scale);
147
+ if (parsed === null) {
148
+ setInputDraft(null);
149
+ return;
150
+ }
151
+ commitDraft(parsed);
152
+ }, [commitDraft, inputValue, scale]);
153
+
154
+ const nudge = useCallback(
155
+ (direction: -1 | 1) => {
156
+ commitDraft(draft + step * direction);
157
+ },
158
+ [commitDraft, draft, step],
159
+ );
160
+
161
+ const range = max - min;
162
+ const valuePercent = range === 0 ? 0 : ((draft - min) / range) * 100;
163
+ const neutralPercent = range === 0 ? 0 : ((neutral - min) / range) * 100;
164
+ const fillLeft = Math.min(valuePercent, neutralPercent);
165
+ const fillWidth = Math.abs(valuePercent - neutralPercent);
166
+ const ticks = Array.from(new Set([min, neutral, max])).sort((a, b) => a - b);
167
+
168
+ return (
169
+ <div className="grid min-w-0 gap-1.5 rounded-md bg-panel-input/30 p-2">
170
+ <div className="flex min-w-0 items-center gap-1.5">
171
+ <span className={`${LABEL} min-w-0 flex-1 truncate`}>{label}</span>
172
+ {onReset && (
173
+ <button
174
+ type="button"
175
+ disabled={disabled}
176
+ aria-label={`Reset ${label}`}
177
+ onClick={(event) => {
178
+ event.stopPropagation();
179
+ onReset();
180
+ }}
181
+ className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-panel-text-5 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
182
+ title={`Reset ${label}`}
183
+ >
184
+ <RotateCcw size={11} />
185
+ </button>
186
+ )}
187
+ </div>
188
+
189
+ <div className="relative h-7 min-w-0">
190
+ <div
191
+ data-color-grading-slider-track="true"
192
+ className="pointer-events-none absolute inset-y-0 z-0"
193
+ style={{ left: SLIDER_THUMB_RADIUS, right: SLIDER_THUMB_RADIUS }}
194
+ >
195
+ {ticks.map((tick) => (
196
+ <div
197
+ key={tick}
198
+ data-color-grading-slider-tick="true"
199
+ className="absolute top-1/2 h-3 w-px -translate-y-1/2 bg-panel-text-3"
200
+ style={{ left: `${tickPercent(tick, min, max)}%` }}
201
+ title={String(tick / scale)}
202
+ />
203
+ ))}
204
+ <div className="absolute left-0 right-0 top-1/2 z-10 h-0.5 -translate-y-1/2 rounded-full bg-panel-border" />
205
+ <div
206
+ className="absolute top-1/2 z-20 h-0.5 -translate-y-1/2 rounded-full bg-studio-accent"
207
+ style={{ left: `${fillLeft}%`, width: `${fillWidth}%` }}
208
+ />
209
+ </div>
210
+ <input
211
+ type="range"
212
+ min={min}
213
+ max={max}
214
+ step={step}
215
+ value={draft}
216
+ disabled={disabled}
217
+ aria-label={label}
218
+ onChange={(event) => scheduleCommit(Number(event.currentTarget.value))}
219
+ onMouseUp={() => commitDraft(draft)}
220
+ onTouchEnd={() => commitDraft(draft)}
221
+ onBlur={() => commitDraft(draft)}
222
+ className="hf-color-grading-range absolute left-0 right-0 top-1/2 z-30 min-w-0 w-full -translate-y-1/2"
223
+ title={displayValue}
224
+ />
225
+ </div>
226
+
227
+ <div className="flex min-w-0 items-center justify-end gap-1.5">
228
+ <div className="flex flex-shrink-0 items-center rounded-md bg-panel-input px-1.5 py-1">
229
+ <input
230
+ type="number"
231
+ value={inputValue}
232
+ min={min / scale}
233
+ max={max / scale}
234
+ step={step / scale}
235
+ disabled={disabled}
236
+ onChange={(event) =>
237
+ setInputDraft({ value: event.currentTarget.value, source: valueRef.current })
238
+ }
239
+ onBlur={commitInputDraft}
240
+ onKeyDown={(event) => {
241
+ if (event.key === "Enter") {
242
+ event.currentTarget.blur();
243
+ return;
244
+ }
245
+ if (event.key === "ArrowUp") {
246
+ event.preventDefault();
247
+ nudge(1);
248
+ return;
249
+ }
250
+ if (event.key === "ArrowDown") {
251
+ event.preventDefault();
252
+ nudge(-1);
253
+ }
254
+ }}
255
+ className="hf-color-grading-number h-5 w-[38px] bg-transparent text-right text-[11px] font-medium tabular-nums text-panel-text-1 outline-none disabled:cursor-not-allowed"
256
+ title={displayValue}
257
+ />
258
+ {suffix && <span className="ml-0.5 text-[10px] text-panel-text-5">{suffix}</span>}
259
+ </div>
260
+ <div className="flex flex-shrink-0 overflow-hidden rounded-md bg-panel-input">
261
+ <button
262
+ type="button"
263
+ disabled={disabled}
264
+ aria-label={`Decrease ${label}`}
265
+ onClick={() => nudge(-1)}
266
+ className="flex h-7 w-5 items-center justify-center text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
267
+ title={`Decrease ${label}`}
268
+ >
269
+ <Minus size={11} />
270
+ </button>
271
+ <button
272
+ type="button"
273
+ disabled={disabled}
274
+ aria-label={`Increase ${label}`}
275
+ onClick={() => nudge(1)}
276
+ className="flex h-7 w-5 items-center justify-center border-l border-panel-border text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
277
+ title={`Increase ${label}`}
278
+ >
279
+ <Plus size={11} />
280
+ </button>
281
+ </div>
282
+ </div>
283
+ </div>
284
+ );
285
+ }
286
+
287
+ export function ColorGradingControls({
288
+ grading,
289
+ assets,
290
+ onImportAssets,
291
+ onCommitColorGrading,
292
+ }: {
293
+ grading: NormalizedHfColorGrading;
294
+ assets: string[];
295
+ onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
296
+ onCommitColorGrading: (nextGrading: NormalizedHfColorGrading) => void;
297
+ }) {
298
+ const lutInputRef = useRef<HTMLInputElement>(null);
299
+ const lutAssets = useMemo(
300
+ () => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)),
301
+ [assets],
302
+ );
303
+ const selectedLut = grading.lut?.src ?? "";
304
+ const selectedProjectLut = selectedLut ? (selectedLut.split("/").pop() ?? selectedLut) : null;
305
+
306
+ const applyPreset = (preset: string) => {
307
+ const next = normalizeHfColorGrading({ preset, intensity: 1 });
308
+ if (next) onCommitColorGrading(next);
309
+ };
310
+ const applyLut = (src: string | null, intensity = 1) => {
311
+ onCommitColorGrading({
312
+ ...grading,
313
+ intensity: 1,
314
+ lut: src ? { src, intensity } : null,
315
+ });
316
+ };
317
+ const updateLutIntensity = (value: number) => {
318
+ if (!grading.lut) return;
319
+ applyLut(grading.lut.src, value / 100);
320
+ };
321
+ const importLuts = async (files: FileList | null) => {
322
+ if (!files?.length || !onImportAssets) return;
323
+ const uploaded = await onImportAssets(files, LUT_UPLOAD_DIR);
324
+ const firstLut = uploaded.find((asset) => LUT_EXT.test(asset));
325
+ if (firstLut) applyLut(firstLut, 1);
326
+ };
327
+
328
+ return (
329
+ <div className="space-y-4">
330
+ <label className="grid min-w-0 gap-1.5">
331
+ <span className={LABEL}>Preset</span>
332
+ <select
333
+ value={String(grading.preset ?? "neutral")}
334
+ onChange={(event) => applyPreset(event.target.value)}
335
+ className="w-full min-w-0 rounded-md bg-panel-input px-3 py-2 text-[11px] font-medium text-panel-text-1 outline-none"
336
+ >
337
+ {HF_COLOR_GRADING_PRESETS.map((preset) => (
338
+ <option key={preset.id} value={preset.id}>
339
+ {preset.label}
340
+ </option>
341
+ ))}
342
+ </select>
343
+ </label>
344
+
345
+ <div className="grid min-w-0 gap-1.5">
346
+ <span className={LABEL}>LUT Filter</span>
347
+ <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_28px] gap-2">
348
+ <select
349
+ value={selectedLut}
350
+ onChange={(event) => {
351
+ const nextSrc = event.target.value;
352
+ applyLut(
353
+ nextSrc || null,
354
+ nextSrc && grading.lut?.src === nextSrc ? grading.lut.intensity : 1,
355
+ );
356
+ }}
357
+ className="w-full min-w-0 rounded-md bg-panel-input px-3 py-2 text-[11px] font-medium text-panel-text-1 outline-none"
358
+ title="Uploaded .cube LUT filter"
359
+ >
360
+ <option value="">None</option>
361
+ {lutAssets.length > 0 && (
362
+ <optgroup label="Uploaded LUTs">
363
+ {lutAssets.map((asset) => (
364
+ <option key={asset} value={asset}>
365
+ {asset.split("/").pop() ?? asset}
366
+ </option>
367
+ ))}
368
+ </optgroup>
369
+ )}
370
+ </select>
371
+ <button
372
+ type="button"
373
+ disabled={!onImportAssets}
374
+ onClick={(event) => {
375
+ event.stopPropagation();
376
+ lutInputRef.current?.click();
377
+ }}
378
+ className="flex h-8 w-8 items-center justify-center rounded-md bg-panel-input text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
379
+ title="Import .cube LUT"
380
+ aria-label="Import .cube LUT"
381
+ >
382
+ <Plus size={13} />
383
+ </button>
384
+ <input
385
+ ref={lutInputRef}
386
+ type="file"
387
+ accept=".cube"
388
+ multiple
389
+ className="hidden"
390
+ onChange={(event) => {
391
+ void importLuts(event.currentTarget.files);
392
+ event.currentTarget.value = "";
393
+ }}
394
+ />
395
+ </div>
396
+ {grading.lut && (
397
+ <div className="grid gap-2">
398
+ {selectedProjectLut && (
399
+ <div className="flex min-w-0 items-start gap-2 text-[10px] leading-4 text-panel-text-3">
400
+ <span className="mt-[5px] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-studio-accent" />
401
+ <span className="min-w-0">
402
+ <span className="font-medium text-panel-text-2">Uploaded LUT</span>
403
+ {` · ${selectedProjectLut}`}
404
+ </span>
405
+ </div>
406
+ )}
407
+ <ColorGradingSliderControl
408
+ label="LUT Strength"
409
+ value={Math.round((grading.lut.intensity ?? 1) * 100)}
410
+ min={0}
411
+ max={100}
412
+ step={1}
413
+ neutral={0}
414
+ suffix="%"
415
+ displayValue={`${Math.round((grading.lut.intensity ?? 1) * 100)}%`}
416
+ onCommit={updateLutIntensity}
417
+ onReset={() => updateLutIntensity(100)}
418
+ />
419
+ </div>
420
+ )}
421
+ </div>
422
+
423
+ <div className="grid min-w-0 grid-cols-2 gap-3">
424
+ {SLIDERS.map((slider, index) => {
425
+ const value = grading.adjust[slider.key] * slider.scale;
426
+ const isExposure = slider.key === "exposure";
427
+ return (
428
+ <div
429
+ key={slider.key}
430
+ className={
431
+ SLIDERS.length % 2 === 1 && index === SLIDERS.length - 1 ? "col-span-2" : ""
432
+ }
433
+ >
434
+ <ColorGradingSliderControl
435
+ label={slider.label}
436
+ value={Math.round(value)}
437
+ min={slider.min}
438
+ max={slider.max}
439
+ step={slider.step}
440
+ neutral={0}
441
+ scale={isExposure ? 100 : 1}
442
+ suffix={isExposure ? "" : slider.suffix}
443
+ displayValue={
444
+ isExposure
445
+ ? `${value > 0 ? "+" : ""}${(value / 100).toFixed(2)}`
446
+ : `${Math.round(value)}%`
447
+ }
448
+ onCommit={(next) => {
449
+ onCommitColorGrading({
450
+ ...grading,
451
+ intensity: 1,
452
+ adjust: {
453
+ ...grading.adjust,
454
+ [slider.key]: next / slider.scale,
455
+ },
456
+ });
457
+ }}
458
+ onReset={() => {
459
+ onCommitColorGrading({
460
+ ...grading,
461
+ intensity: 1,
462
+ adjust: {
463
+ ...grading.adjust,
464
+ [slider.key]: 0,
465
+ },
466
+ });
467
+ }}
468
+ />
469
+ </div>
470
+ );
471
+ })}
472
+ </div>
473
+ </div>
474
+ );
475
+ }