@hyperframes/studio 0.7.5 → 0.7.6

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 (61) hide show
  1. package/dist/assets/{index-BwFzbjZQ.js → index-B_NnmU6q.js} +1 -1
  2. package/dist/assets/{index-C5NAfiPa.js → index-DsBhGFPe.js} +1 -1
  3. package/dist/assets/{index-DzWIinxk.css → index-wJZf6FK3.css} +1 -1
  4. package/dist/assets/index-ysftPins.js +374 -0
  5. package/dist/chunk-KZXYQYIU.js +876 -0
  6. package/dist/chunk-KZXYQYIU.js.map +1 -0
  7. package/dist/domEditingLayers-SSXQZHHQ.js +41 -0
  8. package/dist/domEditingLayers-SSXQZHHQ.js.map +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +2298 -2483
  12. package/dist/index.js.map +1 -1
  13. package/package.json +5 -5
  14. package/src/captions/hooks/useCaptionSync.ts +6 -1
  15. package/src/components/StudioPreviewArea.tsx +10 -3
  16. package/src/components/StudioRightPanel.tsx +2 -0
  17. package/src/components/editor/AnimationCard.tsx +72 -251
  18. package/src/components/editor/AnimationCardParts.tsx +220 -0
  19. package/src/components/editor/BlockParamsPanel.tsx +4 -8
  20. package/src/components/editor/DomEditOverlay.tsx +132 -48
  21. package/src/components/editor/EaseCurveSection.tsx +6 -6
  22. package/src/components/editor/GsapAnimationSection.tsx +2 -0
  23. package/src/components/editor/KeyframeEaseList.tsx +63 -0
  24. package/src/components/editor/OffCanvasIndicators.tsx +111 -0
  25. package/src/components/editor/PropertyPanel.tsx +52 -54
  26. package/src/components/editor/gsapAnimationCallbacks.ts +1 -0
  27. package/src/components/editor/gsapAnimationConstants.ts +8 -0
  28. package/src/components/editor/manualOffsetDrag.ts +8 -0
  29. package/src/components/editor/marqueeCommit.ts +168 -0
  30. package/src/components/editor/propertyPanelHelpers.ts +1 -0
  31. package/src/components/editor/snapTargetCollection.ts +0 -5
  32. package/src/components/panels/SlideshowPanel.tsx +0 -1
  33. package/src/contexts/DomEditContext.tsx +8 -0
  34. package/src/hooks/gsapDragPositionCommit.ts +1 -2
  35. package/src/hooks/gsapRuntimeBridge.ts +6 -2
  36. package/src/hooks/gsapRuntimeReaders.ts +1 -6
  37. package/src/hooks/useDomEditCommits.ts +0 -4
  38. package/src/hooks/useDomEditPreviewSync.ts +5 -0
  39. package/src/hooks/useDomEditSession.ts +23 -0
  40. package/src/hooks/useDomEditTextCommits.ts +3 -12
  41. package/src/hooks/useDomSelection.ts +46 -0
  42. package/src/hooks/useGestureCommit.ts +26 -3
  43. package/src/hooks/useGestureRecording.ts +2 -16
  44. package/src/hooks/useGsapAnimationOps.ts +2 -2
  45. package/src/hooks/useGsapTweenCache.ts +32 -4
  46. package/src/player/components/Player.tsx +0 -5
  47. package/src/player/components/timelineIcons.tsx +2 -1
  48. package/src/player/hooks/useTimelinePlayer.ts +4 -14
  49. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -9
  50. package/src/player/lib/timelineIframeHelpers.ts +2 -6
  51. package/src/telemetry/client.ts +2 -0
  52. package/src/utils/editDebugLog.ts +3 -10
  53. package/src/utils/gestureSmoother.test.ts +48 -0
  54. package/src/utils/gestureSmoother.ts +46 -0
  55. package/src/utils/marqueeGeometry.test.ts +123 -0
  56. package/src/utils/marqueeGeometry.ts +172 -0
  57. package/src/utils/optimisticUpdate.ts +1 -2
  58. package/src/utils/sourcePatcher.ts +0 -10
  59. package/src/utils/velocityEaseFitter.test.ts +58 -0
  60. package/src/utils/velocityEaseFitter.ts +121 -0
  61. package/dist/assets/index-D_JGXmfx.js +0 -374
