@hyperframes/studio 0.6.86 → 0.6.88

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 (87) hide show
  1. package/dist/assets/index-B9_ctmee.js +143 -0
  2. package/dist/assets/index-CGlIm_-E.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +159 -6
  6. package/src/components/StudioHeader.tsx +20 -7
  7. package/src/components/StudioPreviewArea.tsx +6 -1
  8. package/src/components/StudioRightPanel.tsx +13 -0
  9. package/src/components/StudioToast.tsx +47 -7
  10. package/src/components/TimelineToolbar.tsx +12 -122
  11. package/src/components/editor/AnimationCard.tsx +64 -10
  12. package/src/components/editor/ArcPathControls.tsx +131 -0
  13. package/src/components/editor/BorderRadiusEditor.tsx +209 -0
  14. package/src/components/editor/DomEditOverlay.tsx +70 -11
  15. package/src/components/editor/DopesheetStrip.tsx +141 -0
  16. package/src/components/editor/EaseCurveSection.tsx +82 -7
  17. package/src/components/editor/GestureTrailOverlay.tsx +132 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +14 -1
  19. package/src/components/editor/KeyframeDiamond.tsx +27 -12
  20. package/src/components/editor/LayersPanel.tsx +14 -12
  21. package/src/components/editor/MotionPathOverlay.tsx +146 -0
  22. package/src/components/editor/PropertyPanel.tsx +196 -66
  23. package/src/components/editor/SourceEditor.tsx +0 -1
  24. package/src/components/editor/StaggerControls.tsx +61 -0
  25. package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
  26. package/src/components/editor/domEditOverlayGeometry.ts +2 -1
  27. package/src/components/editor/domEditing.test.ts +43 -0
  28. package/src/components/editor/domEditing.ts +2 -0
  29. package/src/components/editor/domEditingElement.ts +25 -2
  30. package/src/components/editor/domEditingLayers.test.ts +78 -0
  31. package/src/components/editor/domEditingLayers.ts +33 -13
  32. package/src/components/editor/domEditingTypes.ts +1 -0
  33. package/src/components/editor/manualEditingAvailability.ts +1 -1
  34. package/src/components/editor/manualEdits.ts +3 -0
  35. package/src/components/editor/manualEditsDom.ts +23 -5
  36. package/src/components/editor/manualOffsetDrag.ts +59 -0
  37. package/src/components/editor/panelTokens.ts +10 -0
  38. package/src/components/editor/propertyPanelColor.tsx +2 -2
  39. package/src/components/editor/propertyPanelFill.tsx +1 -1
  40. package/src/components/editor/propertyPanelHelpers.ts +18 -2
  41. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  42. package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
  43. package/src/components/editor/propertyPanelSections.tsx +4 -6
  44. package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
  45. package/src/components/editor/useDomEditOverlayRects.ts +46 -2
  46. package/src/components/renders/RenderQueue.tsx +121 -100
  47. package/src/components/renders/RenderQueueItem.tsx +13 -13
  48. package/src/contexts/DomEditContext.tsx +12 -0
  49. package/src/contexts/FileManagerContext.tsx +3 -0
  50. package/src/contexts/StudioContext.tsx +0 -4
  51. package/src/hooks/gsapKeyframeCommit.ts +92 -0
  52. package/src/hooks/gsapRuntimeBridge.ts +147 -85
  53. package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
  54. package/src/hooks/gsapRuntimePreview.ts +19 -0
  55. package/src/hooks/useAppHotkeys.ts +18 -0
  56. package/src/hooks/useAskAgentModal.ts +2 -4
  57. package/src/hooks/useDomEditCommits.ts +11 -17
  58. package/src/hooks/useDomEditSession.ts +47 -4
  59. package/src/hooks/useEnableKeyframes.ts +171 -0
  60. package/src/hooks/useFileManager.ts +7 -0
  61. package/src/hooks/useGestureRecording.ts +340 -0
  62. package/src/hooks/useGsapScriptCommits.ts +171 -35
  63. package/src/hooks/useGsapSelectionHandlers.ts +27 -8
  64. package/src/hooks/useGsapTweenCache.ts +169 -11
  65. package/src/hooks/useKeyframeKeyboard.ts +103 -0
  66. package/src/hooks/useStudioContextValue.ts +5 -4
  67. package/src/hooks/useStudioUrlState.ts +1 -2
  68. package/src/hooks/useTimelineEditing.ts +50 -3
  69. package/src/hooks/useToast.ts +6 -1
  70. package/src/player/components/ShortcutsPanel.tsx +40 -0
  71. package/src/player/components/TimelineClipDiamonds.tsx +3 -3
  72. package/src/player/components/TimelinePropertyRows.tsx +120 -0
  73. package/src/player/lib/timelineDOM.test.ts +55 -0
  74. package/src/player/lib/timelineDOM.ts +13 -0
  75. package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
  76. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  77. package/src/player/store/playerStore.ts +43 -0
  78. package/src/utils/audioBeatDetection.ts +58 -0
  79. package/src/utils/globalTimeCompiler.test.ts +169 -0
  80. package/src/utils/globalTimeCompiler.ts +77 -0
  81. package/src/utils/gsapSoftReload.ts +30 -10
  82. package/src/utils/keyframeSnapping.test.ts +74 -0
  83. package/src/utils/keyframeSnapping.ts +63 -0
  84. package/src/utils/rdpSimplify.ts +183 -0
  85. package/src/utils/sourcePatcher.ts +2 -0
  86. package/dist/assets/index-BT9VHgSy.js +0 -140
  87. package/dist/assets/index-DHcptK1_.css +0 -1
