@hyperframes/studio 0.7.4 → 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.
- package/dist/assets/{hyperframes-player-Ztwg53-a.js → hyperframes-player-BGW5hpsb.js} +2 -2
- package/dist/assets/{index-CUhSVvnV.js → index-B_NnmU6q.js} +1 -1
- package/dist/assets/{index-DvpQwnaB.js → index-DsBhGFPe.js} +1 -1
- package/dist/assets/{index-DzWIinxk.css → index-wJZf6FK3.css} +1 -1
- package/dist/assets/index-ysftPins.js +374 -0
- package/dist/chunk-KZXYQYIU.js +876 -0
- package/dist/chunk-KZXYQYIU.js.map +1 -0
- package/dist/domEditingLayers-SSXQZHHQ.js +41 -0
- package/dist/domEditingLayers-SSXQZHHQ.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.html +2 -2
- package/dist/index.js +2298 -2483
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/components/StudioPreviewArea.tsx +10 -3
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/editor/AnimationCard.tsx +72 -251
- package/src/components/editor/AnimationCardParts.tsx +220 -0
- package/src/components/editor/BlockParamsPanel.tsx +4 -8
- package/src/components/editor/DomEditOverlay.tsx +132 -48
- package/src/components/editor/EaseCurveSection.tsx +6 -6
- package/src/components/editor/GsapAnimationSection.tsx +2 -0
- package/src/components/editor/KeyframeEaseList.tsx +63 -0
- package/src/components/editor/OffCanvasIndicators.tsx +111 -0
- package/src/components/editor/PropertyPanel.tsx +52 -54
- package/src/components/editor/gsapAnimationCallbacks.ts +1 -0
- package/src/components/editor/gsapAnimationConstants.ts +8 -0
- package/src/components/editor/manualOffsetDrag.ts +8 -0
- package/src/components/editor/marqueeCommit.ts +168 -0
- package/src/components/editor/propertyPanelHelpers.ts +1 -0
- package/src/components/editor/snapTargetCollection.ts +0 -5
- package/src/components/panels/SlideshowPanel.tsx +0 -1
- package/src/contexts/DomEditContext.tsx +8 -0
- package/src/hooks/gsapDragPositionCommit.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.ts +6 -2
- package/src/hooks/gsapRuntimeReaders.ts +1 -6
- package/src/hooks/useDomEditCommits.ts +0 -4
- package/src/hooks/useDomEditPreviewSync.ts +5 -0
- package/src/hooks/useDomEditSession.ts +23 -0
- package/src/hooks/useDomEditTextCommits.ts +3 -12
- package/src/hooks/useDomSelection.ts +46 -0
- package/src/hooks/useGestureCommit.ts +26 -3
- package/src/hooks/useGestureRecording.ts +2 -16
- package/src/hooks/useGsapAnimationOps.ts +2 -2
- package/src/hooks/useGsapTweenCache.ts +32 -4
- package/src/player/components/Player.tsx +0 -5
- package/src/player/components/timelineIcons.tsx +2 -1
- package/src/player/hooks/useTimelinePlayer.ts +4 -14
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -9
- package/src/player/lib/timelineIframeHelpers.ts +2 -6
- package/src/telemetry/client.ts +2 -0
- package/src/utils/editDebugLog.ts +3 -10
- package/src/utils/gestureSmoother.test.ts +48 -0
- package/src/utils/gestureSmoother.ts +46 -0
- package/src/utils/marqueeGeometry.test.ts +123 -0
- package/src/utils/marqueeGeometry.ts +172 -0
- package/src/utils/optimisticUpdate.ts +1 -2
- package/src/utils/sourcePatcher.ts +0 -10
- package/src/utils/velocityEaseFitter.test.ts +58 -0
- package/src/utils/velocityEaseFitter.ts +121 -0
- package/dist/assets/index-Dp0h2I9v.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
|
|
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
|
|
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
|
|
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 = {
|
|
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({
|
|
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
|
|
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
|
|
378
|
+
} catch {
|
|
389
379
|
// Preview failed — disable it for the rest of the gesture (recording
|
|
390
|
-
// continues).
|
|
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)
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
143
|
-
console.warn("[useTimelinePlayer] Failed to set preview playback rate", err);
|
|
144
|
-
}
|
|
140
|
+
} catch {}
|
|
145
141
|
}
|
|
146
142
|
|
|
147
143
|
/**
|
package/src/telemetry/client.ts
CHANGED
|
@@ -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(
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
}
|