@hyperframes/studio 0.7.70 → 0.7.72

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 (62) hide show
  1. package/dist/assets/{hyperframes-player-DpKeYLEo.js → hyperframes-player-Bjf2HPzR.js} +1 -1
  2. package/dist/assets/index-BgZMle9I.css +1 -0
  3. package/dist/assets/{index-DXjAllII.js → index-Ch1hbJ3e.js} +1 -1
  4. package/dist/assets/{index-CbX-xoud.js → index-Ct_pxETK.js} +1 -1
  5. package/dist/assets/index-DCyWLHnx.js +428 -0
  6. package/dist/index.d.ts +8 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5383 -3804
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +4 -4
  12. package/src/components/StudioLeftSidebar.tsx +2 -3
  13. package/src/components/StudioRightPanel.tsx +10 -4
  14. package/src/components/editor/AnimationCard.test.tsx +100 -0
  15. package/src/components/editor/AnimationCard.tsx +5 -2
  16. package/src/components/editor/EaseCurveSection.test.tsx +348 -0
  17. package/src/components/editor/EaseCurveSection.tsx +444 -237
  18. package/src/components/editor/EaseParamFields.test.tsx +188 -0
  19. package/src/components/editor/EaseParamFields.tsx +246 -0
  20. package/src/components/editor/PropertyPanel.test.tsx +9 -7
  21. package/src/components/editor/PropertyPanelFlat.tsx +74 -88
  22. package/src/components/editor/colorGradingScopePatch.test.ts +3 -3
  23. package/src/components/editor/easeCurveSvg.tsx +65 -0
  24. package/src/components/editor/easePresetLibrary.test.ts +114 -0
  25. package/src/components/editor/easePresetLibrary.ts +49 -0
  26. package/src/components/editor/gsapAnimationConstants.ts +15 -13
  27. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +73 -40
  28. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +85 -66
  29. package/src/components/editor/propertyPanelFlatEffectControl.tsx +96 -0
  30. package/src/components/editor/propertyPanelFlatEffectSpecs.ts +305 -0
  31. package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +331 -0
  32. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +503 -0
  33. package/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx +113 -0
  34. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +108 -0
  35. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +5 -5
  36. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -0
  37. package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
  38. package/src/components/editor/propertyPanelTypes.ts +13 -0
  39. package/src/components/editor/useColorGradingController.test.ts +235 -30
  40. package/src/components/editor/useColorGradingController.ts +46 -88
  41. package/src/components/editor/useColorGradingPreviews.ts +307 -0
  42. package/src/components/nle/NLEContext.tsx +1 -1
  43. package/src/contexts/PanelLayoutContext.tsx +3 -6
  44. package/src/hooks/useBlockCatalog.ts +31 -13
  45. package/src/hooks/useBlockHandlers.ts +22 -0
  46. package/src/hooks/useGsapScriptCommits.test.tsx +15 -0
  47. package/src/hooks/useGsapScriptCommits.ts +14 -1
  48. package/src/hooks/usePanelLayout.test.ts +68 -1
  49. package/src/hooks/usePanelLayout.ts +72 -34
  50. package/src/player/components/PlayerControls.tsx +68 -229
  51. package/src/player/components/ShortcutsPanel.tsx +2 -4
  52. package/src/player/components/SpeedMenu.tsx +1 -2
  53. package/src/telemetry/events.test.ts +9 -0
  54. package/src/telemetry/events.ts +5 -0
  55. package/src/utils/blockInstaller.test.ts +67 -0
  56. package/src/utils/blockInstaller.ts +162 -109
  57. package/src/utils/studioUiPreferences.test.ts +5 -0
  58. package/src/utils/studioUiPreferences.ts +8 -0
  59. package/dist/assets/index-CAeU317U.js +0 -428
  60. package/dist/assets/index-Ceyz8Qt2.css +0 -1
  61. package/src/player/components/PlayerControls.test.ts +0 -20
  62. package/src/player/components/useSeekBarDrag.ts +0 -169
