@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
@@ -1,15 +1,13 @@
1
- import { useRef, useCallback, useEffect, memo } from "react";
1
+ import { useRef, useEffect, memo } from "react";
2
2
  import gsap from "gsap";
3
3
  import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
4
- import { formatFrameTime, formatTime, stepFrameTime } from "../lib/time";
5
- import { usePlayerStore } from "../store/playerStore";
4
+ import { formatFrameTime, formatTime } from "../lib/time";
5
+ import { liveTime, usePlayerStore } from "../store/playerStore";
6
6
  import { trackStudioEvent } from "../../utils/studioTelemetry";
7
7
  import { Tooltip } from "../../components/ui";
8
+ import { useMountEffect } from "../../hooks/useMountEffect";
8
9
  import { ShortcutsPanel } from "./ShortcutsPanel";
9
10
  import { SpeedMenu } from "./SpeedMenu";
10
- import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag";
11
-
12
- export { resolveSeekPercent };
13
11
 
14
12
  /* ── Icon sub-components ─────────────────────────────────────────── */
15
13
 
@@ -78,10 +76,8 @@ const MuteButton = memo(function MuteButton({
78
76
  disabled={controlsDisabled}
79
77
  aria-label={label}
80
78
  aria-pressed={audioMuted}
81
- className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors disabled:pointer-events-none ${
82
- audioMuted
83
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
84
- : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
79
+ className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-30 ${
80
+ audioMuted ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
85
81
  }`}
86
82
  >
87
83
  <svg
@@ -131,10 +127,8 @@ const LoopButton = memo(function LoopButton({
131
127
  setLoopEnabled(!loopEnabled);
132
128
  }}
133
129
  disabled={disabled}
134
- className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
135
- loopEnabled
136
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
137
- : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
130
+ className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors disabled:opacity-30 ${
131
+ loopEnabled ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
138
132
  }`}
139
133
  aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
140
134
  aria-pressed={loopEnabled}
@@ -175,10 +169,8 @@ const FullscreenButton = memo(function FullscreenButton({
175
169
  trackStudioEvent("playback", { action: "fullscreen_toggle", active: !isFullscreen });
176
170
  onToggleFullscreen();
177
171
  }}
178
- className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors ${
179
- isFullscreen
180
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
181
- : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
172
+ className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md transition-colors ${
173
+ isFullscreen ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
182
174
  }`}
183
175
  aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
184
176
  >
@@ -214,118 +206,6 @@ const FullscreenButton = memo(function FullscreenButton({
214
206
  );
215
207
  });
216
208
 
217
- /* ── Seek bar sub-component ──────────────────────────────────────── */
218
-
219
- function SeekBarMarker({ position, duration }: { position: number; duration: number }) {
220
- if (duration <= 0) return null;
221
- return (
222
- <div
223
- className="absolute z-[3] pointer-events-none"
224
- style={{
225
- left: `${Math.min(100, (position / duration) * 100)}%`,
226
- top: "50%",
227
- transform: "translate(-50%, -50%)",
228
- width: "2px",
229
- height: "10px",
230
- background: "#3CE6AC",
231
- borderRadius: "1px",
232
- }}
233
- />
234
- );
235
- }
236
-
237
- function WorkAreaOverlay({
238
- inPoint,
239
- outPoint,
240
- duration,
241
- }: {
242
- inPoint: number | null;
243
- outPoint: number | null;
244
- duration: number;
245
- }) {
246
- if ((inPoint === null && outPoint === null) || duration <= 0) return null;
247
- return (
248
- <>
249
- <div
250
- className="absolute top-0 bottom-0 pointer-events-none"
251
- style={{
252
- left: `${inPoint !== null ? Math.min(100, (inPoint / duration) * 100) : 0}%`,
253
- right: `${outPoint !== null ? 100 - Math.min(100, (outPoint / duration) * 100) : 0}%`,
254
- background: "rgba(60,230,172,0.15)",
255
- }}
256
- />
257
- {inPoint !== null && <SeekBarMarker position={inPoint} duration={duration} />}
258
- {outPoint !== null && <SeekBarMarker position={outPoint} duration={duration} />}
259
- </>
260
- );
261
- }
262
-
263
- const SeekBar = memo(function SeekBar({
264
- disabled,
265
- duration,
266
- inPoint,
267
- outPoint,
268
- progressFillRef,
269
- progressThumbRef,
270
- seekBarRef,
271
- sliderRef,
272
- onPointerDown,
273
- onKeyDown,
274
- }: {
275
- disabled: boolean;
276
- duration: number;
277
- inPoint: number | null;
278
- outPoint: number | null;
279
- progressFillRef: React.RefObject<HTMLDivElement | null>;
280
- progressThumbRef: React.RefObject<HTMLDivElement | null>;
281
- seekBarRef: React.RefObject<HTMLDivElement | null>;
282
- sliderRef: React.RefObject<HTMLDivElement | null>;
283
- onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
284
- onKeyDown: (e: React.KeyboardEvent) => void;
285
- }) {
286
- return (
287
- <div
288
- ref={(el) => {
289
- (seekBarRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
290
- (sliderRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
291
- }}
292
- role="slider"
293
- tabIndex={disabled ? -1 : 0}
294
- aria-label="Seek"
295
- aria-disabled={disabled || undefined}
296
- aria-valuemin={0}
297
- aria-valuemax={Math.round(duration)}
298
- aria-valuenow={0}
299
- className={`min-w-[96px] flex-1 h-6 flex items-center group outline-none focus-visible:ring-1 focus-visible:ring-white/30 focus-visible:rounded ${
300
- disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
301
- }`}
302
- style={{ touchAction: "none" }}
303
- onPointerDown={onPointerDown}
304
- onKeyDown={onKeyDown}
305
- >
306
- <div
307
- className="w-full rounded-full relative"
308
- style={{ background: "rgba(255,255,255,0.15)", height: "3px" }}
309
- >
310
- <WorkAreaOverlay inPoint={inPoint} outPoint={outPoint} duration={duration} />
311
- <div
312
- ref={progressFillRef}
313
- className="absolute top-0 bottom-0 left-0 z-[1] rounded-full"
314
- style={{ background: "linear-gradient(90deg, var(--hf-accent, #3CE6AC), #2BBFA0)" }}
315
- />
316
- <div
317
- ref={progressThumbRef}
318
- className="absolute top-1/2 z-[4] w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 transition-transform group-hover:scale-125"
319
- style={{
320
- background: "var(--hf-accent, #3CE6AC)",
321
- boxShadow: "0 0 6px rgba(60,230,172,0.4), 0 1px 4px rgba(0,0,0,0.4)",
322
- }}
323
- />
324
- </div>
325
- </div>
326
- );
327
- });
328
-
329
209
  /* ── Main component ──────────────────────────────────────────────── */
330
210
 
331
211
  interface PlayerControlsProps {
@@ -359,12 +239,7 @@ export const PlayerControls = memo(function PlayerControls({
359
239
  const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
360
240
  const setTimeDisplayMode = usePlayerStore.getState().setTimeDisplayMode;
361
241
 
362
- const progressFillRef = useRef<HTMLDivElement>(null);
363
- const progressThumbRef = useRef<HTMLDivElement>(null);
364
242
  const timeDisplayRef = useRef<HTMLSpanElement>(null);
365
- const seekBarRef = useRef<HTMLDivElement>(null);
366
- const sliderRef = useRef<HTMLDivElement>(null);
367
- const isDraggingRef = useRef(false);
368
243
  const currentTimeRef = useRef(0);
369
244
  const timeDisplayModeRef = useRef(timeDisplayMode);
370
245
  timeDisplayModeRef.current = timeDisplayMode;
@@ -380,64 +255,29 @@ export const PlayerControls = memo(function PlayerControls({
380
255
  timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t);
381
256
  }, [duration, timeDisplayMode]);
382
257
 
383
- const { handlePointerDown } = useSeekBarDrag(
384
- {
385
- seekBarRef,
386
- progressFillRef,
387
- progressThumbRef,
388
- sliderRef,
389
- timeDisplayRef,
390
- isDraggingRef,
391
- durationRef,
392
- currentTimeRef,
393
- timeDisplayModeRef,
394
- },
395
- onSeek,
396
- disabled,
397
- duration,
398
- );
399
-
400
- const handleKeyDown = useCallback(
401
- (e: React.KeyboardEvent) => {
402
- if (disabled || !timelineReady || duration <= 0) return;
403
- const step = e.shiftKey ? 10 : 1;
404
- if (e.key === "ArrowLeft") {
405
- e.preventDefault();
406
- onSeek(stepFrameTime(currentTimeRef.current, -step));
407
- } else if (e.key === "ArrowRight") {
408
- e.preventDefault();
409
- onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
410
- }
411
- },
412
- [disabled, timelineReady, duration, onSeek],
413
- );
258
+ useMountEffect(() => {
259
+ const updateTime = (time: number) => {
260
+ currentTimeRef.current = time;
261
+ if (!timeDisplayRef.current) return;
262
+ const currentDuration = durationRef.current;
263
+ timeDisplayRef.current.textContent =
264
+ timeDisplayModeRef.current === "frame"
265
+ ? formatFrameTime(time, currentDuration)
266
+ : formatTime(time);
267
+ };
268
+ const unsubscribe = liveTime.subscribe(updateTime);
269
+ updateTime(usePlayerStore.getState().currentTime);
270
+ return unsubscribe;
271
+ });
414
272
 
415
273
  return (
416
274
  <div
417
- // No own background/border: the transport blends into the preview
418
- // panel's surface — buttons carry their own chrome.
419
- className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
275
+ className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
420
276
  aria-disabled={disabled || undefined}
421
277
  style={{
422
- paddingBottom: "calc(0.5rem + env(safe-area-inset-bottom))",
278
+ paddingBottom: "env(safe-area-inset-bottom)",
423
279
  }}
424
280
  >
425
- <Tooltip label={isPlaying ? "Pause" : "Play"}>
426
- <button
427
- type="button"
428
- aria-label={isPlaying ? "Pause" : "Play"}
429
- onClick={() => {
430
- trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
431
- onTogglePlay();
432
- }}
433
- disabled={controlsDisabled}
434
- className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
435
- style={{ background: "rgba(255,255,255,0.06)" }}
436
- >
437
- <PlayPauseMorphIcon playing={isPlaying} />
438
- </button>
439
- </Tooltip>
440
-
441
281
  <Tooltip
442
282
  label={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
443
283
  >
@@ -445,59 +285,58 @@ export const PlayerControls = memo(function PlayerControls({
445
285
  type="button"
446
286
  onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
447
287
  disabled={disabled}
448
- className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px] text-left transition-colors disabled:pointer-events-none hover:opacity-80"
449
- style={{ color: "#A1A1AA", cursor: "pointer" }}
288
+ className="min-w-0 justify-self-start whitespace-nowrap font-mono text-[11px] tabular-nums text-neutral-400 transition-colors hover:text-neutral-200 disabled:pointer-events-none"
450
289
  >
451
290
  <span ref={timeDisplayRef}>{formatTime(0)}</span>
452
291
  {timeDisplayMode === "time" ? (
453
292
  <>
454
- <span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
455
- <span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
293
+ <span className="mx-0.5 text-neutral-700">/</span>
294
+ <span className="text-neutral-600">{formatTime(duration)}</span>
456
295
  </>
457
296
  ) : null}
458
297
  </button>
459
298
  </Tooltip>
460
299
 
461
- <SeekBar
462
- disabled={disabled}
463
- duration={duration}
464
- inPoint={inPoint}
465
- outPoint={outPoint}
466
- progressFillRef={progressFillRef}
467
- progressThumbRef={progressThumbRef}
468
- seekBarRef={seekBarRef}
469
- sliderRef={sliderRef}
470
- onPointerDown={handlePointerDown}
471
- onKeyDown={handleKeyDown}
472
- />
473
-
474
- <MuteButton
475
- audioMuted={audioMuted}
476
- controlsDisabled={controlsDisabled}
477
- setAudioMuted={setAudioMuted}
478
- />
479
-
480
- <SpeedMenu
481
- playbackRate={playbackRate}
482
- setPlaybackRate={setPlaybackRate}
483
- disabled={disabled}
484
- />
485
-
486
- <LoopButton loopEnabled={loopEnabled} disabled={disabled} setLoopEnabled={setLoopEnabled} />
487
-
488
- {onToggleFullscreen && (
489
- <FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
490
- )}
300
+ <Tooltip label={isPlaying ? "Pause" : "Play"}>
301
+ <button
302
+ type="button"
303
+ aria-label={isPlaying ? "Pause" : "Play"}
304
+ onClick={() => {
305
+ trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
306
+ onTogglePlay();
307
+ }}
308
+ disabled={controlsDisabled}
309
+ className="flex h-8 w-8 items-center justify-center justify-self-center rounded-md text-neutral-100 transition-colors hover:text-white disabled:pointer-events-none disabled:opacity-30"
310
+ >
311
+ <PlayPauseMorphIcon playing={isPlaying} />
312
+ </button>
313
+ </Tooltip>
491
314
 
492
- <ShortcutsPanel
493
- disabled={disabled}
494
- duration={duration}
495
- inPoint={inPoint}
496
- outPoint={outPoint}
497
- setInPoint={setInPoint}
498
- setOutPoint={setOutPoint}
499
- onSeek={onSeek}
500
- />
315
+ <div className="flex min-w-0 items-center justify-self-end">
316
+ <MuteButton
317
+ audioMuted={audioMuted}
318
+ controlsDisabled={controlsDisabled}
319
+ setAudioMuted={setAudioMuted}
320
+ />
321
+ <SpeedMenu
322
+ playbackRate={playbackRate}
323
+ setPlaybackRate={setPlaybackRate}
324
+ disabled={disabled}
325
+ />
326
+ <LoopButton loopEnabled={loopEnabled} disabled={disabled} setLoopEnabled={setLoopEnabled} />
327
+ {onToggleFullscreen && (
328
+ <FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
329
+ )}
330
+ <ShortcutsPanel
331
+ disabled={disabled}
332
+ duration={duration}
333
+ inPoint={inPoint}
334
+ outPoint={outPoint}
335
+ setInPoint={setInPoint}
336
+ setOutPoint={setOutPoint}
337
+ onSeek={onSeek}
338
+ />
339
+ </div>
501
340
  </div>
502
341
  );
503
342
  });
@@ -153,10 +153,8 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
153
153
  <button
154
154
  type="button"
155
155
  onClick={() => setShowShortcuts((v) => !v)}
156
- className={`w-6 h-6 flex items-center justify-center rounded border transition-colors ${
157
- showShortcuts
158
- ? "border-neutral-600 text-neutral-200 bg-neutral-800"
159
- : "border-neutral-800 text-neutral-600 hover:text-neutral-300 hover:border-neutral-600"
156
+ className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
157
+ showShortcuts ? "text-neutral-200" : "text-neutral-600 hover:text-neutral-300"
160
158
  }`}
161
159
  aria-label="Shortcuts and tools"
162
160
  aria-expanded={showShortcuts}
@@ -41,8 +41,7 @@ export const SpeedMenu = memo(function SpeedMenu({
41
41
  type="button"
42
42
  onClick={() => setShowSpeedMenu((v) => !v)}
43
43
  disabled={disabled}
44
- className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
45
- style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
44
+ className="h-7 w-8 rounded-md font-mono text-[10px] tabular-nums text-neutral-500 transition-colors hover:text-neutral-200 disabled:opacity-30"
46
45
  >
47
46
  {playbackRate === 1 ? "1x" : `${playbackRate}x`}
48
47
  </button>
@@ -12,6 +12,7 @@ const {
12
12
  trackStudioRenderStart,
13
13
  trackStudioRazorSplit,
14
14
  trackStudioExpandedClipEdit,
15
+ trackStudioSegmentEaseEdit,
15
16
  trackStudioFeedback,
16
17
  } = await import("./events");
17
18
 
@@ -71,6 +72,14 @@ describe("studio telemetry events", () => {
71
72
  expect(trackEvent).toHaveBeenCalledWith("studio_expanded_clip_edit", { action: "resize" });
72
73
  });
73
74
 
75
+ it("trackStudioSegmentEaseEdit emits 'studio_segment_ease_edit' with action and ease", () => {
76
+ trackStudioSegmentEaseEdit({ ease: "power2.out" });
77
+ expect(trackEvent).toHaveBeenCalledWith("studio_segment_ease_edit", {
78
+ action: "commit",
79
+ ease: "power2.out",
80
+ });
81
+ });
82
+
74
83
  it.each([0, 10])("trackStudioFeedback preserves NPS boundary %i and its scale", (rating) => {
75
84
  trackStudioFeedback({ rating });
76
85
 
@@ -63,6 +63,11 @@ export function trackStudioExpandedClipEdit(props: {
63
63
  trackEvent("studio_expanded_clip_edit", { action: props.action });
64
64
  }
65
65
 
66
+ // Adoption signal for committing an edit to a segment's ease.
67
+ export function trackStudioSegmentEaseEdit(props: { ease: string }): void {
68
+ trackEvent("studio_segment_ease_edit", { action: "commit", ease: props.ease });
69
+ }
70
+
66
71
  export function trackStudioFeedback(props: { rating: number; comment?: string }): void {
67
72
  trackEvent("survey sent", {
68
73
  $survey_id: "studio_experience",
@@ -0,0 +1,67 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { addBlockToProject } from "./blockInstaller";
5
+
6
+ afterEach(() => {
7
+ vi.unstubAllGlobals();
8
+ });
9
+
10
+ describe("addBlockToProject", () => {
11
+ it("uses an explicit selected-media duration and track for a Registry overlay block", async () => {
12
+ const componentPath = "compositions/camcorder-hud.html";
13
+ const targetPath = "compositions/scene.html";
14
+ const files = new Map([
15
+ [
16
+ componentPath,
17
+ `<body><div data-composition-id="camcorder-hud"><div class="hud"></div></div></body>`,
18
+ ],
19
+ [
20
+ targetPath,
21
+ `<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080"></main>`,
22
+ ],
23
+ ]);
24
+ vi.stubGlobal(
25
+ "fetch",
26
+ vi.fn().mockResolvedValue({
27
+ ok: true,
28
+ json: async () => ({
29
+ written: [componentPath],
30
+ block: {
31
+ name: "camcorder-hud",
32
+ title: "Camcorder HUD",
33
+ description: "HUD",
34
+ type: "hyperframes:block",
35
+ files: [],
36
+ dimensions: { width: 1920, height: 1080 },
37
+ duration: 4,
38
+ },
39
+ }),
40
+ }),
41
+ );
42
+ const writeProjectFile = vi.fn(async (path: string, content: string) => {
43
+ files.set(path, content);
44
+ });
45
+
46
+ const result = await addBlockToProject({
47
+ projectId: "project",
48
+ blockName: "camcorder-hud",
49
+ activeCompPath: targetPath,
50
+ placement: { start: 2.5, duration: 4.25, track: 3 },
51
+ timelineElements: [],
52
+ readProjectFile: async (path) => files.get(path) ?? "",
53
+ writeProjectFile,
54
+ recordEdit: vi.fn().mockResolvedValue(undefined),
55
+ refreshFileTree: vi.fn().mockResolvedValue(undefined),
56
+ reloadPreview: vi.fn(),
57
+ showToast: vi.fn(),
58
+ });
59
+
60
+ expect(result?.block.name).toBe("camcorder-hud");
61
+ const source = files.get(targetPath);
62
+ expect(source).toContain('data-composition-src="compositions/camcorder-hud.html"');
63
+ expect(source).toContain('data-start="2.5"');
64
+ expect(source).toContain('data-duration="4.25"');
65
+ expect(source).toContain('data-track-index="3"');
66
+ });
67
+ });