react-native-smooth-clip-view 0.2.4 → 0.2.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 (40) hide show
  1. package/README.md +62 -25
  2. package/android/CMakeLists.txt +0 -1
  3. package/android/src/main/cpp/SmoothClipAndroid.h +5 -0
  4. package/android/src/main/cpp/SmoothClipBindings.cpp +28 -8
  5. package/android/src/main/cpp/SmoothClipRegistry.cpp +198 -189
  6. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +61 -11
  7. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +20 -0
  8. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +1 -0
  9. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +105 -34
  10. package/cpp/SmoothClipAnimationCurve.h +468 -0
  11. package/cpp/SmoothClipAnimationId.h +32 -0
  12. package/cpp/SmoothClipRegistry.h +11 -1
  13. package/cpp/SmoothClipVelocityTracker.h +32 -15
  14. package/ios/SmoothClipRegistry.mm +246 -97
  15. package/ios/SmoothClipTurboModule.cpp +4 -2
  16. package/ios/SmoothClipTurboModule.h +2 -1
  17. package/ios/SmoothClipView.mm +73 -16
  18. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  19. package/lib/module/drivers.android.js +3 -1
  20. package/lib/module/drivers.android.js.map +1 -1
  21. package/lib/module/drivers.ios.js +3 -391
  22. package/lib/module/drivers.ios.js.map +1 -1
  23. package/lib/module/drivers.native.js +470 -0
  24. package/lib/module/drivers.native.js.map +1 -0
  25. package/lib/typescript/src/NativeSmoothClipModule.d.ts +1 -1
  26. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  27. package/lib/typescript/src/driverTypes.d.ts +6 -3
  28. package/lib/typescript/src/driverTypes.d.ts.map +1 -1
  29. package/lib/typescript/src/drivers.android.d.ts +2 -2
  30. package/lib/typescript/src/drivers.android.d.ts.map +1 -1
  31. package/lib/typescript/src/drivers.ios.d.ts +2 -4
  32. package/lib/typescript/src/drivers.ios.d.ts.map +1 -1
  33. package/lib/typescript/src/drivers.native.d.ts +5 -0
  34. package/lib/typescript/src/drivers.native.d.ts.map +1 -0
  35. package/package.json +1 -1
  36. package/src/NativeSmoothClipModule.ts +2 -1
  37. package/src/driverTypes.ts +6 -3
  38. package/src/drivers.android.ts +4 -2
  39. package/src/drivers.ios.ts +4 -687
  40. package/src/drivers.native.ts +805 -0