@@ -0,0 +1,503 @@
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS,
4
+ HF_COLOR_GRADING_EFFECT_PRESETS,
5
+ HF_COLOR_GRADING_PALETTES,
6
+ normalizeHfColorGrading,
7
+ type HfColorGradingActiveEffectKey,
8
+ type HfColorGradingEffectKey,
9
+ type NormalizedHfColorGrading,
10
+ } from "@hyperframes/core/color-grading";
11
+ import { Plus, RotateCcw, X } from "../../icons/SystemIcons";
12
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
13
+ import { FLAT_PREVIEW_GRID, FlatSlider } from "./propertyPanelFlatPrimitives";
14
+ import type {
15
+ ColorGradingPresetPreviews,
16
+ ColorGradingPreviewOptions,
17
+ } from "./useColorGradingController";
18
+ import {
19
+ DEFAULT_EFFECTS,
20
+ EFFECT_GROUPS,
21
+ EFFECT_SPECS,
22
+ type EffectControl,
23
+ type EffectSpec,
24
+ } from "./propertyPanelFlatEffectSpecs";
25
+ import { FlatEffectControl } from "./propertyPanelFlatEffectControl";
26
+ import { presetPreviewHandlers } from "./propertyPanelPresetPreview";
27
+
28
+ export function activeColorGradingEffectCount(grading: NormalizedHfColorGrading): number {
29
+ return EFFECT_SPECS.filter((effect) => grading.effects[effect.key] > 0.0001).length;
30
+ }
31
+
32
+ export function FlatEffectsAccessory({
33
+ grading,
34
+ onCommitColorGrading,
35
+ }: {
36
+ grading: NormalizedHfColorGrading;
37
+ onCommitColorGrading: (next: NormalizedHfColorGrading) => void;
38
+ }) {
39
+ const track = useTrackDesignInput();
40
+ if (!activeColorGradingEffectCount(grading)) return null;
41
+ return (
42
+ <button
43
+ type="button"
44
+ data-flat-effects-reset="true"
45
+ title="Reset effects"
46
+ onClick={(event) => {
47
+ event.stopPropagation();
48
+ track("button", "Reset effects");
49
+ onCommitColorGrading({ ...grading, effects: { ...DEFAULT_EFFECTS }, palette: null });
50
+ }}
51
+ className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1"
52
+ >
53
+ <RotateCcw size={12} />
54
+ </button>
55
+ );
56
+ }
57
+
58
+ export function FlatEffectsSection({
59
+ grading,
60
+ previews,
61
+ presetPreviews,
62
+ onCommitColorGrading,
63
+ onPreviewColorGrading,
64
+ onRequestEffectPreviews,
65
+ onRequestPresetPreviews,
66
+ }: {
67
+ grading: NormalizedHfColorGrading;
68
+ previews: ColorGradingPresetPreviews;
69
+ presetPreviews: ColorGradingPresetPreviews;
70
+ onCommitColorGrading: (next: NormalizedHfColorGrading) => void;
71
+ onPreviewColorGrading: (
72
+ next: NormalizedHfColorGrading | null,
73
+ options?: ColorGradingPreviewOptions,
74
+ ) => void;
75
+ onRequestEffectPreviews: (effects: readonly HfColorGradingActiveEffectKey[]) => void;
76
+ onRequestPresetPreviews: () => void;
77
+ }) {
78
+ const track = useTrackDesignInput();
79
+ const activeEffects = EFFECT_SPECS.filter((effect) => grading.effects[effect.key] > 0.0001);
80
+ const [catalogOpen, setCatalogOpen] = useState(activeEffects.length === 0);
81
+ const [catalogGroup, setCatalogGroup] = useState(EFFECT_GROUPS[0].label);
82
+ const [selectedKey, setSelectedKey] = useState<HfColorGradingActiveEffectKey | null>(
83
+ activeEffects[0]?.key ?? null,
84
+ );
85
+ const selectedEffect =
86
+ activeEffects.find((effect) => effect.key === selectedKey) ?? activeEffects[0] ?? null;
87
+
88
+ useEffect(() => {
89
+ if (!catalogOpen) return;
90
+ const group = EFFECT_GROUPS.find((candidate) => candidate.label === catalogGroup);
91
+ if (!group) return;
92
+ const effectKeys = group.effects.map((effect) => effect.key);
93
+ if (previews.status !== "loading" && effectKeys.some((effect) => !previews.images[effect])) {
94
+ onRequestEffectPreviews(effectKeys);
95
+ }
96
+ if (
97
+ group.presets?.some((preset) => !presetPreviews.images[preset]) &&
98
+ presetPreviews.status !== "loading"
99
+ ) {
100
+ onRequestPresetPreviews();
101
+ }
102
+ }, [
103
+ catalogGroup,
104
+ catalogOpen,
105
+ onRequestEffectPreviews,
106
+ onRequestPresetPreviews,
107
+ presetPreviews.images,
108
+ presetPreviews.status,
109
+ previews.images,
110
+ previews.status,
111
+ ]);
112
+ useEffect(() => () => onPreviewColorGrading(null), [onPreviewColorGrading]);
113
+
114
+ const commitEffects = (effects: NormalizedHfColorGrading["effects"]) => {
115
+ onCommitColorGrading({
116
+ ...grading,
117
+ effects,
118
+ });
119
+ };
120
+ const commitEffect = (key: HfColorGradingEffectKey, value: number) => {
121
+ commitEffects({ ...grading.effects, [key]: value });
122
+ };
123
+ const resolveEffect = (effect: EffectSpec): NormalizedHfColorGrading => ({
124
+ ...grading,
125
+ effects: {
126
+ ...grading.effects,
127
+ ...HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS[effect.key],
128
+ },
129
+ });
130
+ const applyEffect = (effect: EffectSpec) => {
131
+ track("button", `Add ${effect.label}`);
132
+ onCommitColorGrading(resolveEffect(effect));
133
+ setSelectedKey(effect.key);
134
+ setCatalogOpen(false);
135
+ };
136
+ const resolvePreset = (presetId: string) =>
137
+ normalizeHfColorGrading({ preset: presetId, lut: grading.lut }) ?? grading;
138
+ const removeEffect = (effect: EffectSpec) => {
139
+ track("button", `Remove ${effect.label}`);
140
+ commitEffect(effect.key, 0);
141
+ setSelectedKey(null);
142
+ };
143
+
144
+ const renderPalette = (kind: "mono" | "art") => {
145
+ const fallback = kind === "art" ? ["#1a1a1a", "#f5f5dc"] : ["#000000", "#ffffff"];
146
+ const palette = grading.palette;
147
+ return (
148
+ <div data-flat-effects-palette="true" className="space-y-2 py-1">
149
+ <div className="grid grid-cols-3 gap-1">
150
+ {HF_COLOR_GRADING_PALETTES.map((preset) => {
151
+ const selected =
152
+ palette?.length === preset.colors.length &&
153
+ palette.every((color, index) => color === preset.colors[index]);
154
+ return (
155
+ <button
156
+ key={preset.id}
157
+ type="button"
158
+ title={`${preset.group}: ${preset.label}`}
159
+ data-flat-effects-palette-preset={preset.id}
160
+ aria-pressed={selected}
161
+ onClick={() => {
162
+ track("button", `Use ${preset.label} palette`);
163
+ onCommitColorGrading({ ...grading, palette: [...preset.colors] });
164
+ }}
165
+ className={`min-w-0 border p-1 text-left ${
166
+ selected
167
+ ? "border-panel-accent bg-panel-accent/10"
168
+ : "border-panel-hairline hover:border-panel-border-input"
169
+ }`}
170
+ >
171
+ <span className="mb-1 flex h-3 overflow-hidden rounded-[2px]">
172
+ {preset.colors.map((color) => (
173
+ <span key={color} className="flex-1" style={{ backgroundColor: color }} />
174
+ ))}
175
+ </span>
176
+ <span className="block truncate text-[8px] text-panel-text-3">{preset.label}</span>
177
+ </button>
178
+ );
179
+ })}
180
+ </div>
181
+ {!palette ? (
182
+ <button
183
+ type="button"
184
+ data-flat-effects-add-palette="true"
185
+ onClick={() => {
186
+ track("button", "Customize effect palette");
187
+ onCommitColorGrading({ ...grading, palette: fallback });
188
+ }}
189
+ className="flex min-h-[28px] items-center gap-1 text-[10px] font-medium text-panel-accent hover:text-panel-accent/80"
190
+ >
191
+ <Plus size={11} /> Custom palette
192
+ </button>
193
+ ) : (
194
+ <>
195
+ <div className="flex items-center justify-between">
196
+ <span className="text-[11px] text-panel-text-3">Custom palette</span>
197
+ <button
198
+ type="button"
199
+ title="Use default palette"
200
+ onClick={() => onCommitColorGrading({ ...grading, palette: null })}
201
+ className="text-panel-text-4 hover:text-panel-text-1"
202
+ >
203
+ <RotateCcw size={11} />
204
+ </button>
205
+ </div>
206
+ <div className="flex flex-wrap items-center gap-1.5">
207
+ {palette.map((color, index) => (
208
+ <span key={`${index}-${color}`} className="group/swatch relative">
209
+ <input
210
+ type="color"
211
+ aria-label={`Palette color ${index + 1}`}
212
+ value={color}
213
+ onChange={(event) => {
214
+ const nextPalette = [...palette];
215
+ nextPalette[index] = event.target.value;
216
+ onCommitColorGrading({ ...grading, palette: nextPalette });
217
+ }}
218
+ className="h-6 w-6 cursor-pointer rounded-sm border border-panel-border-input bg-transparent p-0"
219
+ />
220
+ {palette.length > 2 && (
221
+ <button
222
+ type="button"
223
+ aria-label={`Remove palette color ${index + 1}`}
224
+ onClick={() =>
225
+ onCommitColorGrading({
226
+ ...grading,
227
+ palette: palette.filter((_, colorIndex) => colorIndex !== index),
228
+ })
229
+ }
230
+ className="absolute -right-1 -top-1 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-panel-bg text-panel-text-2 shadow group-hover/swatch:flex"
231
+ >
232
+ <X size={8} />
233
+ </button>
234
+ )}
235
+ </span>
236
+ ))}
237
+ {palette.length < 6 && (
238
+ <button
239
+ type="button"
240
+ aria-label="Add palette color"
241
+ onClick={() =>
242
+ onCommitColorGrading({
243
+ ...grading,
244
+ palette: [...palette, palette.at(-1) ?? "#ffffff"],
245
+ })
246
+ }
247
+ className="flex h-6 w-6 items-center justify-center rounded-sm border border-panel-border-input text-panel-text-4 hover:text-panel-text-1"
248
+ >
249
+ <Plus size={11} />
250
+ </button>
251
+ )}
252
+ </div>
253
+ </>
254
+ )}
255
+ </div>
256
+ );
257
+ };
258
+
259
+ return (
260
+ <div className="space-y-2" data-flat-effects-section="true">
261
+ {activeEffects.length > 0 && (
262
+ <div data-flat-effects-active-list="true" className="space-y-1">
263
+ {activeEffects.map((effect) => {
264
+ const selected = selectedEffect?.key === effect.key;
265
+ return (
266
+ <button
267
+ key={effect.key}
268
+ type="button"
269
+ data-flat-effect-active={effect.key}
270
+ aria-pressed={selected}
271
+ onClick={() => setSelectedKey(effect.key)}
272
+ className={`flex min-h-[30px] w-full items-center justify-between border px-2 text-left text-[10px] transition-colors ${
273
+ selected
274
+ ? "border-panel-accent bg-panel-accent/10 text-panel-text-0"
275
+ : "border-panel-hairline bg-panel-bg-soft text-panel-text-3 hover:border-panel-border-input hover:text-panel-text-1"
276
+ }`}
277
+ >
278
+ <span>{effect.label}</span>
279
+ <span className="text-[9px] text-panel-text-5">
280
+ {selected ? "Editing" : "Active"}
281
+ </span>
282
+ </button>
283
+ );
284
+ })}
285
+ </div>
286
+ )}
287
+
288
+ {selectedEffect && (
289
+ <div
290
+ data-flat-effect-editor={selectedEffect.key}
291
+ className="space-y-1 border-l-2 border-panel-border-input pl-2.5"
292
+ >
293
+ <div className="flex min-h-[26px] items-center justify-between gap-2">
294
+ <span className="text-[11px] font-medium text-panel-text-1">
295
+ {selectedEffect.label}
296
+ </span>
297
+ <span className="flex items-center gap-2">
298
+ <button
299
+ type="button"
300
+ title={`Reset ${selectedEffect.label}`}
301
+ onClick={() => applyEffect(selectedEffect)}
302
+ className="text-panel-text-4 hover:text-panel-text-1"
303
+ >
304
+ <RotateCcw size={11} />
305
+ </button>
306
+ <button
307
+ type="button"
308
+ title={`Remove ${selectedEffect.label}`}
309
+ onClick={() => removeEffect(selectedEffect)}
310
+ className="text-panel-text-4 hover:text-red-300"
311
+ >
312
+ <X size={11} />
313
+ </button>
314
+ </span>
315
+ </div>
316
+ {selectedEffect.showMaster !== false && (
317
+ <FlatSlider
318
+ label={selectedEffect.masterLabel ?? "Mix"}
319
+ value={grading.effects[selectedEffect.key] * 100}
320
+ min={0}
321
+ max={selectedEffect.max ?? 100}
322
+ tier="explicitCustom"
323
+ displayValue={
324
+ selectedEffect.masterFormat?.(grading.effects[selectedEffect.key]) ??
325
+ `${Math.round(grading.effects[selectedEffect.key] * 100)}%`
326
+ }
327
+ onCommit={(next) => commitEffect(selectedEffect.key, next / 100)}
328
+ onReset={() =>
329
+ commitEffect(
330
+ selectedEffect.key,
331
+ HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS[selectedEffect.key][selectedEffect.key] ??
332
+ 1,
333
+ )
334
+ }
335
+ />
336
+ )}
337
+ {selectedEffect.settings?.map((control: EffectControl) => (
338
+ <FlatEffectControl
339
+ key={control.key}
340
+ control={control}
341
+ effect={selectedEffect}
342
+ effects={grading.effects}
343
+ onCommit={commitEffect}
344
+ />
345
+ ))}
346
+ {selectedEffect.palette && renderPalette(selectedEffect.palette)}
347
+ </div>
348
+ )}
349
+
350
+ <button
351
+ type="button"
352
+ data-flat-effects-add-toggle="true"
353
+ aria-expanded={catalogOpen}
354
+ onClick={() => setCatalogOpen((open) => !open)}
355
+ className="flex min-h-[30px] items-center gap-1 text-[10px] font-medium text-panel-accent hover:text-panel-accent/80"
356
+ >
357
+ <Plus size={11} /> Add effect
358
+ </button>
359
+
360
+ {catalogOpen && (
361
+ <div data-flat-effects-catalog="true" className="space-y-2">
362
+ <div
363
+ role="tablist"
364
+ aria-label="Effect families"
365
+ className="grid grid-cols-4 gap-px overflow-hidden border border-panel-hairline bg-panel-hairline"
366
+ >
367
+ {EFFECT_GROUPS.map((group) => (
368
+ <button
369
+ key={group.label}
370
+ type="button"
371
+ role="tab"
372
+ aria-selected={catalogGroup === group.label}
373
+ data-flat-effect-group={group.label}
374
+ onClick={() => setCatalogGroup(group.label)}
375
+ className={`min-h-[25px] min-w-0 truncate bg-panel-bg px-2 text-[9px] ${
376
+ catalogGroup === group.label
377
+ ? "font-medium text-panel-accent"
378
+ : "text-panel-text-4 hover:text-panel-text-1"
379
+ }`}
380
+ >
381
+ {group.label}
382
+ </button>
383
+ ))}
384
+ </div>
385
+ {EFFECT_GROUPS.filter((group) => group.label === catalogGroup).map((group) => (
386
+ <section key={group.label} className="space-y-1" role="tabpanel">
387
+ <div className={FLAT_PREVIEW_GRID}>
388
+ {group.presets?.map((presetId) => {
389
+ const preset = HF_COLOR_GRADING_EFFECT_PRESETS.find(
390
+ (candidate) => candidate.id === presetId,
391
+ );
392
+ if (!preset) return null;
393
+ const selected = grading.preset === preset.id;
394
+ const preview = presetPreviews.images[preset.id];
395
+ return (
396
+ <button
397
+ key={preset.id}
398
+ type="button"
399
+ data-flat-effect-preset={preset.id}
400
+ aria-pressed={selected}
401
+ {...presetPreviewHandlers({
402
+ id: preset.id,
403
+ label: preset.label,
404
+ resolve: () => resolvePreset(preset.id),
405
+ onPreview: onPreviewColorGrading,
406
+ onCommit: onCommitColorGrading,
407
+ onTrack: (name) => track("button", `Apply ${name}`),
408
+ })}
409
+ className={`min-w-0 overflow-hidden border text-left text-[10px] transition-colors ${
410
+ selected
411
+ ? "border-panel-accent bg-panel-accent/10 text-panel-text-0"
412
+ : "border-panel-hairline bg-panel-bg-soft text-panel-text-3 hover:border-panel-border-input hover:text-panel-text-1"
413
+ }`}
414
+ >
415
+ <span
416
+ className="flex w-full items-center justify-center overflow-hidden bg-black/20"
417
+ style={{
418
+ aspectRatio: `${presetPreviews.width} / ${presetPreviews.height}`,
419
+ }}
420
+ >
421
+ {preview ? (
422
+ <img
423
+ data-flat-effect-preset-preview={preset.id}
424
+ src={preview}
425
+ alt=""
426
+ draggable={false}
427
+ className="block h-full w-full object-contain"
428
+ />
429
+ ) : (
430
+ <span
431
+ data-flat-effect-preset-placeholder={presetPreviews.status}
432
+ className="h-full w-full bg-panel-bg-soft"
433
+ />
434
+ )}
435
+ </span>
436
+ <span className="block truncate px-2 py-1.5">{preset.label}</span>
437
+ </button>
438
+ );
439
+ })}
440
+ {group.effects.map((effect) => {
441
+ const active = grading.effects[effect.key] > 0.0001;
442
+ const preview = previews.images[effect.key];
443
+ return (
444
+ <button
445
+ key={effect.key}
446
+ type="button"
447
+ data-flat-effect-option={effect.key}
448
+ aria-pressed={active}
449
+ title={`Preview ${effect.label}`}
450
+ onPointerEnter={() =>
451
+ onPreviewColorGrading(resolveEffect(effect), {
452
+ animatedPreview: { kind: "effects", id: effect.key },
453
+ })
454
+ }
455
+ onPointerLeave={() => onPreviewColorGrading(null)}
456
+ onFocus={() => onPreviewColorGrading(resolveEffect(effect))}
457
+ onBlur={() => onPreviewColorGrading(null)}
458
+ onClick={() => {
459
+ if (active) {
460
+ setSelectedKey(effect.key);
461
+ setCatalogOpen(false);
462
+ } else {
463
+ applyEffect(effect);
464
+ }
465
+ }}
466
+ className={`min-w-0 overflow-hidden border text-left text-[10px] transition-colors ${
467
+ active
468
+ ? "border-panel-accent bg-panel-accent/10 text-panel-text-1"
469
+ : "border-panel-hairline bg-panel-bg-soft text-panel-text-3 hover:border-panel-border-input hover:text-panel-text-1"
470
+ }`}
471
+ >
472
+ <span
473
+ data-flat-effect-preview-frame={effect.key}
474
+ className="flex w-full items-center justify-center overflow-hidden bg-black/20"
475
+ style={{ aspectRatio: `${previews.width} / ${previews.height}` }}
476
+ >
477
+ {preview ? (
478
+ <img
479
+ data-flat-effect-preview={effect.key}
480
+ src={preview}
481
+ alt=""
482
+ draggable={false}
483
+ className="block h-full w-full object-contain"
484
+ />
485
+ ) : (
486
+ <span
487
+ data-flat-effect-preview-placeholder={previews.status}
488
+ className="h-full w-full bg-panel-bg-soft"
489
+ />
490
+ )}
491
+ </span>
492
+ <span className="block truncate px-2 py-1.5">{effect.label}</span>
493
+ </button>
494
+ );
495
+ })}
496
+ </div>
497
+ </section>
498
+ ))}
499
+ </div>
500
+ )}
501
+ </div>
502
+ );
503
+ }
@@ -0,0 +1,113 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import type { RegistryItem } from "@hyperframes/core/registry";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import {
8
+ deriveMediaOverlayPlacement,
9
+ filterMediaTreatmentOverlays,
10
+ FlatOverlaysSection,
11
+ MEDIA_TREATMENT_OVERLAY_TAG,
12
+ } from "./propertyPanelFlatOverlaysSection";
13
+
14
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ function registryItem(
17
+ name: string,
18
+ tags: string[],
19
+ type: RegistryItem["type"] = "hyperframes:component",
20
+ preview?: RegistryItem["preview"],
21
+ ): RegistryItem {
22
+ const base = {
23
+ name,
24
+ title: name,
25
+ description: `${name} description`,
26
+ tags,
27
+ preview,
28
+ files: [{ path: `${name}.html`, target: `${name}.html`, type: "hyperframes:snippet" as const }],
29
+ };
30
+ if (type === "hyperframes:component") return { ...base, type };
31
+ if (type === "hyperframes:block") {
32
+ return { ...base, type, dimensions: { width: 1920, height: 1080 }, duration: 2 };
33
+ }
34
+ return { ...base, type, dimensions: { width: 1920, height: 1080 }, duration: 2 };
35
+ }
36
+
37
+ afterEach(() => {
38
+ document.body.innerHTML = "";
39
+ vi.unstubAllGlobals();
40
+ });
41
+
42
+ describe("FlatOverlaysSection", () => {
43
+ it("places an overlay over the selected media in its own composition", () => {
44
+ expect(
45
+ deriveMediaOverlayPlacement(
46
+ {
47
+ sourceFile: "compositions/scene.html",
48
+ dataAttributes: { "track-index": "2" },
49
+ },
50
+ { start: 1.5, duration: 3 },
51
+ ),
52
+ ).toEqual({
53
+ start: 1.5,
54
+ duration: 3,
55
+ track: 3,
56
+ compositionPath: "compositions/scene.html",
57
+ });
58
+ });
59
+
60
+ it("keeps only tagged Registry overlay blocks", () => {
61
+ const overlay = registryItem(
62
+ "camcorder-hud",
63
+ [MEDIA_TREATMENT_OVERLAY_TAG],
64
+ "hyperframes:block",
65
+ );
66
+ const ordinary = registryItem("caption", ["caption"]);
67
+ const taggedComponent = registryItem("scene", [MEDIA_TREATMENT_OVERLAY_TAG]);
68
+
69
+ expect(
70
+ filterMediaTreatmentOverlays([overlay, ordinary, taggedComponent]).map(({ name }) => name),
71
+ ).toEqual(["camcorder-hud"]);
72
+ });
73
+
74
+ it("loads tagged overlays and delegates installation by Registry name", async () => {
75
+ const items = [
76
+ registryItem("camcorder-hud", [MEDIA_TREATMENT_OVERLAY_TAG], "hyperframes:block", {
77
+ poster: "https://example.com/camcorder.png",
78
+ video: "https://example.com/camcorder.mp4",
79
+ }),
80
+ registryItem("ordinary-component", ["overlay"]),
81
+ ];
82
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => items }));
83
+ const onAddOverlay = vi.fn().mockResolvedValue(undefined);
84
+ const host = document.createElement("div");
85
+ document.body.append(host);
86
+ const root = createRoot(host);
87
+
88
+ await act(async () => {
89
+ root.render(<FlatOverlaysSection onAddOverlay={onAddOverlay} />);
90
+ await Promise.resolve();
91
+ await Promise.resolve();
92
+ });
93
+
94
+ expect(host.querySelector('[data-flat-overlay="ordinary-component"]')).toBeNull();
95
+ const add = host.querySelector<HTMLButtonElement>('[data-flat-overlay="camcorder-hud"]');
96
+ expect(add).not.toBeNull();
97
+ expect(
98
+ host.querySelector<HTMLImageElement>('[data-flat-overlay-preview="camcorder-hud"]')?.src,
99
+ ).toBe("https://example.com/camcorder.png");
100
+ expect(host.querySelector('[data-flat-overlays="true"]')?.className).toContain("auto-fill");
101
+ act(() => add?.dispatchEvent(new MouseEvent("pointerover", { bubbles: true })));
102
+ expect(host.querySelector<HTMLVideoElement>("video")?.src).toBe(
103
+ "https://example.com/camcorder.mp4",
104
+ );
105
+ await act(async () => {
106
+ add?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
107
+ await Promise.resolve();
108
+ });
109
+ expect(onAddOverlay).toHaveBeenCalledWith("camcorder-hud");
110
+
111
+ act(() => root.unmount());
112
+ });
113
+ });