@@ -126,9 +126,7 @@ export function useDomEditTextCommits({
126
126
  ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
127
127
  : undefined,
128
128
  });
129
- } catch (err) {
130
- console.warn("[Studio] Style persist failed:", err instanceof Error ? err.message : err);
131
- }
129
+ } catch {}
132
130
  refreshDomEditSelectionFromPreview(domEditSelection);
133
131
  },
134
132
  [
@@ -162,9 +160,7 @@ export function useDomEditTextCommits({
162
160
  coalesceKey: `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`,
163
161
  skipRefresh: options.skipRefresh,
164
162
  });
165
- } catch (err) {
166
- console.warn(options.warningMessage, err instanceof Error ? err.message : err);
167
- }
163
+ } catch {}
168
164
  if (options.refreshAfter) {
169
165
  refreshDomEditSelectionFromPreview(domEditSelection);
170
166
  }
@@ -224,12 +220,7 @@ export function useDomEditTextCommits({
224
220
  coalesceKey: `html-attr:${attr}:${getDomEditTargetKey(domEditSelection)}`,
225
221
  skipRefresh: false,
226
222
  });
227
- } catch (err) {
228
- console.warn(
229
- "[Studio] HTML attribute persist failed:",
230
- err instanceof Error ? err.message : err,
231
- );
232
- }
223
+ } catch {}
233
224
  refreshDomEditSelectionFromPreview(domEditSelection);
234
225
  },
235
226
  [
@@ -85,6 +85,7 @@ export interface UseDomSelectionReturn {
85
85
  handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
86
86
  refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
87
87
  refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
88
+ applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
88
89
  }
89
90
 
90
91
  // ── Hook ──
@@ -419,6 +420,50 @@ export function useDomSelection({
419
420
  applyDomSelection(null, { revealPanel: false });
420
421
  }, [applyDomSelection, captionEditMode]);
421
422
 
423
+ const applyMarqueeSelection = useCallback(
424
+ // fallow-ignore-next-line complexity
425
+ (selections: DomEditSelection[], additive: boolean) => {
426
+ // Honor the inspector-panels kill switch like applyDomSelection does, so
427
+ // marquee can't land selections while the inspector UI is suppressed.
428
+ if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
429
+ domEditSelectionRef.current = null;
430
+ domEditGroupSelectionsRef.current = [];
431
+ setDomEditSelection(null);
432
+ setDomEditGroupSelections([]);
433
+ return;
434
+ }
435
+ if (selections.length === 0) {
436
+ if (!additive) applyDomSelection(null, { revealPanel: false });
437
+ return;
438
+ }
439
+ const current = domEditSelectionRef.current;
440
+ const currentGroup = domEditGroupSelectionsRef.current;
441
+ let nextGroup: DomEditSelection[];
442
+ if (additive) {
443
+ nextGroup = seedDomEditGroupWithSelection(currentGroup, current);
444
+ for (const s of selections) {
445
+ if (!domEditSelectionInGroup(nextGroup, s)) nextGroup = [...nextGroup, s];
446
+ }
447
+ } else {
448
+ nextGroup = selections;
449
+ }
450
+ const nextSelection = additive && current ? current : selections[0];
451
+ domEditSelectionRef.current = nextSelection;
452
+ domEditGroupSelectionsRef.current = nextGroup;
453
+ setDomEditSelection(nextSelection);
454
+ setDomEditGroupSelections(nextGroup);
455
+ const nextTimelineId =
456
+ findMatchingTimelineElementId(nextSelection, timelineElements) ??
457
+ findTimelineIdByAncestor(
458
+ nextSelection.element,
459
+ timelineElements,
460
+ nextSelection.sourceFile || "index.html",
461
+ );
462
+ setSelectedTimelineElementId(nextTimelineId);
463
+ },
464
+ [applyDomSelection, timelineElements, setSelectedTimelineElementId],
465
+ );
466
+
422
467
  // Disabled inspector effect