@@ -0,0 +1,468 @@
1
+ #pragma once
2
+
3
+ #include "SmoothClipRegistry.h"
4
+
5
+ #include <algorithm>
6
+ #include <array>
7
+ #include <cmath>
8
+ #include <cstddef>
9
+ #include <utility>
10
+ #include <vector>
11
+
12
+ // Shared animation math for the Android integrator and the iOS CoreAnimation
13
+ // restart paths: frame-clock anchoring, timing curves/remainders, spring
14
+ // continuation, presentation blending and keyframes. Keeping the pure math out
15
+ // of the registry translation units also lets the XCTest binary pin every rule
16
+ // below — see ios/tests/SmoothClipAnimationCurveTests.mm.
17
+ namespace smoothclip {
18
+
19
+ inline double clamp01(double value) {
20
+ return std::min(1.0, std::max(0.0, value));
21
+ }
22
+
23
+ // --- Frame clock ----------------------------------------------------------
24
+
25
+ // Moves a wall-clock start stamp onto the choreographer frame-time axis by
26
+ // taking the EARLIER of the two stamps.
27
+ //
28
+ // This is Reanimated's own rule, which is what keeps a parallel `withTiming`
29
+ // and the native clip at the same phase: `valueSetter.ts` stamps `startTime`
30
+ // with `global.__frameTimestamp || _getAnimationTimestamp()`, and `min()`
31
+ // reproduces both branches — a call issued between frames keeps its wall stamp
32
+ // (the dispatching frame is later, so the first fraction is already positive
33
+ // and no start frame is duplicated), while a call issued inside the frame that
34
+ // dispatches us adopts that frame's stamp.
35
+ //
36
+ // Deliberately NOT a wall-elapsed rebase (`frameNow - (wallNow - startedAt)`):
37
+ // that bakes the first frame's intra-frame dispatch latency into the curve for
38
+ // the animation's whole duration.
39
+ //
40
+ // Both arguments must be CLOCK_MONOTONIC seconds. This compares them
41
+ // absolutely, so a shared epoch is load-bearing — see nowSeconds() in the
42
+ // Android registry for what breaks if that ever stops holding.
43
+ //
44
+ // min() is an APPROXIMATION of Reanimated's rule that reads no JS state, and
45
+ // it has one wrong branch: a start issued from an earlier phase of a frame
46
+ // already in flight (CALLBACK_INPUT — batched gesture moves) is dispatched in
47
+ // that same doFrame, whose stamp is EARLIER than the call. min() adopts the
48
+ // frame stamp while Reanimated — outside its rAF flush, where
49
+ // __frameTimestamp is cleared — keeps its wall stamp, so the clip led a
50
+ // parallel Reanimated animation by the callback's intra-frame offset for the
51
+ // whole duration. Starts that carry the JS-captured stamp resolve through
52
+ // resolveStartStamp() below and skip this anchor entirely; min() remains for
53
+ // latch flushes and stamp-less callers, where its two branches are exact.
54
+ inline double anchorStartTime(double startedAtS, double frameNowS) {
55
+ return std::min(startedAtS, frameNowS);
56
+ }
57
+
58
+ // A resolved start stamp for a new animation.
59
+ struct StartStamp {
60
+ double startedAtS;
61
+ // True when startedAtS already lives on Reanimated's own start axis (the
62
+ // JS side captured `__frameTimestamp || _getAnimationTimestamp()` in the
63
+ // same worklet that issued the animateTo), so the first advance() must NOT
64
+ // re-anchor it with min() — see the CALLBACK_INPUT note on
65
+ // anchorStartTime().
66
+ bool frameClockAnchored;
67
+ };
68
+
69
+ // How far a JS-captured stamp may sit from the native wall clock and still be
70
+ // trusted. The two reads happen microseconds apart in the same synchronous
71
+ // call stack, and a __frameTimestamp hint can lag by at most one stalled
72
+ // frame; a full second means a broken epoch (a caller stamping from the wrong
73
+ // clock), where falling back to the native clock is strictly safer than
74
+ // completing every animation on its first frame.
75
+ inline constexpr double kStartStampSanityWindowS = 1.0;
76
+
77
+ inline StartStamp resolveStartStamp(double jsStampS, double wallNowS) {
78
+ const bool usable = std::isfinite(jsStampS) &&
79
+ std::abs(jsStampS - wallNowS) <= kStartStampSanityWindowS;
80
+ if (usable) return {jsStampS, true};
81
+ return {wallNowS, false};
82
+ }
83
+
84
+ // Elapsed fraction of a timing/keyframe animation. A non-positive duration is
85
+ // complete on its first frame rather than dividing by zero.
86
+ inline double timingFraction(
87
+ double nowS,
88
+ double startedAtS,
89
+ double durationS) {
90
+ if (durationS <= 0) return 1.0;
91
+ return clamp01((nowS - startedAtS) / durationS);
92
+ }
93
+
94
+ // --- Timing curve continuation -------------------------------------------
95
+
96
+ struct CubicBezierPoint {
97
+ double x;
98
+ double y;
99
+ };
100
+
101
+ inline CubicBezierPoint mixBezierPoint(
102
+ CubicBezierPoint from,
103
+ CubicBezierPoint to,
104
+ double progress) {
105
+ return {from.x + (to.x - from.x) * progress,
106
+ from.y + (to.y - from.y) * progress};
107
+ }
108
+
109
+ inline double cubicBezierParameterForX(
110
+ double x1,
111
+ double x2,
112
+ double input,
113
+ int iterations = 14) {
114
+ double low = 0;
115
+ double high = 1;
116
+ // Keep the runtime evaluator's established precision/cost: this same solve
117
+ // runs on every Android timing frame.
118
+ for (int iteration = 0; iteration < iterations; iteration += 1) {
119
+ const double t = (low + high) / 2;
120
+ const double inverse = 1 - t;
121
+ const double x = 3 * inverse * inverse * t * x1 +
122
+ 3 * inverse * t * t * x2 + t * t * t;
123
+ if (x < input) {
124
+ low = t;
125
+ } else {
126
+ high = t;
127
+ }
128
+ }
129
+ return (low + high) / 2;
130
+ }
131
+
132
+ inline double cubicBezier(
133
+ double x1,
134
+ double y1,
135
+ double x2,
136
+ double y2,
137
+ double input) {
138
+ const double t = cubicBezierParameterForX(x1, x2, clamp01(input));
139
+ const double inverse = 1 - t;
140
+ return 3 * inverse * inverse * t * y1 +
141
+ 3 * inverse * t * t * y2 + t * t * t;
142
+ }
143
+
144
+ struct TimingRemainder {
145
+ TimingAnimation animation;
146
+ double easedProgress;
147
+ bool representable;
148
+ };
149
+
150
+ // Exact right-hand segment of a CSS/CoreAnimation cubic Bezier after a raw
151
+ // time cutoff. Restarting the original controls from a frozen presentation
152
+ // resets its slope and creates a visible velocity cusp. De Casteljau splitting
153
+ // preserves the original curve; normalizing the right segment to (0,0)->(1,1)
154
+ // makes it directly reusable with a shorter BasicAnimation/integrator run.
155
+ inline TimingRemainder timingRemainder(
156
+ const TimingAnimation &original,
157
+ double rawProgress) {
158
+ const double progress = clamp01(rawProgress);
159
+ if (progress <= 0) return {original, 0, true};
160
+ if (progress >= 1) {
161
+ TimingAnimation complete = original;
162
+ complete.durationMs = 0;
163
+ complete.controlPoint1X = 0;
164
+ complete.controlPoint1Y = 0;
165
+ complete.controlPoint2X = 1;
166
+ complete.controlPoint2Y = 1;
167
+ return {complete, 1, true};
168
+ }
169
+
170
+ const double t = cubicBezierParameterForX(
171
+ original.controlPoint1X, original.controlPoint2X, progress, 30);
172
+ const CubicBezierPoint p0{0, 0};
173
+ const CubicBezierPoint p1{
174
+ original.controlPoint1X, original.controlPoint1Y};
175
+ const CubicBezierPoint p2{
176
+ original.controlPoint2X, original.controlPoint2Y};
177
+ const CubicBezierPoint p3{1, 1};
178
+ const CubicBezierPoint a = mixBezierPoint(p0, p1, t);
179
+ const CubicBezierPoint b = mixBezierPoint(p1, p2, t);
180
+ const CubicBezierPoint c = mixBezierPoint(p2, p3, t);
181
+ const CubicBezierPoint d = mixBezierPoint(a, b, t);
182
+ const CubicBezierPoint e = mixBezierPoint(b, c, t);
183
+ const CubicBezierPoint split = mixBezierPoint(d, e, t);
184
+
185
+ TimingAnimation remainder = original;
186
+ remainder.durationMs = original.durationMs * (1 - progress);
187
+ const double remainingX = 1 - split.x;
188
+ const double remainingY = 1 - split.y;
189
+ // A curve whose y reaches exactly 1 before its time endpoint and later
190
+ // departs cannot be represented as one endpoint-normalized BasicAnimation.
191
+ // Keep the result finite; ordinary monotone easings never take this branch.
192
+ if (std::abs(remainingX) <= 1e-12 || std::abs(remainingY) <= 1e-12) {
193
+ remainder.controlPoint1X = 0;
194
+ remainder.controlPoint1Y = 0;
195
+ remainder.controlPoint2X = 1;
196
+ remainder.controlPoint2Y = 1;
197
+ return {remainder, split.y, false};
198
+ }
199
+ remainder.controlPoint1X = (e.x - split.x) / remainingX;
200
+ remainder.controlPoint1Y = (e.y - split.y) / remainingY;
201
+ remainder.controlPoint2X = (c.x - split.x) / remainingX;
202
+ remainder.controlPoint2Y = (c.y - split.y) / remainingY;
203
+ return {remainder, split.y, true};
204
+ }
205
+
206
+ // --- Spring continuation --------------------------------------------------
207
+
208
+ struct NormalizedSpringState {
209
+ // Remaining fraction of the original from->target displacement.
210
+ double remaining;
211
+ // Derivative of `remaining` in units per second.
212
+ double velocity;
213
+ };
214
+
215
+ // Closed-form state of the same mass/spring/damper equation CoreAnimation
216
+ // evaluates. If p is animation progress, remaining=1-p starts at 1 and its
217
+ // derivative starts at -initialVelocity.
218
+ inline NormalizedSpringState normalizedSpringState(
219
+ const SpringAnimation &spring,
220
+ double elapsedS) {
221
+ if (!std::isfinite(elapsedS) || elapsedS <= 0) {
222
+ return {1, -spring.initialVelocity};
223
+ }
224
+ const double mass = spring.mass;
225
+ const double stiffness = spring.stiffness;
226
+ const double damping = spring.damping;
227
+ const double initialVelocity = spring.initialVelocity;
228
+ if (!std::isfinite(mass) || !std::isfinite(stiffness) ||
229
+ !std::isfinite(damping) || !std::isfinite(initialVelocity) ||
230
+ mass <= 0 || stiffness <= 0 || damping < 0) {
231
+ return {1, 0};
232
+ }
233
+
234
+ const double alpha = damping / (2 * mass);
235
+ const double omegaSquared = stiffness / mass;
236
+ const double discriminant = alpha * alpha - omegaSquared;
237
+ const double criticalScale = std::max(1.0, omegaSquared);
238
+ if (std::abs(discriminant) <= 1e-12 * criticalScale) {
239
+ const double coefficient = alpha - initialVelocity;
240
+ const double decay = std::exp(-alpha * elapsedS);
241
+ const double remaining = decay * (1 + coefficient * elapsedS);
242
+ const double velocity = decay *
243
+ (coefficient - alpha * (1 + coefficient * elapsedS));
244
+ return {remaining, velocity};
245
+ }
246
+ if (discriminant < 0) {
247
+ const double omega = std::sqrt(-discriminant);
248
+ const double coefficient = (alpha - initialVelocity) / omega;
249
+ const double angle = omega * elapsedS;
250
+ const double cosine = std::cos(angle);
251
+ const double sine = std::sin(angle);
252
+ const double basis = cosine + coefficient * sine;
253
+ const double basisVelocity =
254
+ -omega * sine + coefficient * omega * cosine;
255
+ const double decay = std::exp(-alpha * elapsedS);
256
+ return {decay * basis, decay * (basisVelocity - alpha * basis)};
257
+ }
258
+
259
+ const double root = std::sqrt(discriminant);
260
+ const double firstRate = -alpha + root;
261
+ const double secondRate = -alpha - root;
262
+ const double firstCoefficient =
263
+ (-initialVelocity - secondRate) / (firstRate - secondRate);
264
+ const double secondCoefficient = 1 - firstCoefficient;
265
+ const double first = firstCoefficient * std::exp(firstRate * elapsedS);
266
+ const double second = secondCoefficient * std::exp(secondRate * elapsedS);
267
+ return {first + second, firstRate * first + secondRate * second};
268
+ }
269
+
270
+ // CASpringAnimation.initialVelocity is normalized by the current remaining
271
+ // distance. Re-expressing the old physical velocity against the frozen
272
+ // current->target displacement preserves the derivative at a re-latch seam.
273
+ inline double springContinuationVelocity(
274
+ const SpringAnimation &spring,
275
+ double elapsedS) {
276
+ const NormalizedSpringState state = normalizedSpringState(spring, elapsedS);
277
+ if (!std::isfinite(state.remaining) || !std::isfinite(state.velocity) ||
278
+ std::abs(state.remaining) <= 1e-9) {
279
+ return 0;
280
+ }
281
+ const double result = -state.velocity / state.remaining;
282
+ return std::isfinite(result) ? result : 0;
283
+ }
284
+
285
+ // --- Presentation channels ------------------------------------------------
286
+
287
+ constexpr std::size_t kChannelCount = 7;
288
+ using Channels = std::array<double, kChannelCount>;
289
+
290
+ inline Channels toChannels(const Presentation &presentation) {
291
+ return {presentation.clip.x,
292
+ presentation.clip.y,
293
+ presentation.clip.width,
294
+ presentation.clip.height,
295
+ presentation.clip.radius,
296
+ presentation.contentTranslateX,
297
+ presentation.contentTranslateY};
298
+ }
299
+
300
+ inline Presentation fromChannels(const Channels &channels) {
301
+ return Presentation{
302
+ {channels[0], channels[1], channels[2], channels[3], channels[4]},
303
+ channels[5],
304
+ channels[6]};
305
+ }
306
+
307
+ inline Presentation interpolate(
308
+ const Presentation &from,
309
+ const Presentation &to,
310
+ double progress) {
311
+ const auto mix = [progress](double start, double end) {
312
+ return start + (end - start) * progress;
313
+ };
314
+ return Presentation{
315
+ {mix(from.clip.x, to.clip.x),
316
+ mix(from.clip.y, to.clip.y),
317
+ mix(from.clip.width, to.clip.width),
318
+ mix(from.clip.height, to.clip.height),
319
+ mix(from.clip.radius, to.clip.radius)},
320
+ mix(from.contentTranslateX, to.contentTranslateX),
321
+ mix(from.contentTranslateY, to.contentTranslateY)};
322
+ }
323
+
324
+ // --- Keyframe curve -------------------------------------------------------
325
+
326
+ // Keyframes sampled from a smooth path, evaluated with monotone cubic Hermite
327
+ // interpolation (Fritsch-Carlson).
328
+ //
329
+ // Straight lerping between keyframes is exact at every keyframe and wrong in
330
+ // between in a way that matters more than its position error suggests: the
331
+ // interpolated VELOCITY is a staircase that steps at every keyframe boundary,
332
+ // beside content whose Reanimated curve is continuous. Consumers bake dozens of
333
+ // keyframes precisely because their geometry path is not affine in progress, so
334
+ // reconstructing that path smoothly is the honest reading of their intent.
335
+ //
336
+ // Monotone rather than plain Catmull-Rom because the alternative can overshoot,
337
+ // and these channels do not tolerate it: width, height and radius must never
338
+ // dip below zero or bulge past the values the consumer asked for. The
339
+ // Fritsch-Carlson tangent clamp keeps every segment inside the range of its own
340
+ // endpoints. Two keyframes degenerate to the old straight line (both tangents
341
+ // equal the secant) — equal in real arithmetic, within one ulp (~1e-13 of the
342
+ // travel) in doubles because the Hermite blend orders its operations
343
+ // differently than the lerp it replaced. The jfloat delivery cast erases the
344
+ // difference long before a pixel could see it.
345
+ //
346
+ // PARITY NOTE: iOS builds a CAKeyframeAnimation with `kCAAnimationLinear`
347
+ // (SmoothClipView.mm) and cannot run this evaluator — CoreAnimation interpolates
348
+ // off-thread. The platforms therefore differ mid-segment by the linearization
349
+ // error this removes, sub-pixel at any realistic keyframe density and zero at
350
+ // every keyframe. Closing it needs either `kCAAnimationCubic` (a different
351
+ // spline that CAN overshoot, so no) or resampling the keyframes densely through
352
+ // this curve before handing them to CA.
353
+ class KeyframeCurve {
354
+ public:
355
+ void reset(std::vector<Keyframe> frames) {
356
+ frames_ = std::move(frames);
357
+ cursor_ = 1;
358
+ tangents_.assign(frames_.size(), Channels{});
359
+ const std::size_t count = frames_.size();
360
+ if (count < 2) return;
361
+
362
+ // Secant slope of every segment, per channel. A non-increasing offset pair
363
+ // (the re-latch remap can produce one) contributes no slope rather than
364
+ // dividing by zero; evaluate() short-circuits those segments too.
365
+ std::vector<Channels> secants(count - 1);
366
+ for (std::size_t index = 0; index + 1 < count; index += 1) {
367
+ const double span = frames_[index + 1].offset - frames_[index].offset;
368
+ const Channels lower = toChannels(frames_[index].presentation);
369
+ const Channels upper = toChannels(frames_[index + 1].presentation);
370
+ for (std::size_t channel = 0; channel < kChannelCount; channel += 1) {
371
+ secants[index][channel] =
372
+ span > 0 ? (upper[channel] - lower[channel]) / span : 0.0;
373
+ }
374
+ }
375
+
376
+ // One-sided at the ends, averaged inside.
377
+ for (std::size_t channel = 0; channel < kChannelCount; channel += 1) {
378
+ tangents_[0][channel] = secants[0][channel];
379
+ tangents_[count - 1][channel] = secants[count - 2][channel];
380
+ for (std::size_t index = 1; index + 1 < count; index += 1) {
381
+ tangents_[index][channel] =
382
+ (secants[index - 1][channel] + secants[index][channel]) / 2.0;
383
+ }
384
+ }
385
+
386
+ // Fritsch-Carlson clamp: this is what makes the curve non-overshooting.
387
+ for (std::size_t index = 0; index + 1 < count; index += 1) {
388
+ for (std::size_t channel = 0; channel < kChannelCount; channel += 1) {
389
+ const double secant = secants[index][channel];
390
+ if (secant == 0.0) {
391
+ // Flat segment: pin both ends flat so the curve cannot bulge off a
392
+ // pair of equal keyframes.
393
+ tangents_[index][channel] = 0.0;
394
+ tangents_[index + 1][channel] = 0.0;
395
+ continue;
396
+ }
397
+ double alpha = tangents_[index][channel] / secant;
398
+ double beta = tangents_[index + 1][channel] / secant;
399
+ if (alpha < 0.0) {
400
+ tangents_[index][channel] = 0.0;
401
+ alpha = 0.0;
402
+ }
403
+ if (beta < 0.0) {
404
+ tangents_[index + 1][channel] = 0.0;
405
+ beta = 0.0;
406
+ }
407
+ const double magnitude = alpha * alpha + beta * beta;
408
+ if (magnitude > 9.0) {
409
+ const double scale = 3.0 / std::sqrt(magnitude);
410
+ tangents_[index][channel] = scale * alpha * secant;
411
+ tangents_[index + 1][channel] = scale * beta * secant;
412
+ }
413
+ }
414
+ }
415
+ }
416
+
417
+ const std::vector<Keyframe> &frames() const { return frames_; }
418
+ std::size_t size() const { return frames_.size(); }
419
+ bool usable() const { return frames_.size() >= 2; }
420
+
421
+ Presentation evaluate(double progress) {
422
+ const std::size_t count = frames_.size();
423
+ if (count == 0) return Presentation{{0, 0, 0, 0, 0}, 0, 0};
424
+ if (count == 1) return frames_[0].presentation;
425
+
426
+ // The scan resumes from the previous segment instead of restarting at 1.
427
+ // Progress is monotonic within an animation, so this is O(1) amortized
428
+ // where the old restart-every-frame scan was O(keyframes) per frame; the
429
+ // backward walk keeps it correct if progress ever moves the other way.
430
+ std::size_t upper = std::min(std::max<std::size_t>(cursor_, 1), count - 1);
431
+ while (upper > 1 && progress < frames_[upper - 1].offset) upper -= 1;
432
+ while (upper < count - 1 && progress > frames_[upper].offset) upper += 1;
433
+ cursor_ = upper;
434
+
435
+ const Keyframe &lower = frames_[upper - 1];
436
+ const Keyframe &higher = frames_[upper];
437
+ const double span = higher.offset - lower.offset;
438
+ if (span <= 0) return higher.presentation;
439
+
440
+ const double t = clamp01((progress - lower.offset) / span);
441
+ const double t2 = t * t;
442
+ const double t3 = t2 * t;
443
+ // Cubic Hermite basis.
444
+ const double h00 = 2 * t3 - 3 * t2 + 1;
445
+ const double h10 = t3 - 2 * t2 + t;
446
+ const double h01 = -2 * t3 + 3 * t2;
447
+ const double h11 = t3 - t2;
448
+
449
+ const Channels from = toChannels(lower.presentation);
450
+ const Channels to = toChannels(higher.presentation);
451
+ const Channels &fromSlope = tangents_[upper - 1];
452
+ const Channels &toSlope = tangents_[upper];
453
+ Channels blended{};
454
+ for (std::size_t channel = 0; channel < kChannelCount; channel += 1) {
455
+ blended[channel] = h00 * from[channel] +
456
+ h10 * span * fromSlope[channel] + h01 * to[channel] +
457
+ h11 * span * toSlope[channel];
458
+ }
459
+ return fromChannels(blended);
460
+ }
461
+
462
+ private:
463
+ std::vector<Keyframe> frames_;
464
+ std::vector<Channels> tangents_;
465
+ std::size_t cursor_ = 1;
466
+ };
467
+
468
+ } // namespace smoothclip
@@ -0,0 +1,32 @@
1
+ #pragma once
2
+
3
+ #include <atomic>
4
+ #include <cstdint>
5
+ #include <limits>
6
+
7
+ namespace smoothclip {
8
+
9
+ // Completion delivery is asynchronous with respect to registry teardown. A
10
+ // per-driver counter is therefore not an identity: destroying the last host
11
+ // erases that counter, and an effect replay can recreate the same driver and
12
+ // allocate the same id before the old completion reaches JS. Keep ids unique
13
+ // for the practical lifetime of the process instead. The supported call paths
14
+ // are main-thread confined, but the atomic keeps this header safe if a future
15
+ // rejection path allocates before reaching that guard.
16
+ inline int32_t allocateAnimationId() {
17
+ static std::atomic<int32_t> next{0};
18
+ int32_t observed = next.load(std::memory_order_relaxed);
19
+ while (true) {
20
+ const int32_t desired =
21
+ observed == std::numeric_limits<int32_t>::max() ? 1 : observed + 1;
22
+ if (next.compare_exchange_weak(
23
+ observed,
24
+ desired,
25
+ std::memory_order_relaxed,
26
+ std::memory_order_relaxed)) {
27
+ return desired;
28
+ }
29
+ }
30
+ }
31
+
32
+ } // namespace smoothclip
@@ -2,6 +2,7 @@
2
2
 