@@ -1,8 +1,72 @@
1
1
  import { useEffect, useMemo, useRef, useState, useCallback } from "react";
2
- import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
2
+ import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
3
+ import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
3
4
  import { usePlayerStore } from "../player/store/playerStore";
4
5
  import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
5
6
 
7
+ function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercentageKeyframe[] {
8
+ const byPct = new Map<number, GsapPercentageKeyframe>();
9
+ for (const kf of keyframes) {
10
+ const existing = byPct.get(kf.percentage);
11
+ if (existing) {
12
+ existing.properties = { ...existing.properties, ...kf.properties };
13
+ if (kf.ease) existing.ease = kf.ease;
14
+ } else {
15
+ byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
16
+ }
17
+ }
18
+ return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
19
+ }
20
+
21
+ const PROPERTY_DEFAULTS: Record<string, number> = {
22
+ opacity: 1,
23
+ x: 0,
24
+ y: 0,
25
+ scale: 1,
26
+ scaleX: 1,
27
+ scaleY: 1,
28
+ rotation: 0,
29
+ };
30
+
31
+ function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
32
+ if (anim.method === "set") {
33
+ return {
34
+ format: "percentage",
35
+ keyframes: [{ percentage: 0, properties: { ...anim.properties } }],
36
+ };
37
+ }
38
+ const toProps = anim.properties;
39
+ const fromProps = anim.fromProperties;
40
+ if (!toProps || Object.keys(toProps).length === 0) return null;
41
+
42
+ const startProps: Record<string, number | string> = {};
43
+ const endProps: Record<string, number | string> = {};
44
+
45
+ if (anim.method === "from") {
46
+ for (const [k, v] of Object.entries(toProps)) {
47
+ startProps[k] = v;
48
+ endProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
49
+ }
50
+ } else if (anim.method === "fromTo" && fromProps) {
51
+ Object.assign(startProps, fromProps);
52
+ Object.assign(endProps, toProps);
53
+ } else {
54
+ for (const [k, v] of Object.entries(toProps)) {
55
+ startProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
56
+ endProps[k] = v;
57
+ }
58
+ }
59
+
60
+ return {
61
+ format: "percentage",
62
+ keyframes: [
63
+ { percentage: 0, properties: startProps },
64
+ { percentage: 100, properties: endProps },
65
+ ],
66
+ ...(anim.ease ? { ease: anim.ease } : {}),
67
+ };
68
+ }
69
+
6
70
  function extractIdFromSelector(selector: string): string | null {
7
71
  const match = selector.match(/^#([\w-]+)/);
8
72
  return match ? match[1] : null;
@@ -31,7 +95,12 @@ export function getAnimationsForElement(
31
95
  if (target.selector) matchers.add(target.selector);
32
96
  if (matchers.size === 0) return [];
33
97
  return animations.filter((a) =>
34
- a.targetSelector.split(",").some((part) => matchers.has(part.trim())),
98
+ a.targetSelector.split(",").some((part) => {
99
+ const trimmed = part.trim();
100
+ if (matchers.has(trimmed)) return true;
101
+ const lastSimple = trimmed.split(/\s+/).pop();
102
+ return lastSimple ? matchers.has(lastSimple) : false;
103
+ }),
35
104
  );
36
105
  }
37
106
 
@@ -182,12 +251,60 @@ export function useGsapAnimationsForElement(
182
251
 
183
252
  // Populate keyframe cache for the selected element.
184
253
  // Key format must match timeline element keys: "sourceFile#domId".
254
+ // Merges keyframes from ALL animations targeting this element and synthesizes
255
+ // flat tweens so the cache is never downgraded vs the bulk populate.
185
256
  const elementId = target?.id ?? null;
186
257
  useEffect(() => {
187
258
  if (!elementId) return;
259
+
260
+ // Resolve the element's time range from the player store so we can
261
+ // convert tween-relative keyframe percentages to clip-relative ones.
262
+ const { elements } = usePlayerStore.getState();
263
+ const timelineEl = elements.find(
264
+ (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
265
+ );
266
+ const elStart = timelineEl?.start ?? 0;
267
+ const elDuration = timelineEl?.duration ?? 4;
268
+
269
+ const allKeyframes: GsapKeyframesData["keyframes"] = [];
270
+ let format: GsapKeyframesData["format"] = "percentage";
271
+ let ease: string | undefined;
272
+ let easeEach: string | undefined;
273
+ for (const anim of animations) {
274
+ const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
275
+ if (!kf) continue;
276
+ // Convert tween-relative percentages to clip-relative so diamonds
277
+ // render at the correct position within the timeline clip.
278
+ const tweenPos = typeof anim.position === "number" ? anim.position : 0;
279
+ const tweenDur = anim.duration ?? elDuration;
280
+ for (const k of kf.keyframes) {
281
+ const absTime = tweenPos + (k.percentage / 100) * tweenDur;
282
+ const clipPct =
283
+ elDuration > 0
284
+ ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
285
+ : k.percentage;
286
+ allKeyframes.push({ ...k, percentage: clipPct });
287
+ }
288
+ format = kf.format;
289
+ if (kf.ease) ease = kf.ease;
290
+ if (kf.easeEach) easeEach = kf.easeEach;
291
+ }
292
+ if (allKeyframes.length === 0) {
293
+ const { keyframeCache, setKeyframeCache } = usePlayerStore.getState();
294
+ if (keyframeCache.has(`${sourceFile}#${elementId}`)) {
295
+ setKeyframeCache(`${sourceFile}#${elementId}`, undefined);
296
+ }
297
+ return;
298
+ }
299
+ const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
300
+ const merged: GsapKeyframesData = {
301
+ format,
302
+ keyframes: dedupedKeyframes,
303
+ ...(ease ? { ease } : {}),
304
+ ...(easeEach ? { easeEach } : {}),
305
+ };
188
306
  const { setKeyframeCache } = usePlayerStore.getState();
189
- const withKeyframes = animations.find((a) => a.keyframes);
190
- setKeyframeCache(`${sourceFile}#${elementId}`, withKeyframes?.keyframes ?? undefined);
307
+ setKeyframeCache(`${sourceFile}#${elementId}`, merged);
191
308
  }, [elementId, sourceFile, animations]);
192
309
 
193
310
  return { animations, multipleTimelines, unsupportedTimelinePattern };
@@ -213,25 +330,63 @@ export function usePopulateKeyframeCacheForFile(
213
330
  const lastFetchKeyRef = useRef("");
214
331
 
215
332
  const runtimeScanDoneRef = useRef("");
333
+ const astFetchDoneRef = useRef("");
216
334
 
217
335
  useEffect(() => {
218
336
  const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`;
219
337
  if (fetchKey === lastFetchKeyRef.current) return;
220
338
  lastFetchKeyRef.current = fetchKey;
221
339
  runtimeScanDoneRef.current = "";
340
+ astFetchDoneRef.current = "";
222
341
  if (!projectId) return;
223
342
 
224
343
  const sf = sourceFile;
225
344
  fetchParsedAnimations(projectId, sf).then((parsed) => {
226
345
  if (!parsed) return;
227
- const { setKeyframeCache } = usePlayerStore.getState();
346
+ const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
347
+ const sfPrefix = `${sf}#`;
348
+ const fallbackPrefix = "index.html#";
349
+ for (const key of keyframeCache.keys()) {
350
+ if (key.startsWith(sfPrefix) || (sf !== "index.html" && key.startsWith(fallbackPrefix))) {
351
+ setKeyframeCache(key, undefined);
352
+ }
353
+ }
354
+ const { elements } = usePlayerStore.getState();
355
+ const mergedByElement = new Map<string, GsapKeyframesData>();
228
356
  for (const anim of parsed.animations) {
229
357
  const id = extractIdFromSelector(anim.targetSelector);
230
- if (!id || !anim.keyframes) continue;
231
- setKeyframeCache(`${sf}#${id}`, anim.keyframes);
232
- if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes);
358
+ if (!id) continue;
359
+ const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
360
+ if (!kfData) continue;
361
+ // Convert tween-relative percentages to clip-relative.
362
+ const tweenPos = typeof anim.position === "number" ? anim.position : 0;
363
+ const tweenDur = anim.duration ?? 1;
364
+ const timelineEl = elements.find(
365
+ (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
366
+ );
367
+ const elStart = timelineEl?.start ?? 0;
368
+ const elDuration = timelineEl?.duration ?? 4;
369
+ const clipKeyframes = kfData.keyframes.map((kf) => {
370
+ const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
371
+ const clipPct =
372
+ elDuration > 0
373
+ ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
374
+ : kf.percentage;
375
+ return { ...kf, percentage: clipPct };
376
+ });
377
+ const existing = mergedByElement.get(id);
378
+ if (existing) {
379
+ existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
380
+ } else {
381
+ mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
382
+ }
383
+ }
384
+ for (const [id, kfData] of mergedByElement) {
385
+ setKeyframeCache(`${sf}#${id}`, kfData);
386
+ setKeyframeCache(id, kfData);
387
+ if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, kfData);
233
388
  }
234
- runtimeScanDoneRef.current = fetchKey;
389
+ astFetchDoneRef.current = fetchKey;
235
390
  });
236
391
  }, [projectId, sourceFile, version]);
237
392
 
@@ -246,7 +401,8 @@ export function usePopulateKeyframeCacheForFile(
246
401
 
247
402
  const tryRuntimeScan = () => {
248
403
  if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
249
- const iframe = iframeRef?.current;
404
+ const iframe =
405
+ iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
250
406
  if (!iframe) return false;
251
407
  const scanned = scanAllRuntimeKeyframes(iframe);
252
408
  if (scanned.size === 0) return false;
@@ -254,7 +410,8 @@ export function usePopulateKeyframeCacheForFile(
254
410
  for (const [id, data] of scanned) {
255
411
  const cacheKey = `${sf}#${id}`;
256
412
  const fallbackKey = `index.html#${id}`;
257
- if (keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey)) continue;
413
+ if (keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id))
414
+ continue;
258
415
  const entry = {
259
416
  format: "percentage" as const,
260
417
  keyframes: data.keyframes,
@@ -262,6 +419,7 @@ export function usePopulateKeyframeCacheForFile(
262
419
  };
263
420
  setKeyframeCache(cacheKey, entry);
264
421
  if (sf !== "index.html") setKeyframeCache(fallbackKey, entry);
422
+ setKeyframeCache(id, entry);
265
423
  }
266
424
  runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
267
425
  return true;
@@ -0,0 +1,103 @@
1
+ import { useEffect, useCallback } from "react";
2
+ import { usePlayerStore } from "../player/store/playerStore";
3
+
4
+ interface KeyframeKeyboardOptions {
5
+ enabled: boolean;
6
+ onAddKeyframe?: () => void;
7
+ onDeleteKeyframe?: () => void;
8
+ onPrevKeyframe?: () => void;
9
+ onNextKeyframe?: () => void;
10
+ onToggleHold?: () => void;
11
+ onToggleExpand?: () => void;
12
+ onNudgeKeyframe?: (direction: -1 | 1, large: boolean) => void;
13
+ }
14
+
15
+ function isTextInput(el: Element | null): boolean {
16
+ if (!el) return false;
17
+ const tag = el.tagName;
18
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
19
+ return (el as HTMLElement).isContentEditable === true;
20
+ }
21
+
22
+ export function useKeyframeKeyboard({
23
+ enabled,
24
+ onAddKeyframe,
25
+ onDeleteKeyframe,
26
+ onPrevKeyframe,
27
+ onNextKeyframe,
28
+ onToggleHold,
29
+ onToggleExpand,
30
+ onNudgeKeyframe,
31
+ }: KeyframeKeyboardOptions): void {
32
+ const handler = useCallback(
33
+ (e: KeyboardEvent) => {
34
+ if (!enabled) return;
35
+ if (isTextInput(document.activeElement)) return;
36
+
37
+ const hasSelectedKeyframes = usePlayerStore.getState().selectedKeyframes.size > 0;
38
+
39
+ switch (e.key.toLowerCase()) {
40
+ case "k":
41
+ if (!e.metaKey && !e.ctrlKey) {
42
+ e.preventDefault();
43
+ onAddKeyframe?.();
44
+ }
45
+ break;
46
+ case "delete":
47
+ case "backspace":
48
+ if (hasSelectedKeyframes) {
49
+ e.preventDefault();
50
+ onDeleteKeyframe?.();
51
+ }
52
+ break;
53
+ case "j":
54
+ if (!e.metaKey && !e.ctrlKey) {
55
+ e.preventDefault();
56
+ if (e.shiftKey) onNextKeyframe?.();
57
+ else onPrevKeyframe?.();
58
+ }
59
+ break;
60
+ case "h":
61
+ if (!e.metaKey && !e.ctrlKey && hasSelectedKeyframes) {
62
+ e.preventDefault();
63
+ onToggleHold?.();
64
+ }
65
+ break;
66
+ case "u":
67
+ if (!e.metaKey && !e.ctrlKey) {
68
+ e.preventDefault();
69
+ onToggleExpand?.();
70
+ }
71
+ break;
72
+ case "arrowleft":
73
+ if (hasSelectedKeyframes && !e.metaKey && !e.ctrlKey && !e.altKey) {
74
+ e.preventDefault();
75
+ onNudgeKeyframe?.(-1, e.shiftKey);
76
+ }
77
+ break;
78
+ case "arrowright":
79
+ if (hasSelectedKeyframes && !e.metaKey && !e.ctrlKey && !e.altKey) {
80
+ e.preventDefault();
81
+ onNudgeKeyframe?.(1, e.shiftKey);
82
+ }
83
+ break;
84
+ }
85
+ },
86
+ [
87
+ enabled,
88
+ onAddKeyframe,
89
+ onDeleteKeyframe,
90
+ onPrevKeyframe,
91
+ onNextKeyframe,
92
+ onToggleHold,
93
+ onToggleExpand,
94
+ onNudgeKeyframe,
95
+ ],
96
+ );
97
+
98
+ useEffect(() => {
99
+ if (!enabled) return;
100
+ window.addEventListener("keydown", handler);
101
+ return () => window.removeEventListener("keydown", handler);
102
+ }, [enabled, handler]);
103
+ }
@@ -17,7 +17,6 @@ interface StudioContextInput {
17
17
  compositionLoading: boolean;
18
18
  refreshKey: number;
19
19
  setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
20
- currentTime: number;
21
20
  timelineElements: StudioContextValue["timelineElements"];
22
21
  isPlaying: boolean;
23
22
  editHistory: { canUndo: boolean; canRedo: boolean; undoLabel: string; redoLabel: string };
@@ -50,7 +49,7 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex
50
49
  compositionLoading: input.compositionLoading,
51
50
  refreshKey: input.refreshKey,
52
51
  setRefreshKey: input.setRefreshKey,
53
- currentTime: input.currentTime,
52
+
54
53
  timelineElements: input.timelineElements,
55
54
  isPlaying: input.isPlaying,
56
55
  editHistory: input.editHistory,
@@ -81,6 +80,7 @@ export function useInspectorState(
81
80
  rightCollapsed: boolean,
82
81
  isPlaying: boolean,
83
82
  domEditSelection: DomEditSelection | null,
83
+ isGestureRecording?: boolean,
84
84
  ): InspectorState {
85
85
  // fallow-ignore-next-line complexity
86
86
  return useMemo(() => {
@@ -101,9 +101,10 @@ export function useInspectorState(
101
101
  inspectorPanelActive,
102
102
  inspectorButtonActive:
103
103
  STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
104
- shouldShowSelectedDomBounds: inspectorPanelActive && !rightCollapsed && !isPlaying,
104
+ shouldShowSelectedDomBounds:
105
+ inspectorPanelActive && !rightCollapsed && !isPlaying && !isGestureRecording,
105
106
  };
106
- }, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection]);
107
+ }, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection, isGestureRecording]);
107
108
  }
108
109
 
109
110
  // fallow-ignore-next-line complexity
@@ -11,7 +11,6 @@ import {
11
11
  interface UseStudioUrlStateParams {
12
12
  projectId: string | null;
13
13
  activeCompPath: string | null;
14
- currentTime: number;
15
14
  duration: number;
16
15
  isPlaying: boolean;
17
16
  compositionLoading: boolean;
@@ -57,7 +56,6 @@ function replaceHash(nextHash: string) {
57
56
  export function useStudioUrlState({
58
57
  projectId,
59
58
  activeCompPath,
60
- currentTime,
61
59
  duration,
62
60
  isPlaying,
63
61
  compositionLoading,
@@ -72,6 +70,7 @@ export function useStudioUrlState({
72
70
  applyDomSelection,
73
71
  initialState,
74
72
  }: UseStudioUrlStateParams) {
73
+ const currentTime = usePlayerStore((s) => s.currentTime);
75
74
  const hydratedSeekRef = useRef(initialState.currentTime == null);
76
75
  const hydratedInitialTimeRef = useRef(initialState.currentTime == null);
77
76
  const hydratedSelectionRef = useRef(initialState.selection == null);
@@ -41,13 +41,27 @@ interface UseTimelineEditingOptions {
41
41
  previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
42
42
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
43
43
  uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
44
+ isRecordingRef?: React.RefObject<boolean>;
44
45
  }
45
46
 
46
47
  // ── Helpers ──
47
48
 
48
- function buildPatchTarget(element: { domId?: string; selector?: string; selectorIndex?: number }) {
49
+ function buildPatchTarget(element: {
50
+ domId?: string;
51
+ hfId?: string;
52
+ selector?: string;
53
+ selectorIndex?: number;
54
+ }) {
49
55
  if (element.domId) {
50
- return { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex };
56
+ return {
57
+ id: element.domId,
58
+ hfId: element.hfId,
59
+ selector: element.selector,
60
+ selectorIndex: element.selectorIndex,
61
+ };
62
+ }
63
+ if (element.hfId) {
64
+ return { hfId: element.hfId, selector: element.selector, selectorIndex: element.selectorIndex };
51
65
  }
52
66
  if (element.selector) {
53
67
  return { selector: element.selector, selectorIndex: element.selectorIndex };
@@ -174,6 +188,7 @@ export function useTimelineEditing({
174
188
  previewIframeRef,
175
189
  pendingTimelineEditPathRef,
176
190
  uploadProjectFiles,
191
+ isRecordingRef,
177
192
  }: UseTimelineEditingOptions) {
178
193
  const projectIdRef = useRef(projectId);
179
194
  projectIdRef.current = projectId;
@@ -187,6 +202,10 @@ export function useTimelineEditing({
187
202
  label: string,
188
203
  buildPatches: PersistTimelineEditInput["buildPatches"],
189
204
  ): Promise<void> => {
205
+ if (isRecordingRef?.current) {
206
+ showToast("Cannot edit timeline while recording", "error");
207
+ return Promise.resolve();
208
+ }
190
209
  const pid = projectIdRef.current;
191
210
  if (!pid) return Promise.resolve();
192
211
  const queued = editQueueRef.current.then(() =>
@@ -213,6 +232,8 @@ export function useTimelineEditing({
213
232
  writeProjectFile,
214
233
  domEditSaveTimestampRef,
215
234
  pendingTimelineEditPathRef,
235
+ showToast,
236
+ isRecordingRef,
216
237
  ],
217
238
  );
218
239
 
@@ -274,6 +295,10 @@ export function useTimelineEditing({
274
295
 
275
296
  const handleTimelineElementDelete = useCallback(
276
297
  async (element: TimelineElement) => {
298
+ if (isRecordingRef?.current) {
299
+ showToast("Cannot edit timeline while recording", "error");
300
+ return;
301
+ }
277
302
  const pid = projectIdRef.current;
278
303
  if (!pid) throw new Error("No active project");
279
304
  const label = getTimelineElementLabel(element);
@@ -338,6 +363,7 @@ export function useTimelineEditing({
338
363
  writeProjectFile,
339
364
  domEditSaveTimestampRef,
340
365
  reloadPreview,
366
+ isRecordingRef,
341
367
  ],
342
368
  );
343
369
 
@@ -347,6 +373,10 @@ export function useTimelineEditing({
347
373
  placement: Pick<TimelineElement, "start" | "track">,
348
374
  durationOverride?: number,
349
375
  ) => {
376
+ if (isRecordingRef?.current) {
377
+ showToast("Cannot edit timeline while recording", "error");
378
+ return;
379
+ }
350
380
  const pid = projectIdRef.current;
351
381
  if (!pid) throw new Error("No active project");
352
382
 
@@ -415,11 +445,16 @@ export function useTimelineEditing({
415
445
  writeProjectFile,
416
446
  domEditSaveTimestampRef,
417
447
  reloadPreview,
448
+ isRecordingRef,
418
449
  ],
419
450
  );
420
451
 
421
452
  const handleTimelineFileDrop = useCallback(
422
453
  async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
454
+ if (isRecordingRef?.current) {
455
+ showToast("Cannot edit timeline while recording", "error");
456
+ return;
457
+ }
423
458
  const pid = projectIdRef.current;
424
459
  if (!pid) return;
425
460
  const uploaded = await uploadProjectFiles(files);
@@ -453,7 +488,14 @@ export function useTimelineEditing({
453
488
  );
454
489
  }
455
490
  },
456
- [activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles],
491
+ [
492
+ activeCompPath,
493
+ handleTimelineAssetDrop,
494
+ timelineElements,
495
+ uploadProjectFiles,
496
+ isRecordingRef,
497
+ showToast,
498
+ ],
457
499
  );
458
500
 
459
501
  const handleBlockedTimelineEdit = useCallback(
@@ -468,6 +510,10 @@ export function useTimelineEditing({
468
510
 
469
511
  const handleTimelineElementSplit = useCallback(
470
512
  async (element: TimelineElement, splitTime: number) => {
513
+ if (isRecordingRef?.current) {
514
+ showToast("Cannot edit timeline while recording", "error");
515
+ return;
516
+ }
471
517
  const pid = projectIdRef.current;
472
518
  if (!pid) return;
473
519
 
@@ -555,6 +601,7 @@ export function useTimelineEditing({
555
601
  writeProjectFile,
556
602
  domEditSaveTimestampRef,
557
603
  reloadPreview,
604
+ isRecordingRef,
558
605
  ],
559
606
  );
560
607
 
@@ -16,5 +16,10 @@ export function useToast() {
16
16
  if (timerRef.current) clearTimeout(timerRef.current);
17
17
  });
18
18
 
19
- return { appToast, showToast };
19
+ const dismissToast = useCallback(() => {
20
+ if (timerRef.current) clearTimeout(timerRef.current);
21
+ setAppToast(null);
22
+ }, []);
23
+
24
+ return { appToast, showToast, dismissToast };
20
25
  }
@@ -17,6 +17,46 @@ const SHORTCUT_SECTIONS = [
17
17
  { key: "F", label: "Toggle fullscreen" },
18
18
  ],
19
19
  },
20
+ {
21
+ title: "Keyframes",
22
+ hints: [
23
+ { key: "K", label: "Add keyframe at playhead" },
24
+ { key: "Del", label: "Delete selected keyframe" },
25
+ { key: "H", label: "Toggle hold / bezier" },
26
+ { key: "U", label: "Expand / collapse properties" },
27
+ { key: "R", label: "Record gesture" },
28
+ ],
29
+ },
30
+ {
31
+ title: "Editing",
32
+ hints: [
33
+ { key: "⌘Z", label: "Undo" },
34
+ { key: "⌘⇧Z", label: "Redo" },
35
+ { key: "⌘C", label: "Copy element" },
36
+ { key: "⌘V", label: "Paste element" },
37
+ { key: "⌘X", label: "Cut element" },
38
+ { key: "S", label: "Split clip at playhead" },
39
+ { key: "Del", label: "Delete selected element" },
40
+ ],
41
+ },
42
+ {
43
+ title: "Gesture recording modifiers",
44
+ hints: [
45
+ { key: "Drag", label: "Record x / y position" },
46
+ { key: "Scroll", label: "Record z depth" },
47
+ { key: "⇧ Drag", label: "Record rotationX / rotationY" },
48
+ { key: "⌥ Drag", label: "Record rotation" },
49
+ { key: "⌘ Drag↕", label: "Record opacity" },
50
+ { key: "⌘ Scroll", label: "Record scale" },
51
+ ],
52
+ },
53
+ {
54
+ title: "Panels",
55
+ hints: [
56
+ { key: "⌘1", label: "Compositions tab" },
57
+ { key: "⌘2", label: "Assets tab" },
58
+ ],
59
+ },
20
60
  {
21
61
  title: "Work area",
22
62
  hints: [
@@ -102,7 +102,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
102
102
  const x2 = (kf.percentage / 100) * clipWidthPx;
103
103
  return (
104
104
  <div
105
- key={`line-${prev.percentage}-${kf.percentage}`}
105
+ key={`line-${i}-${prev.percentage}-${kf.percentage}`}
106
106
  className="absolute"
107
107
  style={{
108
108
  left: x1,
@@ -118,7 +118,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
118
118
  );
119
119
  })}
120
120
 
121
- {sorted.map((kf) => {
121
+ {sorted.map((kf, i) => {
122
122
  const leftPx = (kf.percentage / 100) * clipWidthPx - half;
123
123
  const kfKey = `${elementId}:${kf.percentage}`;
124
124
  const isKfSelected = selectedKeyframes.has(kfKey);
@@ -126,7 +126,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
126
126
  const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3";
127
127
  return (
128
128
  <button
129
- key={kf.percentage}
129
+ key={`${i}-${kf.percentage}`}
130
130
  type="button"
131
131
  className="absolute"
132
132
  style={{