423
468
  // eslint-disable-next-line no-restricted-syntax
424
469
  useEffect(() => {
@@ -451,5 +496,6 @@ export function useDomSelection({
451
496
  handleTimelineElementSelect,
452
497
  refreshDomEditSelectionFromPreview,
453
498
  refreshDomEditGroupSelectionsFromPreview,
499
+ applyMarqueeSelection,
454
500
  };
455
501
  }
@@ -6,13 +6,19 @@ import { useState, useCallback, useRef, useEffect } from "react";
6
6
  import { editLog } from "../utils/editDebugLog";
7
7
  import { useGestureRecording } from "./useGestureRecording";
8
8
  import { simplifyGestureSamples } from "../utils/rdpSimplify";
9
+ import { fitEasesFromVelocity } from "../utils/velocityEaseFitter";
10
+ import { smoothGestureKeyframes } from "../utils/gestureSmoother";
9
11
  import { usePlayerStore } from "../player";
10
12
  import type { DomEditSelection } from "../components/editor/domEditing";
11
13
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
12
14
  import { roundTo3 } from "../utils/rounding";
13
15
  import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
14
16
 
15
- type RecordedKeyframe = { percentage: number; properties: Record<string, number | string> };
17
+ type RecordedKeyframe = {
18
+ percentage: number;
19
+ properties: Record<string, number | string>;
20
+ ease?: string;
21
+ };
16
22
 
17
23
  /**
18
24
  * Split recorded keyframes into one keyframe-set per property group (position /
@@ -24,6 +30,7 @@ type RecordedKeyframe = { percentage: number; properties: Record<string, number
24
30
  * Emitting one tween per group keeps the position tween tagged and editable.
25
31
  * Keyframes with no prop in a group are dropped from that group's set.
26
32
  */
33
+ // fallow-ignore-next-line complexity
27
34
  function partitionKeyframesByGroup(keyframes: RecordedKeyframe[]): RecordedKeyframe[][] {
28
35
  // Preserve first-seen group order for deterministic, stable mutation ordering.
29
36
  const groupOrder: string[] = [];
@@ -46,7 +53,11 @@ function partitionKeyframesByGroup(keyframes: RecordedKeyframe[]): RecordedKeyfr
46
53
  byGroup.set(group, set);
47
54
  groupOrder.push(group);
48
55
  }
49
- set.push({ percentage: kf.percentage, properties: props });
56
+ set.push({
57
+ percentage: kf.percentage,
58
+ properties: props,
59
+ ...(kf.ease ? { ease: kf.ease } : {}),
60
+ });
50
61
  }
51
62
  }
52
63
  return groupOrder.map((group) => byGroup.get(group)!);
