@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.
- package/dist/assets/index-B9_ctmee.js +143 -0
- package/dist/assets/index-CGlIm_-E.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +159 -6
- package/src/components/StudioHeader.tsx +20 -7
- package/src/components/StudioPreviewArea.tsx +6 -1
- package/src/components/StudioRightPanel.tsx +13 -0
- package/src/components/StudioToast.tsx +47 -7
- package/src/components/TimelineToolbar.tsx +12 -122
- package/src/components/editor/AnimationCard.tsx +64 -10
- package/src/components/editor/ArcPathControls.tsx +131 -0
- package/src/components/editor/BorderRadiusEditor.tsx +209 -0
- package/src/components/editor/DomEditOverlay.tsx +70 -11
- package/src/components/editor/DopesheetStrip.tsx +141 -0
- package/src/components/editor/EaseCurveSection.tsx +82 -7
- package/src/components/editor/GestureTrailOverlay.tsx +132 -0
- package/src/components/editor/GsapAnimationSection.tsx +14 -1
- package/src/components/editor/KeyframeDiamond.tsx +27 -12
- package/src/components/editor/LayersPanel.tsx +14 -12
- package/src/components/editor/MotionPathOverlay.tsx +146 -0
- package/src/components/editor/PropertyPanel.tsx +196 -66
- package/src/components/editor/SourceEditor.tsx +0 -1
- package/src/components/editor/StaggerControls.tsx +61 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
- package/src/components/editor/domEditOverlayGeometry.ts +2 -1
- package/src/components/editor/domEditing.test.ts +43 -0
- package/src/components/editor/domEditing.ts +2 -0
- package/src/components/editor/domEditingElement.ts +25 -2
- package/src/components/editor/domEditingLayers.test.ts +78 -0
- package/src/components/editor/domEditingLayers.ts +33 -13
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +3 -0
- package/src/components/editor/manualEditsDom.ts +23 -5
- package/src/components/editor/manualOffsetDrag.ts +59 -0
- package/src/components/editor/panelTokens.ts +10 -0
- package/src/components/editor/propertyPanelColor.tsx +2 -2
- package/src/components/editor/propertyPanelFill.tsx +1 -1
- package/src/components/editor/propertyPanelHelpers.ts +18 -2
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
- package/src/components/editor/propertyPanelSections.tsx +4 -6
- package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
- package/src/components/editor/useDomEditOverlayRects.ts +46 -2
- package/src/components/renders/RenderQueue.tsx +121 -100
- package/src/components/renders/RenderQueueItem.tsx +13 -13
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/StudioContext.tsx +0 -4
- package/src/hooks/gsapKeyframeCommit.ts +92 -0
- package/src/hooks/gsapRuntimeBridge.ts +147 -85
- package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
- package/src/hooks/gsapRuntimePreview.ts +19 -0
- package/src/hooks/useAppHotkeys.ts +18 -0
- package/src/hooks/useAskAgentModal.ts +2 -4
- package/src/hooks/useDomEditCommits.ts +11 -17
- package/src/hooks/useDomEditSession.ts +47 -4
- package/src/hooks/useEnableKeyframes.ts +171 -0
- package/src/hooks/useFileManager.ts +7 -0
- package/src/hooks/useGestureRecording.ts +340 -0
- package/src/hooks/useGsapScriptCommits.ts +171 -35
- package/src/hooks/useGsapSelectionHandlers.ts +27 -8
- package/src/hooks/useGsapTweenCache.ts +169 -11
- package/src/hooks/useKeyframeKeyboard.ts +103 -0
- package/src/hooks/useStudioContextValue.ts +5 -4
- package/src/hooks/useStudioUrlState.ts +1 -2
- package/src/hooks/useTimelineEditing.ts +50 -3
- package/src/hooks/useToast.ts +6 -1
- package/src/player/components/ShortcutsPanel.tsx +40 -0
- package/src/player/components/TimelineClipDiamonds.tsx +3 -3
- package/src/player/components/TimelinePropertyRows.tsx +120 -0
- package/src/player/lib/timelineDOM.test.ts +55 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +43 -0
- package/src/utils/audioBeatDetection.ts +58 -0
- package/src/utils/globalTimeCompiler.test.ts +169 -0
- package/src/utils/globalTimeCompiler.ts +77 -0
- package/src/utils/gsapSoftReload.ts +30 -10
- package/src/utils/keyframeSnapping.test.ts +74 -0
- package/src/utils/keyframeSnapping.ts +63 -0
- package/src/utils/rdpSimplify.ts +183 -0
- package/src/utils/sourcePatcher.ts +2 -0
- package/dist/assets/index-BT9VHgSy.js +0 -140
- package/dist/assets/index-DHcptK1_.css +0 -1
|
@@ -4,7 +4,11 @@ type IframeWindow = Window & {
|
|
|
4
4
|
__hfForceTimelineRebind?: () => void;
|
|
5
5
|
__hfSuppressSceneMutations?: <T>(fn: () => T) => T;
|
|
6
6
|
__hfStudioManualEditsApply?: () => void;
|
|
7
|
-
gsap?: {
|
|
7
|
+
gsap?: {
|
|
8
|
+
timeline?: (...args: unknown[]) => unknown;
|
|
9
|
+
registerPlugin?: (...plugins: unknown[]) => unknown;
|
|
10
|
+
};
|
|
11
|
+
MotionPathPlugin?: unknown;
|
|
8
12
|
};
|
|
9
13
|
|
|
10
14
|
function isGsapScript(text: string): boolean {
|
|
@@ -64,16 +68,32 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
|
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
oldScriptEl.remove();
|
|
67
|
-
const newScript = doc.createElement("script");
|
|
68
|
-
// IIFE prevents const/let redeclaration errors across consecutive edits.
|
|
69
|
-
// Top-level declarations are scoped to the IIFE; window.* assignments
|
|
70
|
-
// (e.g. window.__timelines["root"] = tl) still reach the global scope.
|
|
71
|
-
newScript.textContent = `(function(){${scriptText}\n})();`;
|
|
72
|
-
doc.body.appendChild(newScript);
|
|
73
71
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
const executeScript = () => {
|
|
73
|
+
if (win.MotionPathPlugin && win.gsap?.registerPlugin) {
|
|
74
|
+
win.gsap.registerPlugin(win.MotionPathPlugin);
|
|
75
|
+
}
|
|
76
|
+
const s = doc.createElement("script");
|
|
77
|
+
s.textContent = `(function(){${scriptText}\n})();`;
|
|
78
|
+
doc.body.appendChild(s);
|
|
79
|
+
win.__hfForceTimelineRebind?.();
|
|
80
|
+
win.__player?.seek?.(currentTime);
|
|
81
|
+
win.__hfStudioManualEditsApply?.();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Load MotionPathPlugin on demand if the script uses motionPath.
|
|
85
|
+
// Uses the same CDN as composition templates (GSAP_CDN in constants.ts).
|
|
86
|
+
const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
|
|
87
|
+
if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
|
|
88
|
+
const pluginScript = doc.createElement("script");
|
|
89
|
+
pluginScript.src = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
|
|
90
|
+
pluginScript.onload = () => executeScript();
|
|
91
|
+
pluginScript.onerror = () => executeScript();
|
|
92
|
+
doc.head.appendChild(pluginScript);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
executeScript();
|
|
77
97
|
};
|
|
78
98
|
|
|
79
99
|
try {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { computeSnapThreshold, snapKeyframe } from "./keyframeSnapping";
|
|
3
|
+
|
|
4
|
+
describe("snapKeyframe", () => {
|
|
5
|
+
test("snaps to frame boundary", () => {
|
|
6
|
+
const result = snapKeyframe(0.34, { fps: 30, keyframeTimes: [], threshold: 0.05 });
|
|
7
|
+
expect(result.snapType).toBe("frame");
|
|
8
|
+
expect(Math.abs(result.snappedTime - 1 / 3)).toBeLessThan(0.01);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("snaps to cross-element keyframe when closest", () => {
|
|
12
|
+
const result = snapKeyframe(1.005, { fps: 30, keyframeTimes: [1.0], threshold: 0.05 });
|
|
13
|
+
expect(result.snapType).toBe("keyframe");
|
|
14
|
+
expect(result.snappedTime).toBe(1.0);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("keyframe snap wins tie with frame at same position", () => {
|
|
18
|
+
const result = snapKeyframe(1.0, { fps: 30, keyframeTimes: [1.0], threshold: 0.05 });
|
|
19
|
+
expect(result.snapType).toBe("keyframe");
|
|
20
|
+
expect(result.snappedTime).toBe(1.0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("snaps to beat marker when closer than frame", () => {
|
|
24
|
+
const result = snapKeyframe(2.49, {
|
|
25
|
+
fps: 30,
|
|
26
|
+
keyframeTimes: [],
|
|
27
|
+
beatTimes: [2.5],
|
|
28
|
+
threshold: 0.05,
|
|
29
|
+
});
|
|
30
|
+
expect(result.snapType).toBe("beat");
|
|
31
|
+
expect(result.snappedTime).toBe(2.5);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("disabled returns raw time", () => {
|
|
35
|
+
const result = snapKeyframe(1.5, {
|
|
36
|
+
fps: 30,
|
|
37
|
+
keyframeTimes: [1.5],
|
|
38
|
+
threshold: 0.05,
|
|
39
|
+
disabled: true,
|
|
40
|
+
});
|
|
41
|
+
expect(result.snapType).toBeNull();
|
|
42
|
+
expect(result.snappedTime).toBe(1.5);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("no snap when outside threshold", () => {
|
|
46
|
+
const result = snapKeyframe(1.5, {
|
|
47
|
+
fps: 30,
|
|
48
|
+
keyframeTimes: [0.5],
|
|
49
|
+
threshold: 0.05,
|
|
50
|
+
});
|
|
51
|
+
expect(result.snapType).toBe("frame");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("empty beat times is graceful", () => {
|
|
55
|
+
const result = snapKeyframe(0.5, {
|
|
56
|
+
fps: 30,
|
|
57
|
+
keyframeTimes: [],
|
|
58
|
+
beatTimes: [],
|
|
59
|
+
threshold: 0.05,
|
|
60
|
+
});
|
|
61
|
+
expect(result.snapType).toBe("frame");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("computeSnapThreshold", () => {
|
|
66
|
+
test("returns threshold based on pixels per second", () => {
|
|
67
|
+
const threshold = computeSnapThreshold(100, 5);
|
|
68
|
+
expect(threshold).toBe(0.05);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("fallback for zero pixels per second", () => {
|
|
72
|
+
expect(computeSnapThreshold(0)).toBe(0.1);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type SnapType = "frame" | "keyframe" | "beat" | null;
|
|
2
|
+
|
|
3
|
+
export interface SnapResult {
|
|
4
|
+
snappedTime: number;
|
|
5
|
+
snapType: SnapType;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function snapKeyframe(
|
|
9
|
+
time: number,
|
|
10
|
+
options: {
|
|
11
|
+
fps: number;
|
|
12
|
+
keyframeTimes: number[];
|
|
13
|
+
beatTimes?: number[];
|
|
14
|
+
threshold: number;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
},
|
|
17
|
+
): SnapResult {
|
|
18
|
+
if (options.disabled) return { snappedTime: time, snapType: null };
|
|
19
|
+
|
|
20
|
+
const { fps, keyframeTimes, beatTimes = [], threshold } = options;
|
|
21
|
+
|
|
22
|
+
let bestDist = threshold;
|
|
23
|
+
let bestTime = time;
|
|
24
|
+
let bestType: SnapType = null;
|
|
25
|
+
|
|
26
|
+
// Priority: cross-element keyframes > beat markers > frame boundaries
|
|
27
|
+
// Higher priority snaps use strict < so they win on equal distance
|
|
28
|
+
if (fps > 0) {
|
|
29
|
+
const frameDuration = 1 / fps;
|
|
30
|
+
const nearestFrame = Math.round(time / frameDuration) * frameDuration;
|
|
31
|
+
const dist = Math.abs(time - nearestFrame);
|
|
32
|
+
if (dist < bestDist) {
|
|
33
|
+
bestDist = dist;
|
|
34
|
+
bestTime = nearestFrame;
|
|
35
|
+
bestType = "frame";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const bt of beatTimes) {
|
|
40
|
+
const dist = Math.abs(time - bt);
|
|
41
|
+
if (dist <= bestDist) {
|
|
42
|
+
bestDist = dist;
|
|
43
|
+
bestTime = bt;
|
|
44
|
+
bestType = "beat";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const kt of keyframeTimes) {
|
|
49
|
+
const dist = Math.abs(time - kt);
|
|
50
|
+
if (dist <= bestDist) {
|
|
51
|
+
bestDist = dist;
|
|
52
|
+
bestTime = kt;
|
|
53
|
+
bestType = "keyframe";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { snappedTime: bestTime, snapType: bestType };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function computeSnapThreshold(pixelsPerSecond: number, baseThresholdPx: number = 5): number {
|
|
61
|
+
if (pixelsPerSecond <= 0) return 0.1;
|
|
62
|
+
return baseThresholdPx / pixelsPerSecond;
|
|
63
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ramer-Douglas-Peucker simplification for time-series data.
|
|
3
|
+
*
|
|
4
|
+
* Used to reduce gesture recording samples into a minimal set of keyframes
|
|
5
|
+
* that approximate the original curve within a configurable tolerance.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// 1D time-series simplification
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Perpendicular distance from point (t, v) to the line segment between
|
|
14
|
+
* (t1, v1) and (t2, v2). For 1D time-series this reduces to the vertical
|
|
15
|
+
* distance from the point to the interpolated value on the line.
|
|
16
|
+
*/
|
|
17
|
+
function perpendicularDistance(
|
|
18
|
+
t: number,
|
|
19
|
+
v: number,
|
|
20
|
+
t1: number,
|
|
21
|
+
v1: number,
|
|
22
|
+
t2: number,
|
|
23
|
+
v2: number,
|
|
24
|
+
): number {
|
|
25
|
+
// Degenerate case: start and end share the same time
|
|
26
|
+
if (t2 === t1) return Math.abs(v - v1);
|
|
27
|
+
const interpolated = v1 + ((v2 - v1) * (t - t1)) / (t2 - t1);
|
|
28
|
+
return Math.abs(v - interpolated);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Standard Ramer-Douglas-Peucker on 1D time-series data.
|
|
33
|
+
*
|
|
34
|
+
* Each point is treated as (time, value) in 2D space. Returns the minimal
|
|
35
|
+
* subset of input points that approximates the curve within `epsilon`.
|
|
36
|
+
*
|
|
37
|
+
* - `epsilon = 0` returns all points (no simplification).
|
|
38
|
+
* - A large `epsilon` returns just the first and last points.
|
|
39
|
+
* - Empty or single-point input is returned unchanged.
|
|
40
|
+
*/
|
|
41
|
+
function simplifyTimeSeries(
|
|
42
|
+
points: Array<{ time: number; value: number }>,
|
|
43
|
+
epsilon: number,
|
|
44
|
+
): Array<{ time: number; value: number }> {
|
|
45
|
+
if (points.length <= 2) return points;
|
|
46
|
+
if (epsilon <= 0) return points;
|
|
47
|
+
|
|
48
|
+
const first = points[0];
|
|
49
|
+
const last = points[points.length - 1];
|
|
50
|
+
|
|
51
|
+
let maxDist = 0;
|
|
52
|
+
let maxIndex = 0;
|
|
53
|
+
|
|
54
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
55
|
+
const d = perpendicularDistance(
|
|
56
|
+
points[i].time,
|
|
57
|
+
points[i].value,
|
|
58
|
+
first.time,
|
|
59
|
+
first.value,
|
|
60
|
+
last.time,
|
|
61
|
+
last.value,
|
|
62
|
+
);
|
|
63
|
+
if (d > maxDist) {
|
|
64
|
+
maxDist = d;
|
|
65
|
+
maxIndex = i;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (maxDist > epsilon) {
|
|
70
|
+
const left = simplifyTimeSeries(points.slice(0, maxIndex + 1), epsilon);
|
|
71
|
+
const right = simplifyTimeSeries(points.slice(maxIndex), epsilon);
|
|
72
|
+
// left includes maxIndex, right starts with maxIndex — drop the duplicate
|
|
73
|
+
return left.slice(0, -1).concat(right);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [first, last];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Multi-property gesture simplification
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Simplify gesture recording samples into percentage-keyed keyframes.
|
|
85
|
+
*
|
|
86
|
+
* Runs `simplifyTimeSeries` independently per property across all samples,
|
|
87
|
+
* then merges the retained time points into a single Map keyed by percentage
|
|
88
|
+
* of `totalDuration` (0–100, rounded to 1 decimal).
|
|
89
|
+
*
|
|
90
|
+
* Independent per-property simplification means that complex motion on one
|
|
91
|
+
* property (e.g. `x`) does not force extra keyframes on a simpler property
|
|
92
|
+
* (e.g. `opacity`).
|
|
93
|
+
*
|
|
94
|
+
* At each retained percentage the output contains all properties interpolated
|
|
95
|
+
* at that time — not just the property that caused the time point to survive.
|
|
96
|
+
*/
|
|
97
|
+
export function simplifyGestureSamples(
|
|
98
|
+
samples: Array<{ time: number; properties: Record<string, number> }>,
|
|
99
|
+
totalDuration: number,
|
|
100
|
+
epsilon: number,
|
|
101
|
+
): Map<number, Record<string, number>> {
|
|
102
|
+
if (samples.length === 0) return new Map();
|
|
103
|
+
if (totalDuration <= 0) return new Map();
|
|
104
|
+
|
|
105
|
+
// Collect all property keys present across samples
|
|
106
|
+
const propertyKeys = new Set<string>();
|
|
107
|
+
for (const s of samples) {
|
|
108
|
+
for (const key of Object.keys(s.properties)) {
|
|
109
|
+
propertyKeys.add(key);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Run RDP independently per property and collect surviving times
|
|
114
|
+
const survivingTimes = new Set<number>();
|
|
115
|
+
|
|
116
|
+
for (const key of propertyKeys) {
|
|
117
|
+
const series: Array<{ time: number; value: number }> = [];
|
|
118
|
+
for (const s of samples) {
|
|
119
|
+
if (key in s.properties) {
|
|
120
|
+
series.push({ time: s.time, value: s.properties[key] });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const simplified = simplifyTimeSeries(series, epsilon);
|
|
124
|
+
for (const pt of simplified) {
|
|
125
|
+
survivingTimes.add(pt.time);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Sort surviving times so we can iterate in order
|
|
130
|
+
const sortedTimes = Array.from(survivingTimes).sort((a, b) => a - b);
|
|
131
|
+
|
|
132
|
+
// For each surviving time, interpolate all properties and store by percentage
|
|
133
|
+
const result = new Map<number, Record<string, number>>();
|
|
134
|
+
|
|
135
|
+
for (const t of sortedTimes) {
|
|
136
|
+
const pct = Math.round((t / totalDuration) * 1000) / 10; // 1 decimal
|
|
137
|
+
const props: Record<string, number> = {};
|
|
138
|
+
|
|
139
|
+
for (const key of propertyKeys) {
|
|
140
|
+
props[key] = interpolatePropertyAtTime(samples, key, t);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
result.set(pct, props);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Linearly interpolate a single property value at the given time from the
|
|
151
|
+
* samples array. Assumes samples are sorted by time.
|
|
152
|
+
*/
|
|
153
|
+
function interpolatePropertyAtTime(
|
|
154
|
+
samples: Array<{ time: number; properties: Record<string, number> }>,
|
|
155
|
+
key: string,
|
|
156
|
+
t: number,
|
|
157
|
+
): number {
|
|
158
|
+
// Find bracketing samples that contain this property
|
|
159
|
+
let before: { time: number; value: number } | undefined;
|
|
160
|
+
let after: { time: number; value: number } | undefined;
|
|
161
|
+
|
|
162
|
+
for (const s of samples) {
|
|
163
|
+
if (!(key in s.properties)) continue;
|
|
164
|
+
const v = s.properties[key];
|
|
165
|
+
|
|
166
|
+
if (s.time <= t) {
|
|
167
|
+
before = { time: s.time, value: v };
|
|
168
|
+
}
|
|
169
|
+
if (s.time >= t && after === undefined) {
|
|
170
|
+
after = { time: s.time, value: v };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Exact match or only one side available
|
|
175
|
+
if (before && before.time === t) return before.value;
|
|
176
|
+
if (after && after.time === t) return after.value;
|
|
177
|
+
if (!before) return after!.value;
|
|
178
|
+
if (!after) return before.value;
|
|
179
|
+
|
|
180
|
+
// Linear interpolation
|
|
181
|
+
const ratio = (t - before.time) / (after.time - before.time);
|
|
182
|
+
return before.value + (after.value - before.value) * ratio;
|
|
183
|
+
}
|
|
@@ -92,6 +92,8 @@ export interface PatchOperation {
|
|
|
92
92
|
value: string | null;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// Runtime validation for hfId lives in findTagByTarget → execDataAttrPattern (CSS attr-value
|
|
96
|
+
// escape). This type is documentation only; the server's MutationTarget mirrors this shape.
|
|
95
97
|
export interface PatchTarget {
|
|
96
98
|
id?: string | null;
|
|
97
99
|
hfId?: string;
|