3
3
  #include <cstdint>
4
4
  #include <functional>
5
+ #include <limits>
5
6
  #include <vector>
6
7
 
7
8
  namespace smoothclip {
@@ -46,6 +47,14 @@ struct SpringAnimation {
46
47
  struct AnimationStart {
47
48
  bool hasInteractiveStart;
48
49
  Presentation interactiveStart;
50
+ // Reanimated-rule start stamp, captured on the UI runtime at animateTo time
51
+ // as `__frameTimestamp || _getAnimationTimestamp()` and converted to
52
+ // CLOCK_MONOTONIC seconds. NaN means no stamp was captured (older callers,
53
+ // tests, platforms that ignore it): the integrator then falls back to its
54
+ // own clock plus the min() frame-clock anchor. iOS constructs this struct
55
+ // without the member and inherits the NaN default — CoreAnimation anchors
56
+ // its own animations, so the hint is Android-only by design.
57
+ double startedAtHintS = std::numeric_limits<double>::quiet_NaN();
49
58
  };
50
59
 
51
60
  struct CancelResult {
@@ -64,7 +73,8 @@ void clearCompletionCallback(const void *owner);
64
73
  void setPresentation(
65
74
  uint64_t driverId,
66
75
  Presentation presentation,
67
- bool takeOwnership);
76
+ bool takeOwnership,
77
+ bool overridePendingAnimation = false);
68
78
  Presentation beginInteraction(uint64_t driverId);
69
79
  int32_t animateTiming(
70
80
  uint64_t driverId,
@@ -27,9 +27,16 @@ struct VelocitySampleHistory {
27
27
  // batch spacing and below the 8.33 ms 120 Hz frame, so legitimate
28
28
  // consecutive-frame samples never coalesce.
29
29
  inline constexpr double kSampleCoalesceWindowS = 0.004;
30
- // A latest sample older than this no longer describes the finger; 'inherit'
31
- // yields zero instead of replaying stale motion.
30
+ // A latest sample this old no longer describes the finger at all; 'inherit'
31
+ // yields zero rather than replaying stale motion. It is the far end of the
32
+ // decay below, not a cliff.
32
33
  inline constexpr double kVelocityStalenessS = 0.1;
34
+ // Below this age the sample is still "now": an ordinary fling calls animateTo
35
+ // in the same input batch as, or one frame after, the last drag write, and
36
+ // must inherit its motion undiminished. Decaying from zero age instead would
37
+ // shave ~17% off every normal handoff at 60 Hz — a regression in the case that
38
+ // matters most, in exchange for fixing the case that barely happens.
39
+ inline constexpr double kVelocityFullCreditS = 1.0 / 60.0;
33
40
 
34
41
  inline void recordVelocitySample(
35
42
  VelocitySampleHistory &history,
@@ -60,22 +67,31 @@ inline void recordVelocitySample(
60
67
  history.latestTimeS = nowS;
61
68
  }
62
69
 
63
- // beginInteraction semantics: history restarts from a single fresh sample;
64
- // one more record is needed before 'inherit' can measure motion.
65
- inline void resetVelocitySamples(
66
- VelocitySampleHistory &history,
67
- const std::array<double, 7> &channels,
68
- double nowS) {
69
- history.hasPrevious = false;
70
- history.hasLatest = true;
71
- history.latest = channels;
72
- history.latestTimeS = nowS;
73
- }
70
+ // beginInteraction on both platforms records the frozen presentation as a
71
+ // plain sample: a grab mid-animation pairs the frozen value with the last
72
+ // real sample, so an instant refling inherits bounded recent motion instead
73
+ // of launching dead, and a grab at an unchanged value dedupes to a no-op
74
+ // whose staleness keeps aging. There is deliberately no reset primitive —
75
+ // the staleness decay below is the forgetting mechanism, and it forgets
76
+ // gradually rather than all at once.
74
77
 
75
78
  inline void clearVelocitySamples(VelocitySampleHistory &history) {
76
79
  history = VelocitySampleHistory{};
77
80
  }
78
81
 
82
+ // Age of the newest sample expressed as a credit multiplier: full inside the
83
+ // grace window, decaying linearly to zero at the staleness bound. The old
84
+ // binary cutoff meant a release after a 99 ms still-hold launched at the full
85
+ // drag velocity while 101 ms launched dead — a step the hand can feel, and one
86
+ // RNGH's own windowed event.velocity* does not have. Continuous and monotone
87
+ // in holdS, so no hold duration produces a discontinuity in launch speed.
88
+ inline double velocityStalenessCredit(double holdS) {
89
+ if (holdS <= kVelocityFullCreditS) return 1;
90
+ if (holdS >= kVelocityStalenessS) return 0;
91
+ return (kVelocityStalenessS - holdS) /
92
+ (kVelocityStalenessS - kVelocityFullCreditS);
93
+ }
94
+
79
95
  // Normalized velocity of the recorded motion projected onto the
80
96
  // latest→target trajectory, in units of remaining-distance per second
81
97
  // (1 covers the remaining distance in one second) — the CASpringAnimation
@@ -87,7 +103,8 @@ inline double inheritedVelocity(
87
103
  double nowS) {
88
104
  if (!history.hasPrevious || !history.hasLatest) return 0;
89
105
  const double elapsed = history.latestTimeS - history.previousTimeS;
90
- if (elapsed <= 0 || nowS - history.latestTimeS > kVelocityStalenessS) {
106
+ const double credit = velocityStalenessCredit(nowS - history.latestTimeS);
107
+ if (elapsed <= 0 || credit <= 0) {
91
108
  return 0;
92
109
  }
93
110
  double numerator = 0;
@@ -99,7 +116,7 @@ inline double inheritedVelocity(
99
116
  denominator += destinationDelta * destinationDelta;
100
117
  }
101
118
  if (denominator <= 1e-12) return 0;
102
- const double result = numerator / elapsed / denominator;
119
+ const double result = numerator / elapsed / denominator * credit;
103
120
  return std::isfinite(result) ? result : 0;
104
121
  }
105
122