@@ -130,6 +141,7 @@ export function useGestureCommit({
130
141
 
131
142
  // Per-property epsilon: small-range properties (opacity 0–1, scale ~0.01–10)
132
143
  // need a much tighter tolerance than positional properties (x/y in px).
144
+ // fallow-ignore-next-line complexity
133
145
  const simplified = simplifyGestureSamples(frozenSamples, duration, (key) => {
134
146
  if (key === "opacity") return 0.01;
135
147
  if (key === "scale" || key === "scaleX" || key === "scaleY") return 0.01;
@@ -150,10 +162,12 @@ export function useGestureCommit({
150
162
  }
151
163
  if (liveSession.commitMutation) {
152
164
  const recStart = recordingStartTimeRef.current;
153
- const keyframes = sortedPcts.map((pct) => ({
165
+ const rawKeyframes = sortedPcts.map((pct) => ({
154
166
  percentage: pct,
155
167
  properties: simplified.get(pct) as Record<string, number | string>,
156
168
  }));
169
+ const smoothed = smoothGestureKeyframes(rawKeyframes, 3);
170
+ const keyframes = fitEasesFromVelocity(smoothed, frozenSamples, duration);
157
171
  const hasPositionProps = keyframes.some((kf) =>
158
172
  Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
159
173
  );
@@ -206,6 +220,7 @@ export function useGestureCommit({
206
220
  const mapped = keyframes.map((kf) => ({
207
221
  percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct),
208
222
  properties: kf.properties,
223
+ ...(kf.ease ? { ease: kf.ease } : {}),
209
224
  }));
210
225
 
211
226
  const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
@@ -236,6 +251,11 @@ export function useGestureCommit({
236
251
  position: roundTo3(recStart),
237
252
  duration: roundTo3(duration),
238
253
  keyframes: groupKfs,
254
+ // Linear fallback: the velocity fitter assigns a per-keyframe
255
+ // ease to non-constant segments and intentionally leaves
256
+ // constant-speed segments undefined → they must stay linear,
257
+ // not inherit a sigmoid.
258
+ easeEach: "none",
239
259
  },
240
260
  { label: "Gesture recording (new range)", softReload: true },
241
261
  );
@@ -252,6 +272,8 @@ export function useGestureCommit({
252
272
  position: roundTo3(recStart),
253
273
  duration: roundTo3(duration),
254
274
  keyframes: groupKfs,
275
+ // Linear fallback (see above) — constant-speed segments stay linear.
276
+ easeEach: "none",
255
277
  },
256
278
  { label: "Gesture recording", softReload: true },
257
279
  );
@@ -270,6 +292,7 @@ export function useGestureCommit({
270
292
  }
271
293
  }, [gestureRecording, showToast, isGestureRecordingRef, domEditSessionRef]);
272
294
 
295
+ // fallow-ignore-next-line complexity
273
296
  const handleToggleRecording = useCallback(() => {
274
297
  editLog("gesture", gestureStateRef.current === "recording" ? "stop" : "start", {
275
298
  id: domEditSessionRef.current.domEditSelection?.id,
@@ -1,16 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { usePlayerStore, liveTime } from "../player/store/playerStore";
3
3
 
4
- // `import.meta.env` may be undefined in non-Vite bundlers (Next.js Turbopack),
5
- // so guard the access like the telemetry client does.
6
- function isDevBuild(): boolean {
7
- try {
8
- return import.meta.env.DEV === true;
9
- } catch {
10
- return false;
11
- }
12
- }
13
-
14
4
  export interface GestureSample {
15
5
  time: number;
16
6
  properties: Record<string, number>;
@@ -385,13 +375,9 @@ export function useGestureRecording() {
385
375
  if (r.runtime) {
386
376
  try {
387
377
  applyRuntimePreview(r.runtime, time, properties);
388
- } catch (err) {
378
+ } catch {
389
379
  // Preview failed — disable it for the rest of the gesture (recording
390
- // continues). Surface in dev so a dead preview isn't silent; `r.runtime`
391
- // is nulled below so this warns at most once per gesture.
392
- if (isDevBuild()) {
393
- console.warn("[GR] live preview disabled — runtime threw:", err);
394
- }
380
+ // continues). `r.runtime` is nulled so we don't retry on every frame.
395
381
  r.runtime = null;
396
382
  }
397
383
  }
@@ -41,7 +41,7 @@ export function useGsapAnimationOps({
41
41
  async (
42
42
  selection: DomEditSelection,
43
43
  animationId: string,
44
- updates: { duration?: number; ease?: string; position?: number },
44
+ updates: { duration?: number; ease?: string; easeEach?: string; position?: number },
45
45
  ) => {
46
46
  if (sdkSession && sdkDeps) {
47
47
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
@@ -57,7 +57,7 @@ export function useGsapAnimationOps({
57
57
  commitMutationSafely(
58
58
  selection,
59
59
  { type: "update-meta", animationId, updates },
60
- { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` },
60
+ { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, softReload: true },
61
61
  );
62
62
  },
63
63
  [commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
@@ -24,6 +24,7 @@ function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercenta
24
24
  return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
25
25
  }
26
26
 
27
+ // fallow-ignore-next-line complexity
27
28
  function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
28
29
  if (anim.method === "set") {
29
30
  return {
@@ -107,6 +108,9 @@ export async function fetchParsedAnimations(
107
108
  try {
108
109
  const res = await fetch(
109
110
  `/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
111
+ // Always re-read the freshly-parsed source; no per-call timestamp (which
112
+ // would defeat caching forever and is a deterministic-render no-no).
113
+ { cache: "no-store" },
110
114
  );
111
115
  if (!res.ok) return null;
112
116
  const parsed = (await res.json()) as ParsedGsap;
@@ -156,7 +160,9 @@ export function useGsapAnimationsForElement(
156
160
 
157
161
  let cancelled = false;
158
162
  fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
159
- if (cancelled) return;
163
+ if (cancelled) {
164
+ return;
165
+ }
160
166
  if (!parsed) {
161
167
  setAllAnimations([]);
162
168
  setMultipleTimelines(false);
@@ -169,7 +175,7 @@ export function useGsapAnimationsForElement(
169
175
 
170
176
  // Retry once if initial fetch returned 0 animations — handles
171
177
  // cold-load race where the sourceFile isn't resolved yet.
172
- if (parsed.animations.length === 0 && target) {
178
+ if (parsed.animations.length === 0 && targetKey) {
173
179
  retryTimerRef.current = setTimeout(() => {
174
180
  if (cancelled) return;
175
181
  fetchParsedAnimations(projectId, sourceFile).then((retryParsed) => {
@@ -189,7 +195,7 @@ export function useGsapAnimationsForElement(
189
195
  retryTimerRef.current = null;
190
196
  }
191
197
  };
192
- }, [projectId, sourceFile, version, target]);
198
+ }, [projectId, sourceFile, version, target?.id, target?.selector]);
193
199
 
194
200
  const targetId = target?.id ?? null;
195
201
  const targetSelector = target?.selector ?? null;
@@ -201,6 +207,7 @@ export function useGsapAnimationsForElement(
201
207
  [allAnimations, targetId, targetSelector],
202
208
  );
203
209
 
210
+ // fallow-ignore-next-line complexity
204
211
  const animations = useMemo(() => {
205
212
  const iframe = iframeRef?.current;
206
213
  let result = rawAnimations;
@@ -268,6 +275,7 @@ export function useGsapAnimationsForElement(
268
275
  // Merges keyframes from ALL animations targeting this element and synthesizes
269
276
  // flat tweens so the cache is never downgraded vs the bulk populate.
270
277
  const elementId = target?.id ?? null;
278
+ // fallow-ignore-next-line complexity
271
279
  useEffect(() => {
272
280
  if (!elementId) return;
273
281
 
@@ -287,6 +295,11 @@ export function useGsapAnimationsForElement(
287
295
  let ease: string | undefined;
288
296
  let easeEach: string | undefined;
289
297
  for (const anim of animations) {
298
+ if (
299
+ anim.method === "set" &&
300
+ Object.keys(anim.properties).every((k) => k === "x" || k === "y")
301
+ )
302
+ continue;
290
303
  const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
291
304
  if (!kf) continue;
292
305
  // Convert tween-relative percentages to clip-relative so diamonds
@@ -366,6 +379,7 @@ export function usePopulateKeyframeCacheForFile(
366
379
  if (!projectId) return;
367
380
 
368
381
  const sf = sourceFile;
382
+ // fallow-ignore-next-line complexity
369
383
  fetchParsedAnimations(projectId, sf).then((parsed) => {
370
384
  if (!parsed) return;
371
385
  const { setKeyframeCache } = usePlayerStore.getState();
@@ -376,6 +390,14 @@ export function usePopulateKeyframeCacheForFile(
376
390
  const id = extractIdFromSelector(anim.targetSelector);
377
391
  if (!id) continue;
378
392
  if (anim.hasUnresolvedKeyframes) continue;
393
+ // Position-only set tweens are static holds (created by drag), not
394
+ // keyframed animations — skip them so they don't show timeline diamonds.
395
+ if (anim.method === "set") {
396
+ const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender");
397
+ if (propKeys.every((k) => k === "x" || k === "y")) {
398
+ continue;
399
+ }
400
+ }
379
401
  const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
380
402
  if (!kfData) continue;
381
403
  const tweenPos =
@@ -430,6 +452,7 @@ export function usePopulateKeyframeCacheForFile(
430
452
  let attempts = 0;
431
453
  const maxAttempts = 10;
432
454
 
455
+ // fallow-ignore-next-line complexity
433
456
  const tryRuntimeScan = () => {
434
457
  if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
435
458
  const iframe =
@@ -449,7 +472,12 @@ export function usePopulateKeyframeCacheForFile(
449
472
  const fallbackKey = `index.html#${id}`;
450
473
  const alreadyCached =
451
474
  keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
452
- if (alreadyCached) {
475
+ if (alreadyCached) continue;
476
+ // Skip position-only set tweens from runtime too — same filter as AST path
477
+ const isPosOnly =
478
+ data.keyframes.length === 1 &&
479
+ Object.keys(data.keyframes[0].properties).every((k) => k === "x" || k === "y");
480
+ if (isPosOnly) {
453
481
  continue;
454
482
  }
455
483
  const entry = {
@@ -252,11 +252,6 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
252
252
  if (assetPollRef.current) clearInterval(assetPollRef.current);
253
253
  assetPollRef.current = null;
254
254
  setAssetsLoading(false);
255
- if (lastUnloaded) {
256
- console.debug(
257
- "[Player] Asset-loading overlay timed out after 10s; hiding anyway. Check network or asset integrity.",
258
- );
259
- }
260
255
  }
261
256
  }, 100);
262
257
  } else {
@@ -39,7 +39,8 @@ const ICONS: Record<string, ReactNode> = {
39
39
  };
40
40
 
41
41
  export function getTrackStyle(tag: string): TrackVisualStyle {
42
- if (!tag) console.warn("[Timeline] getTrackStyle received empty tag, defaulting to div");
42
+ // Defensive: callers may pass an empty/undefined tag; fall back to "div"
43
+ // (restores the #1679 null-guard that a restack had dropped).
43
44
  const safeTag = tag || "div";
44
45
  const trackStyle = getTimelineTrackStyle(safeTag);
45
46
  const normalized = safeTag.toLowerCase();
@@ -199,8 +199,7 @@ export function useTimelinePlayer() {
199
199
  }
200
200
 
201
201
  return bestAdapter;
202
- } catch (err) {
203
- console.warn("[useTimelinePlayer] Could not get playback adapter (cross-origin)", err);
202
+ } catch {
204
203
  return null;
205
204
  }
206
205
  }, []);
@@ -264,9 +263,7 @@ export function useTimelinePlayer() {
264
263
  }
265
264
  }
266
265
  }
267
- } catch (err) {
268
- console.warn("[useTimelinePlayer] Could not set playback rate (cross-origin)", err);
269
- }
266
+ } catch {}
270
267
  }, []);
271
268
  const applyPreviewAudioState = useCallback((playbackRateOverride?: number) => {
272
269
  const { audioMuted, playbackRate } = usePlayerStore.getState();
@@ -506,9 +503,7 @@ export function useTimelinePlayer() {
506
503
  if (msSinceTimeline > 500) {
507
504
  enrichMissingCompositionsRef.current();
508
505
  }
509
- } catch (err) {
510
- console.warn("[useTimelinePlayer] Could not read clip manifest from iframe", err);
511
- }
506
+ } catch {}
512
507
  }
513
508
  if (data?.source === "hf-preview" && data?.type === "timeline" && Array.isArray(data.clips)) {
514
509
  lastTimelineMessageRef.current = Date.now();
@@ -524,12 +519,7 @@ export function useTimelinePlayer() {
524
519
  syncTimelineElements(els);
525
520
  }
526
521
  }
527
- } catch (err) {
528
- console.warn(
529
- "[useTimelinePlayer] Could not read timeline elements on navigate (cross-origin)",
530
- err,
531
- );
532
- }
522
+ } catch {}
533
523
  }
534
524
  }
535
525
  };
@@ -164,9 +164,7 @@ export function useTimelineSyncCallbacks({
164
164
  const dedupedMissing = missing.filter((m) => !finalIds.has(m.id));
165
165
  syncTimelineElements([...updatedEls, ...dedupedMissing]);
166
166
  }
167
- } catch (err) {
168
- console.warn("[useTimelinePlayer] enrichMissingCompositions failed", err);
169
- }
167
+ } catch {}
170
168
  }, [iframeRef, syncTimelineElements]);
171
169
 
172
170
  const initializeAdapter = useCallback(() => {
@@ -241,9 +239,7 @@ export function useTimelineSyncCallbacks({
241
239
  if (fallbackElement) syncTimelineElements([fallbackElement]);
242
240
  }
243
241
  }
244
- } catch (err) {
245
- console.warn("[useTimelinePlayer] Could not read timeline elements from iframe", err);
246
- }
242
+ } catch {}
247
243
  return true;
248
244
  }, [
249
245
  getAdapter,
@@ -295,9 +291,6 @@ export function useTimelineSyncCallbacks({
295
291
  probeIntervalRef.current = setTimeout(() => {
296
292
  if (!settled) {
297
293
  trySettle();
298
- if (!settled) {
299
- console.warn("[useTimelinePlayer] Runtime did not signal readiness within 5s");
300
- }
301
294
  }
302
295
  window.removeEventListener("message", onMessage);
303
296
  }, 5000) as unknown as ReturnType<typeof setInterval>;
@@ -121,9 +121,7 @@ export function setPreviewMediaMuted(iframe: HTMLIFrameElement | null, muted: bo
121
121
  return;
122
122
  }
123
123
  postPreviewControl(iframe, "set-muted", { muted });
124
- } catch (err) {
125
- console.warn("[useTimelinePlayer] Failed to set preview media mute state", err);
126
- }
124
+ } catch {}
127
125
  }
128
126
 
129
127
  export function setPreviewPlaybackRate(
@@ -139,9 +137,7 @@ export function setPreviewPlaybackRate(
139
137
  return;
140
138
  }
141
139
  postPreviewControl(iframe, "set-playback-rate", { playbackRate: rate });
142
- } catch (err) {
143
- console.warn("[useTimelinePlayer] Failed to set preview playback rate", err);
144
- }
140
+ } catch {}
145
141
  }
146
142
 
147
143
  /**
@@ -129,6 +129,8 @@ function send(url: string, payload: string): void {
129
129
  function showNoticeOnce(): void {
130
130
  if (hasShownNotice()) return;
131
131
  markNoticeShown();
132
+ // Intentional one-time consent disclosure (not debug noise): tells users
133
+ // anonymous analytics are on and how to opt out. Kept behind a pragma.
132
134
  // eslint-disable-next-line no-console
133
135
  console.info(
134
136
  "%c[HyperFrames]%c Anonymous studio usage analytics enabled. " +
@@ -3,14 +3,7 @@
3
3
  // `window.__hfDebug = true` in the console. Single `[hf-edit:<scope>]` prefix so
4
4
  // the whole edit pipeline is greppable. Fires only at commit boundaries (user
5
5
  // actions), never in render/raf loops, so it doesn't spam.
6
- export function editLog(scope: string, ...args: unknown[]): void {
7
- if (typeof window === "undefined") return;
8
- const w = window as unknown as { __hfDebug?: boolean };
9
- if (!import.meta.env.DEV && !w.__hfDebug) return;
10
- // Stringify object args so the console prints their contents inline (`{x:1}`)
11
- // instead of a collapsed `Object` — keeps the edit trail greppable/copyable.
12
- const parts = args.map((a) =>
13
- typeof a === "object" && a !== null ? JSON.stringify(a) : String(a),
14
- );
15
- console.debug(`[hf-edit:${scope}]`, ...parts);
6
+ export function editLog(_scope: string, ..._args: unknown[]): void {
7
+ // ponytail: body removed all console.* stripped from studio.
8
+ // Restore with: console.log(`[hf-edit:${_scope}]`, ..._args);
16
9
  }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { smoothGestureKeyframes } from "./gestureSmoother";
3
+
4
+ describe("smoothGestureKeyframes", () => {
5
+ it("returns input unchanged for ≤2 keyframes", () => {
6
+ const kfs = [
7
+ { percentage: 0, properties: { x: 0, y: 0 } },
8
+ { percentage: 100, properties: { x: 100, y: 100 } },
9
+ ];
10
+ expect(smoothGestureKeyframes(kfs, 3)).toEqual(kfs);
11
+ });
12
+
13
+ it("pins first and last keyframes", () => {
14
+ const kfs = [
15
+ { percentage: 0, properties: { x: 0 } },
16
+ { percentage: 50, properties: { x: 999 } },
17
+ { percentage: 100, properties: { x: 200 } },
18
+ ];
19
+ const result = smoothGestureKeyframes(kfs, 3);
20
+ expect(result[0].properties.x).toBe(0);
21
+ expect(result[result.length - 1].properties.x).toBe(200);
22
+ });
23
+
24
+ it("smooths a zigzag into a gentler curve", () => {
25
+ const kfs = [
26
+ { percentage: 0, properties: { x: 0 } },
27
+ { percentage: 25, properties: { x: 100 } },
28
+ { percentage: 50, properties: { x: 0 } },
29
+ { percentage: 75, properties: { x: 100 } },
30
+ { percentage: 100, properties: { x: 0 } },
31
+ ];
32
+ const result = smoothGestureKeyframes(kfs, 2);
33
+ const mid = result[2].properties.x as number;
34
+ // The sharp 0→100→0 zigzag should be softened — mid should be
35
+ // pulled toward the neighbors, not stay at exactly 0.
36
+ expect(mid).toBeGreaterThan(0);
37
+ expect(mid).toBeLessThan(100);
38
+ });
39
+
40
+ it("returns input unchanged with radius 0", () => {
41
+ const kfs = [
42
+ { percentage: 0, properties: { x: 0 } },
43
+ { percentage: 50, properties: { x: 999 } },
44
+ { percentage: 100, properties: { x: 0 } },
45
+ ];
46
+ expect(smoothGestureKeyframes(kfs, 0)).toEqual(kfs);
47
+ });
48
+ });
@@ -0,0 +1,46 @@
1
+ // ponytail: Gaussian-weighted moving average over gesture keyframes.
2
+ // Rounds off jittery corners from raw pointer input while preserving
3
+ // overall path shape. First/last keyframes are pinned (never moved).
4
+ // Upgrade path: Catmull-Rom spline if users need curve-fitted paths.
5
+
6
+ interface Keyframe {
7
+ percentage: number;
8
+ properties: Record<string, number | string>;
9
+ }
10
+
11
+ function gaussianWeight(distance: number, sigma: number): number {
12
+ return Math.exp(-(distance * distance) / (2 * sigma * sigma));
13
+ }
14
+
15
+ export function smoothGestureKeyframes(keyframes: Keyframe[], radius: number): Keyframe[] {
16
+ if (keyframes.length <= 2 || radius <= 0) return keyframes;
17
+ const sigma = radius / 2;
18
+ const numericKeys = new Set<string>();
19
+ for (const kf of keyframes) {
20
+ for (const [k, v] of Object.entries(kf.properties)) {
21
+ if (typeof v === "number") numericKeys.add(k);
22
+ }
23
+ }
24
+ if (numericKeys.size === 0) return keyframes;
25
+
26
+ return keyframes.map((kf, i) => {
27
+ if (i === 0 || i === keyframes.length - 1) return kf;
28
+ const smoothed: Record<string, number | string> = { ...kf.properties };
29
+ for (const key of numericKeys) {
30
+ let weightSum = 0;
31
+ let valueSum = 0;
32
+ for (let j = Math.max(0, i - radius); j <= Math.min(keyframes.length - 1, i + radius); j++) {
33
+ const v = keyframes[j].properties[key];
34
+ if (typeof v !== "number") continue;
35
+ // Weight by index distance, not time. Samples here are roughly evenly
36
+ // spaced, so for the small radius (3) this is fine; switch to a
37
+ // percentage-domain distance if the window ever grows much larger.
38
+ const w = gaussianWeight(j - i, sigma);
39
+ weightSum += w;
40
+ valueSum += v * w;
41
+ }
42
+ if (weightSum > 0) smoothed[key] = Math.round((valueSum / weightSum) * 1000) / 1000;
43
+ }
44
+ return { percentage: kf.percentage, properties: smoothed };
45
+ });
46
+ }