egaki 0.6.0 → 0.7.0

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 (271) hide show
  1. package/CHANGELOG.md +140 -9
  2. package/dist/cli/cache-utils.d.ts +33 -0
  3. package/dist/cli/cache-utils.d.ts.map +1 -0
  4. package/dist/cli/cache-utils.js +157 -0
  5. package/dist/cli/cached-generate.d.ts +105 -0
  6. package/dist/cli/cached-generate.d.ts.map +1 -0
  7. package/dist/cli/cached-generate.js +208 -0
  8. package/dist/cli/cached-generate.test.d.ts +11 -0
  9. package/dist/cli/cached-generate.test.d.ts.map +1 -0
  10. package/dist/cli/cached-generate.test.js +431 -0
  11. package/dist/cli/cartesia-provider.d.ts +16 -0
  12. package/dist/cli/cartesia-provider.d.ts.map +1 -0
  13. package/dist/cli/cartesia-provider.js +184 -0
  14. package/dist/cli/chatgpt-auth.d.ts.map +1 -0
  15. package/dist/cli/cli.d.ts +4 -0
  16. package/dist/cli/cli.d.ts.map +1 -0
  17. package/dist/{cli.js → cli/cli.js} +626 -600
  18. package/dist/cli/clone-generate.d.ts +26 -0
  19. package/dist/cli/clone-generate.d.ts.map +1 -0
  20. package/dist/cli/clone-generate.js +148 -0
  21. package/dist/cli/credentials.d.ts.map +1 -0
  22. package/dist/{credentials.js → cli/credentials.js} +20 -0
  23. package/dist/cli/elevenlabs-speech-provider.d.ts +26 -0
  24. package/dist/cli/elevenlabs-speech-provider.d.ts.map +1 -0
  25. package/dist/cli/elevenlabs-speech-provider.js +199 -0
  26. package/dist/cli/generate.d.ts +140 -0
  27. package/dist/cli/generate.d.ts.map +1 -0
  28. package/dist/cli/generate.js +604 -0
  29. package/dist/cli/generate.test.d.ts +2 -0
  30. package/dist/cli/generate.test.d.ts.map +1 -0
  31. package/dist/cli/generate.test.js +131 -0
  32. package/dist/cli/login.d.ts.map +1 -0
  33. package/dist/cli/main.d.ts +3 -0
  34. package/dist/cli/main.d.ts.map +1 -0
  35. package/dist/cli/main.js +7 -0
  36. package/dist/{model-catalog.d.ts → cli/model-catalog.d.ts} +6 -0
  37. package/dist/cli/model-catalog.d.ts.map +1 -0
  38. package/dist/{model-catalog.js → cli/model-catalog.js} +714 -4
  39. package/dist/{models.d.ts → cli/models.d.ts} +12 -5
  40. package/dist/cli/models.d.ts.map +1 -0
  41. package/dist/{models.js → cli/models.js} +58 -57
  42. package/dist/cli/open-browser.d.ts.map +1 -0
  43. package/dist/cli/openai-speech-provider.d.ts +3 -0
  44. package/dist/cli/openai-speech-provider.d.ts.map +1 -0
  45. package/dist/cli/openai-speech-provider.js +28 -0
  46. package/dist/cli/separation-generate.d.ts +34 -0
  47. package/dist/cli/separation-generate.d.ts.map +1 -0
  48. package/dist/cli/separation-generate.js +117 -0
  49. package/dist/cli/speech-catalog.d.ts +35 -0
  50. package/dist/cli/speech-catalog.d.ts.map +1 -0
  51. package/dist/cli/speech-catalog.js +195 -0
  52. package/dist/cli/speech-generate.d.ts +86 -0
  53. package/dist/cli/speech-generate.d.ts.map +1 -0
  54. package/dist/cli/speech-generate.js +156 -0
  55. package/dist/cli/speech-models.d.ts +8 -0
  56. package/dist/cli/speech-models.d.ts.map +1 -0
  57. package/dist/cli/speech-models.js +109 -0
  58. package/dist/cli/speech-provider.test.d.ts +2 -0
  59. package/dist/cli/speech-provider.test.d.ts.map +1 -0
  60. package/dist/cli/speech-provider.test.js +197 -0
  61. package/dist/cli/subscription.d.ts.map +1 -0
  62. package/dist/cli/transcription-catalog.d.ts +35 -0
  63. package/dist/cli/transcription-catalog.d.ts.map +1 -0
  64. package/dist/cli/transcription-catalog.js +192 -0
  65. package/dist/cli/transcription-generate.d.ts +62 -0
  66. package/dist/cli/transcription-generate.d.ts.map +1 -0
  67. package/dist/cli/transcription-generate.js +144 -0
  68. package/dist/cli/transcription-models.d.ts +9 -0
  69. package/dist/cli/transcription-models.d.ts.map +1 -0
  70. package/dist/cli/transcription-models.js +100 -0
  71. package/dist/cli/transcription-providers.d.ts +22 -0
  72. package/dist/cli/transcription-providers.d.ts.map +1 -0
  73. package/dist/cli/transcription-providers.js +290 -0
  74. package/dist/cli/video-model-catalog.d.ts.map +1 -0
  75. package/dist/cli/xai-auth.d.ts.map +1 -0
  76. package/dist/{xai-auth.js → cli/xai-auth.js} +1 -1
  77. package/dist/vite/angled-screen.d.ts +57 -0
  78. package/dist/vite/angled-screen.d.ts.map +1 -0
  79. package/dist/vite/angled-screen.js +115 -0
  80. package/dist/vite/app.d.ts +51 -0
  81. package/dist/vite/app.d.ts.map +1 -0
  82. package/dist/vite/app.js +150 -0
  83. package/dist/vite/bands-shader.d.ts +47 -0
  84. package/dist/vite/bands-shader.d.ts.map +1 -0
  85. package/dist/vite/bands-shader.js +267 -0
  86. package/dist/vite/code-block.d.ts +107 -0
  87. package/dist/vite/code-block.d.ts.map +1 -0
  88. package/dist/vite/code-block.js +641 -0
  89. package/dist/vite/components.d.ts +36 -0
  90. package/dist/vite/components.d.ts.map +1 -0
  91. package/dist/vite/components.js +113 -0
  92. package/dist/vite/dispersion-rings-shader.d.ts +44 -0
  93. package/dist/vite/dispersion-rings-shader.d.ts.map +1 -0
  94. package/dist/vite/dispersion-rings-shader.js +282 -0
  95. package/dist/vite/easing-curves.d.ts +129 -0
  96. package/dist/vite/easing-curves.d.ts.map +1 -0
  97. package/dist/vite/easing-curves.js +584 -0
  98. package/dist/vite/easing-curves.test.d.ts +2 -0
  99. package/dist/vite/easing-curves.test.d.ts.map +1 -0
  100. package/dist/vite/easing-curves.test.js +200 -0
  101. package/dist/vite/generated-media-client.d.ts +35 -0
  102. package/dist/vite/generated-media-client.d.ts.map +1 -0
  103. package/dist/vite/generated-media-client.js +76 -0
  104. package/dist/vite/keyframes.d.ts +129 -0
  105. package/dist/vite/keyframes.d.ts.map +1 -0
  106. package/dist/vite/keyframes.js +194 -0
  107. package/dist/vite/layout-editor.d.ts +45 -0
  108. package/dist/vite/layout-editor.d.ts.map +1 -0
  109. package/dist/vite/layout-editor.js +587 -0
  110. package/dist/vite/layout-transition.d.ts +136 -0
  111. package/dist/vite/layout-transition.d.ts.map +1 -0
  112. package/dist/vite/layout-transition.js +581 -0
  113. package/dist/vite/liquid-gradient-shader.d.ts +62 -0
  114. package/dist/vite/liquid-gradient-shader.d.ts.map +1 -0
  115. package/dist/vite/liquid-gradient-shader.js +413 -0
  116. package/dist/vite/mdx-client.d.ts +15 -0
  117. package/dist/vite/mdx-client.d.ts.map +1 -0
  118. package/dist/vite/mdx-client.js +265 -0
  119. package/dist/vite/mdx-parse.d.ts +102 -0
  120. package/dist/vite/mdx-parse.d.ts.map +1 -0
  121. package/dist/vite/mdx-parse.js +251 -0
  122. package/dist/vite/mdx-provided-components.d.ts +18 -0
  123. package/dist/vite/mdx-provided-components.d.ts.map +1 -0
  124. package/dist/vite/mdx-provided-components.js +14 -0
  125. package/dist/vite/mdx-video.d.ts +327 -0
  126. package/dist/vite/mdx-video.d.ts.map +1 -0
  127. package/dist/vite/mdx-video.js +596 -0
  128. package/dist/vite/mdx-video.test.d.ts +9 -0
  129. package/dist/vite/mdx-video.test.d.ts.map +1 -0
  130. package/dist/vite/mdx-video.test.js +1945 -0
  131. package/dist/vite/media-components.d.ts +45 -0
  132. package/dist/vite/media-components.d.ts.map +1 -0
  133. package/dist/vite/media-components.js +355 -0
  134. package/dist/vite/media-duration-store.d.ts +66 -0
  135. package/dist/vite/media-duration-store.d.ts.map +1 -0
  136. package/dist/vite/media-duration-store.js +204 -0
  137. package/dist/vite/player-page.d.ts +35 -0
  138. package/dist/vite/player-page.d.ts.map +1 -0
  139. package/dist/vite/player-page.js +720 -0
  140. package/dist/vite/render-client.d.ts +18 -0
  141. package/dist/vite/render-client.d.ts.map +1 -0
  142. package/dist/vite/render-client.js +67 -0
  143. package/dist/vite/sdk.d.ts +221 -0
  144. package/dist/vite/sdk.d.ts.map +1 -0
  145. package/dist/vite/sdk.js +355 -0
  146. package/dist/vite/server-components.d.ts +55 -0
  147. package/dist/vite/server-components.d.ts.map +1 -0
  148. package/dist/vite/server-components.js +156 -0
  149. package/dist/vite/server-mdx.d.ts +74 -0
  150. package/dist/vite/server-mdx.d.ts.map +1 -0
  151. package/dist/vite/server-mdx.js +259 -0
  152. package/dist/vite/shader-renderer.d.ts +48 -0
  153. package/dist/vite/shader-renderer.d.ts.map +1 -0
  154. package/dist/vite/shader-renderer.js +411 -0
  155. package/dist/vite/store-hooks.d.ts +12 -0
  156. package/dist/vite/store-hooks.d.ts.map +1 -0
  157. package/dist/vite/store-hooks.js +93 -0
  158. package/dist/vite/store.d.ts +75 -0
  159. package/dist/vite/store.d.ts.map +1 -0
  160. package/dist/vite/store.js +52 -0
  161. package/dist/vite/store.test.d.ts +6 -0
  162. package/dist/vite/store.test.d.ts.map +1 -0
  163. package/dist/vite/store.test.js +172 -0
  164. package/dist/vite/tweakpane-hook.d.ts +94 -0
  165. package/dist/vite/tweakpane-hook.d.ts.map +1 -0
  166. package/dist/vite/tweakpane-hook.js +450 -0
  167. package/dist/vite/vite-plugin.d.ts +28 -0
  168. package/dist/vite/vite-plugin.d.ts.map +1 -0
  169. package/dist/vite/vite-plugin.js +538 -0
  170. package/dist/vite/wave-gradient-shader.d.ts +41 -0
  171. package/dist/vite/wave-gradient-shader.d.ts.map +1 -0
  172. package/dist/vite/wave-gradient-shader.js +207 -0
  173. package/package.json +101 -24
  174. package/skills/egaki/SKILL.md +162 -1
  175. package/src/cli/cache-utils.ts +169 -0
  176. package/src/cli/cached-generate.test.ts +531 -0
  177. package/src/cli/cached-generate.ts +348 -0
  178. package/src/cli/cartesia-provider.ts +249 -0
  179. package/src/{cli.ts → cli/cli.ts} +836 -884
  180. package/src/cli/clone-generate.ts +202 -0
  181. package/src/{credentials.ts → cli/credentials.ts} +20 -0
  182. package/src/cli/elevenlabs-speech-provider.ts +243 -0
  183. package/src/cli/generate.test.ts +166 -0
  184. package/src/cli/generate.ts +912 -0
  185. package/src/cli/main.ts +8 -0
  186. package/src/{model-catalog.ts → cli/model-catalog.ts} +787 -58
  187. package/src/{models.ts → cli/models.ts} +65 -86
  188. package/src/cli/openai-speech-provider.ts +34 -0
  189. package/src/cli/separation-generate.ts +187 -0
  190. package/src/cli/speech-catalog.ts +253 -0
  191. package/src/cli/speech-generate.ts +275 -0
  192. package/src/cli/speech-models.ts +146 -0
  193. package/src/cli/speech-provider.test.ts +226 -0
  194. package/src/cli/transcription-catalog.ts +250 -0
  195. package/src/cli/transcription-generate.ts +214 -0
  196. package/src/cli/transcription-models.ts +136 -0
  197. package/src/cli/transcription-providers.ts +427 -0
  198. package/src/{xai-auth.ts → cli/xai-auth.ts} +1 -1
  199. package/src/vite/angled-screen.tsx +214 -0
  200. package/src/vite/app.tsx +197 -0
  201. package/src/vite/bands-shader.tsx +319 -0
  202. package/src/vite/code-block.tsx +939 -0
  203. package/src/vite/components.tsx +278 -0
  204. package/src/vite/dispersion-rings-shader.tsx +331 -0
  205. package/src/vite/easing-curves.test.ts +232 -0
  206. package/src/vite/easing-curves.ts +743 -0
  207. package/src/vite/generated-media-client.tsx +121 -0
  208. package/src/vite/keyframes.tsx +347 -0
  209. package/src/vite/layout-editor.tsx +709 -0
  210. package/src/vite/layout-transition.tsx +706 -0
  211. package/src/vite/liquid-gradient-shader.tsx +474 -0
  212. package/src/vite/mdx-client.tsx +420 -0
  213. package/src/vite/mdx-parse.ts +343 -0
  214. package/src/vite/mdx-provided-components.ts +19 -0
  215. package/src/vite/mdx-video.test.tsx +2219 -0
  216. package/src/vite/mdx-video.tsx +976 -0
  217. package/src/vite/media-components.tsx +473 -0
  218. package/src/vite/media-duration-store.ts +225 -0
  219. package/src/vite/motion-timing.ts +194 -0
  220. package/src/vite/player-page.tsx +1107 -0
  221. package/src/vite/render-client.ts +84 -0
  222. package/src/vite/sdk.ts +598 -0
  223. package/src/vite/server-components.tsx +241 -0
  224. package/src/vite/server-mdx.ts +285 -0
  225. package/src/vite/shader-renderer.tsx +535 -0
  226. package/src/vite/store-hooks.ts +117 -0
  227. package/src/vite/store.test.ts +202 -0
  228. package/src/vite/store.ts +115 -0
  229. package/src/vite/styles.css +21 -0
  230. package/src/vite/tweakpane-hook.tsx +593 -0
  231. package/src/vite/virtual-modules.d.ts +30 -0
  232. package/src/vite/vite-plugin.ts +584 -0
  233. package/src/vite/wave-gradient-shader.tsx +253 -0
  234. package/README.md +0 -370
  235. package/dist/antigravity-auth.d.ts +0 -15
  236. package/dist/antigravity-auth.d.ts.map +0 -1
  237. package/dist/antigravity-auth.js +0 -544
  238. package/dist/antigravity-image.d.ts +0 -11
  239. package/dist/antigravity-image.d.ts.map +0 -1
  240. package/dist/antigravity-image.js +0 -285
  241. package/dist/chatgpt-auth.d.ts.map +0 -1
  242. package/dist/cli.d.ts +0 -3
  243. package/dist/cli.d.ts.map +0 -1
  244. package/dist/credentials.d.ts.map +0 -1
  245. package/dist/gateway-model-candidates.generated.d.ts +0 -11
  246. package/dist/gateway-model-candidates.generated.d.ts.map +0 -1
  247. package/dist/gateway-model-candidates.generated.js +0 -64
  248. package/dist/login.d.ts.map +0 -1
  249. package/dist/model-catalog.d.ts.map +0 -1
  250. package/dist/models.d.ts.map +0 -1
  251. package/dist/open-browser.d.ts.map +0 -1
  252. package/dist/subscription.d.ts.map +0 -1
  253. package/dist/video-model-catalog.d.ts.map +0 -1
  254. package/dist/xai-auth.d.ts.map +0 -1
  255. /package/dist/{chatgpt-auth.d.ts → cli/chatgpt-auth.d.ts} +0 -0
  256. /package/dist/{chatgpt-auth.js → cli/chatgpt-auth.js} +0 -0
  257. /package/dist/{credentials.d.ts → cli/credentials.d.ts} +0 -0
  258. /package/dist/{login.d.ts → cli/login.d.ts} +0 -0
  259. /package/dist/{login.js → cli/login.js} +0 -0
  260. /package/dist/{open-browser.d.ts → cli/open-browser.d.ts} +0 -0
  261. /package/dist/{open-browser.js → cli/open-browser.js} +0 -0
  262. /package/dist/{subscription.d.ts → cli/subscription.d.ts} +0 -0
  263. /package/dist/{subscription.js → cli/subscription.js} +0 -0
  264. /package/dist/{video-model-catalog.d.ts → cli/video-model-catalog.d.ts} +0 -0
  265. /package/dist/{video-model-catalog.js → cli/video-model-catalog.js} +0 -0
  266. /package/dist/{xai-auth.d.ts → cli/xai-auth.d.ts} +0 -0
  267. /package/src/{chatgpt-auth.ts → cli/chatgpt-auth.ts} +0 -0
  268. /package/src/{login.ts → cli/login.ts} +0 -0
  269. /package/src/{open-browser.ts → cli/open-browser.ts} +0 -0
  270. /package/src/{subscription.ts → cli/subscription.ts} +0 -0
  271. /package/src/{video-model-catalog.ts → cli/video-model-catalog.ts} +0 -0
@@ -0,0 +1,706 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * LayoutTransition — FLIP-based layout animation across section boundaries.
5
+ *
6
+ * <LayoutTransition id="x"> wraps an element that should animate from its
7
+ * position in the previous section to its position in the current section.
8
+ * When two consecutive sections both contain <LayoutTransition id="x">,
9
+ * the element in the current section starts at the previous section's
10
+ * position and springs to its natural position.
11
+ *
12
+ * Architecture (no temporal state — seek-safe by design):
13
+ * - The previous section is re-rendered in a hidden "ghost" container,
14
+ * pinned at its last frame via Remotion <Freeze>. This happens in
15
+ * SectionWithLayoutTransition (player-page.tsx).
16
+ * - LayoutContainerContext tells each LayoutTransition whether it lives
17
+ * in the ghost or the visible container.
18
+ * - Entries register into LayoutRegistryContext in useLayoutEffect.
19
+ * React runs effects in tree order, so all LayoutTransition effects
20
+ * (descendants of containers rendered before the layer) complete before
21
+ * LayoutAnimationLayer's effect runs.
22
+ * - LayoutAnimationLayer measures ghost vs visible rects every frame and
23
+ * writes interpolated FLIP transforms directly to the visible wrappers.
24
+ *
25
+ * Hard-won correctness details (do not "simplify" these away):
26
+ * - getBoundingClientRect() INCLUDES CSS transforms. Transforms must be
27
+ * reset before measuring, or each frame compounds the previous frame's
28
+ * FLIP offset and the element drifts.
29
+ * - The Remotion Player scales the composition to fit the viewport.
30
+ * Client-px deltas must be divided by that scale before being used as
31
+ * transform values in composition space. The layer measures the scale
32
+ * from its own composition-sized AbsoluteFill.
33
+ * - The web-renderer does not support z-index; layer order is DOM order.
34
+ * The ghost renders BEFORE the visible content so the visible section
35
+ * paints over it even if a renderer ignores visibility:hidden.
36
+ */
37
+
38
+ import {
39
+ createContext,
40
+ useContext,
41
+ useLayoutEffect,
42
+ useRef,
43
+ useState,
44
+ type ReactNode,
45
+ type RefObject,
46
+ } from 'react'
47
+ import {
48
+ AbsoluteFill,
49
+ interpolate,
50
+ useCurrentFrame,
51
+ useVideoConfig,
52
+ } from 'remotion'
53
+ import { dspring } from './mdx-video.tsx'
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Types and context
57
+ // ---------------------------------------------------------------------------
58
+
59
+ type LayoutContainerKind = 'ghost' | 'visible'
60
+
61
+ /** Which axes to animate: position + size, position only, or size only. */
62
+ export type LayoutTransitionMode = 'both' | 'position' | 'size'
63
+
64
+ export interface LayoutEntry {
65
+ id: string
66
+ container: LayoutContainerKind
67
+ ref: RefObject<HTMLDivElement | null>
68
+ /** Transition duration in frames */
69
+ durationInFrames: number
70
+ /** Spring bounce, 0 = no overshoot */
71
+ bounce: number
72
+ /** Custom easing function. When set, overrides the spring. */
73
+ easing: ((t: number) => number) | null
74
+ /** Which axes to animate. Default 'both'. */
75
+ mode: LayoutTransitionMode
76
+ /** Intra-scene: frame at which this instance becomes visible (inclusive). */
77
+ showFrom?: number
78
+ /** Intra-scene: frame at which this instance stops being visible (exclusive). */
79
+ showUpTo?: number
80
+ }
81
+
82
+ export interface LayoutRegistry {
83
+ entries: Set<LayoutEntry>
84
+ /** Returns an unregister function */
85
+ register(entry: LayoutEntry): () => void
86
+ }
87
+
88
+ const LayoutRegistryContext = createContext<LayoutRegistry | null>(null)
89
+ export const LayoutContainerContext = createContext<LayoutContainerKind>('visible')
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Style interpolation helpers for LayoutTransition FLIP animations.
93
+ // Mirrors Framer Motion's mix-values.ts, scale-border-radius.ts, and
94
+ // scale-box-shadow.ts but adapted for our ghost/visible FLIP approach.
95
+ // ---------------------------------------------------------------------------
96
+
97
+ type RGBA = [number, number, number, number]
98
+
99
+ /** Parse rgb()/rgba()/hex color string to [r, g, b, a] tuple. */
100
+ function parseColor(str: string): RGBA | null {
101
+ if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return null
102
+ // rgb(r, g, b) or rgba(r, g, b, a)
103
+ const m = str.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/)
104
+ if (m && m[1] && m[2] && m[3]) return [+m[1], +m[2], +m[3], m[4] !== undefined ? +m[4] : 1]
105
+ return null
106
+ }
107
+
108
+ /** Mix two RGBA colors by progress (0–1). */
109
+ function mixColor(from: RGBA, to: RGBA, progress: number): string {
110
+ const r = Math.round(from[0] + (to[0] - from[0]) * progress)
111
+ const g = Math.round(from[1] + (to[1] - from[1]) * progress)
112
+ const b = Math.round(from[2] + (to[2] - from[2]) * progress)
113
+ const a = from[3] + (to[3] - from[3]) * progress
114
+ return a >= 1 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${a.toFixed(3)})`
115
+ }
116
+
117
+ /** Parse a borderRadius value (px or %) to a number. Returns 0 for unparseable. */
118
+ function parseBorderRadius(val: string): number {
119
+ return parseFloat(val) || 0
120
+ }
121
+
122
+ /** Linearly interpolate two numbers. */
123
+ function mixNumber(from: number, to: number, progress: number): number {
124
+ return from + (to - from) * progress
125
+ }
126
+
127
+ /** CSS border-radius corner property names in kebab-case for
128
+ * getPropertyValue() reads and setProperty() writes. */
129
+ const BORDER_RADIUS_PROPS = [
130
+ 'border-top-left-radius',
131
+ 'border-top-right-radius',
132
+ 'border-bottom-left-radius',
133
+ 'border-bottom-right-radius',
134
+ ] as const
135
+
136
+ /**
137
+ * Parse a simple box-shadow string into its numeric components.
138
+ * Returns null for 'none', multiple shadows, or unparseable values.
139
+ *
140
+ * getComputedStyle returns shadows like:
141
+ * "rgba(0, 0, 0, 0.2) 0px 4px 12px 0px"
142
+ * "0px 4px 12px 0px rgba(0, 0, 0, 0.2)"
143
+ *
144
+ * Multiple shadows are separated by commas OUTSIDE parentheses.
145
+ * We must not reject commas inside rgba().
146
+ */
147
+ function parseBoxShadow(val: string): { offsetX: number; offsetY: number; blur: number; spread: number; color: string } | null {
148
+ if (!val || val === 'none') return null
149
+ // Check for multiple shadows: commas outside parentheses.
150
+ // Walk the string tracking paren depth; if we find a comma at depth 0
151
+ // there are multiple shadows, which we don't support.
152
+ let depth = 0
153
+ for (let i = 0; i < val.length; i++) {
154
+ const ch = val[i]
155
+ if (ch === '(') depth++
156
+ else if (ch === ')') depth--
157
+ else if (ch === ',' && depth === 0) return null
158
+ }
159
+ // Extract the color function or keyword
160
+ const colorMatch = val.match(/(rgba?\([^)]+\)|#[0-9a-fA-F]+)/)
161
+ if (!colorMatch || !colorMatch[1]) return null
162
+ const color = colorMatch[1]
163
+ const withoutColor = val.replace(color, '').trim()
164
+ const nums = withoutColor.split(/\s+/).map(parseFloat).filter((n) => !isNaN(n))
165
+ if (nums.length < 2 || nums[0] === undefined || nums[1] === undefined) return null
166
+ return {
167
+ offsetX: nums[0],
168
+ offsetY: nums[1],
169
+ blur: nums[2] ?? 0,
170
+ spread: nums[3] ?? 0,
171
+ color,
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Apply FLIP transform and interpolated visual styles to an element.
177
+ *
178
+ * Reads computed styles from the source/target's first child elements (where
179
+ * user visual styles like borderRadius and backgroundColor live) and applies
180
+ * interpolated values to the wrapper element during the transition.
181
+ *
182
+ * Scale correction for borderRadius and boxShadow follows Framer Motion's
183
+ * approach: borderRadius is counter-scaled by the projection delta so corners
184
+ * don't distort; boxShadow offsets and blur are divided by the scale so
185
+ * shadows don't squish.
186
+ */
187
+ function applyFlip({
188
+ el,
189
+ sourceEl,
190
+ fromRect,
191
+ toRect,
192
+ progress,
193
+ playerScale,
194
+ entry,
195
+ }: {
196
+ el: HTMLElement
197
+ sourceEl: HTMLElement
198
+ fromRect: DOMRect
199
+ toRect: DOMRect
200
+ progress: number
201
+ playerScale: number
202
+ entry: LayoutEntry
203
+ }) {
204
+ const inv = 1 - progress
205
+ let dx = ((fromRect.x - toRect.x) / playerScale) * inv
206
+ let dy = ((fromRect.y - toRect.y) / playerScale) * inv
207
+ let sx = 1 + (fromRect.width / toRect.width - 1) * inv
208
+ let sy = 1 + (fromRect.height / toRect.height - 1) * inv
209
+
210
+ // Mode: constrain which axes animate
211
+ if (entry.mode === 'position') { sx = 1; sy = 1 }
212
+ if (entry.mode === 'size') { dx = 0; dy = 0 }
213
+
214
+ el.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`
215
+
216
+ // Read computed styles from the visual content elements (first child),
217
+ // since user styles live on children, not the LayoutTransition wrapper.
218
+ // Interpolated visual styles are applied to targetVisual (not the wrapper)
219
+ // so they override the child's own styles during the transition. If we
220
+ // wrote to the wrapper, the child's own background/shadow would paint
221
+ // over our interpolated values.
222
+ const sourceVisual = sourceEl.firstElementChild
223
+ const targetVisual = el.firstElementChild
224
+ if (!(sourceVisual instanceof HTMLElement) || !(targetVisual instanceof HTMLElement)) return
225
+
226
+ const fromStyles = getComputedStyle(sourceVisual)
227
+ const toStyles = getComputedStyle(targetVisual)
228
+
229
+ // --- Border radius interpolation + scale correction ---
230
+ // Interpolate each corner independently, then counter-scale per axis
231
+ // so corners appear visually correct despite the scale transform.
232
+ // Uses elliptical border-radius (xR / sx, yR / sy) for accurate
233
+ // correction under non-uniform scale, unlike simple average scaling.
234
+ // Only processes px values; % units pass through unchanged (mixing
235
+ // incompatible units would produce incorrect results).
236
+ const hasRadiusChange = BORDER_RADIUS_PROPS.some(
237
+ (prop) => fromStyles.getPropertyValue(prop) !== toStyles.getPropertyValue(prop),
238
+ )
239
+ if (hasRadiusChange || sx !== 1 || sy !== 1) {
240
+ let hasNonZeroRadius = false
241
+ for (const prop of BORDER_RADIUS_PROPS) {
242
+ const fromVal = fromStyles.getPropertyValue(prop)
243
+ const toVal = toStyles.getPropertyValue(prop)
244
+ const fromIsPercent = fromVal.includes('%')
245
+ const toIsPercent = toVal.includes('%')
246
+ // Only mix compatible units. If either is %, skip interpolation
247
+ // and use the target value directly (same as Motion's fallback).
248
+ if (fromIsPercent || toIsPercent) {
249
+ patchStyle(el, targetVisual, prop, toVal)
250
+ hasNonZeroRadius = hasNonZeroRadius || parseBorderRadius(toVal) > 0
251
+ continue
252
+ }
253
+ const fromR = parseBorderRadius(fromVal)
254
+ const toR = parseBorderRadius(toVal)
255
+ const mixed = mixNumber(fromR, toR, progress)
256
+ // Elliptical counter-scale: divide by each axis's scale separately
257
+ // so non-uniform scales (e.g. scale(2, 1)) correct each direction.
258
+ const correctedX = sx !== 0 ? Math.max(mixed / sx, 0) : mixed
259
+ const correctedY = sy !== 0 ? Math.max(mixed / sy, 0) : mixed
260
+ patchStyle(el, targetVisual, prop, `${correctedX}px ${correctedY}px`)
261
+ hasNonZeroRadius = hasNonZeroRadius || mixed > 0
262
+ }
263
+ // Only set overflow:hidden when there's a non-zero radius that needs
264
+ // child clipping. Setting it unconditionally can crop content.
265
+ if (hasNonZeroRadius) patchStyle(el, targetVisual, 'overflow', 'hidden')
266
+ }
267
+
268
+ // --- Background color interpolation ---
269
+ // Motion doesn't interpolate background during layout transitions,
270
+ // but for video this is valuable: hard color cuts between sections
271
+ // look jarring. We interpolate if both sides have a non-transparent bg.
272
+ const fromColor = parseColor(fromStyles.backgroundColor)
273
+ const toColor = parseColor(toStyles.backgroundColor)
274
+ if (fromColor && toColor) {
275
+ patchStyle(el, targetVisual, 'background-color', mixColor(fromColor, toColor, progress))
276
+ }
277
+
278
+ // --- Box shadow scale correction ---
279
+ // When the element is scaled, box-shadow offsets and blur get squished.
280
+ // Counter-scale them by the projection delta, same as Motion's
281
+ // correctBoxShadow. offsetX by sx, offsetY by sy, blur+spread by
282
+ // average scale. Only handles single shadows for simplicity.
283
+ const targetShadow = toStyles.boxShadow
284
+ if (targetShadow && targetShadow !== 'none') {
285
+ const parsed = parseBoxShadow(targetShadow)
286
+ if (parsed) {
287
+ const avgScale = (sx + sy) / 2
288
+ if (avgScale !== 0 && avgScale !== 1) {
289
+ patchStyle(el, targetVisual, 'box-shadow', `${parsed.offsetX / sx}px ${parsed.offsetY / sy}px ${parsed.blur / avgScale}px ${parsed.spread / avgScale}px ${parsed.color}`)
290
+ }
291
+ }
292
+ }
293
+
294
+ // --- Opacity interpolation ---
295
+ // Linearly interpolate opacity when source and target differ.
296
+ // Applied to the visual child (not wrapper) to avoid multiplying
297
+ // with the child's own opacity.
298
+ const toOpacity = parseFloat(toStyles.opacity) || 1
299
+ const fromOpacity = parseFloat(fromStyles.opacity) || 1
300
+ if (fromOpacity !== toOpacity) {
301
+ patchStyle(el, targetVisual, 'opacity', String(mixNumber(fromOpacity, toOpacity, progress)))
302
+ }
303
+ }
304
+
305
+ /** Tracks imperative style overrides applied by applyFlip. Keyed by the
306
+ * patched element (the visual child). Each entry maps CSS property →
307
+ * { applied, previous }. On reset, restores the previous value (what the
308
+ * DOM had before we overwrote it, typically a React inline style) instead
309
+ * of clearing to empty. Only restores if the DOM still has our applied
310
+ * value; if React changed it between frames, we leave it alone.
311
+ *
312
+ * Limitation: if a user component animates the SAME CSS property that
313
+ * applyFlip patches (e.g. backgroundColor via interpolate()) and React's
314
+ * new value happens to serialize identically to our last applied value,
315
+ * reset will incorrectly restore the stale previous. This only matters
316
+ * on the transition end frame (|progress - 1| < 0.001) when applyFlip is
317
+ * skipped. Avoid animating borderRadius, backgroundColor, boxShadow,
318
+ * overflow, or opacity on elements inside LayoutTransition during the
319
+ * transition window. */
320
+ const appliedPatches = new WeakMap<HTMLElement, Map<string, { applied: string; previous: string }>>()
321
+
322
+ /** Maps each LayoutTransition wrapper → set of child elements we patched.
323
+ * This ensures resetFlipStyles restores ALL patched children even if
324
+ * the child order changes between frames (e.g. React inserts a new
325
+ * element before the patched child, making it no longer firstElementChild). */
326
+ const patchedChildrenByWrapper = new WeakMap<HTMLElement, Set<HTMLElement>>()
327
+
328
+ /** Record an imperative style write so resetFlipStyles can undo it safely.
329
+ * @param wrapper — the LayoutTransition wrapper div (for tracking)
330
+ * @param el — the actual visual child element being patched
331
+ * Saves the current DOM value before overwriting, so it can be restored. */
332
+ function patchStyle(wrapper: HTMLElement, el: HTMLElement, prop: string, value: string) {
333
+ let patches = appliedPatches.get(el)
334
+ if (!patches) { patches = new Map(); appliedPatches.set(el, patches) }
335
+ // Only capture previous on first patch per reset cycle; subsequent
336
+ // patches within the same frame update `applied` but keep the original
337
+ // previous so we restore to what React had, not our last intermediate.
338
+ const existing = patches.get(prop)
339
+ const previous = existing ? existing.previous : el.style.getPropertyValue(prop)
340
+ patches.set(prop, { applied: value, previous })
341
+ el.style.setProperty(prop, value)
342
+
343
+ // Track this child under its wrapper so resetFlipStyles can find it
344
+ // even if it's no longer firstElementChild.
345
+ let children = patchedChildrenByWrapper.get(wrapper)
346
+ if (!children) { children = new Set(); patchedChildrenByWrapper.set(wrapper, children) }
347
+ children.add(el)
348
+ }
349
+
350
+ /** Reset all styles applied by applyFlip.
351
+ * Clears transform on the wrapper. For child visual styles, restores
352
+ * each property to its previous value (the React inline style that was
353
+ * there before we overwrote it). Only restores if the DOM still has our
354
+ * applied value; if React changed it between frames, that value is kept.
355
+ * Iterates all tracked patched children, not just firstElementChild,
356
+ * so children that moved position in the DOM are still restored. */
357
+ function resetFlipStyles(el: HTMLElement) {
358
+ el.style.transform = ''
359
+ const children = patchedChildrenByWrapper.get(el)
360
+ if (!children) return
361
+ for (const child of children) {
362
+ const patches = appliedPatches.get(child)
363
+ if (!patches) continue
364
+ for (const [prop, { applied, previous }] of patches) {
365
+ if (child.style.getPropertyValue(prop) === applied) {
366
+ child.style.setProperty(prop, previous)
367
+ }
368
+ }
369
+ appliedPatches.delete(child)
370
+ }
371
+ patchedChildrenByWrapper.delete(el)
372
+ }
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Components
376
+ // ---------------------------------------------------------------------------
377
+
378
+ /**
379
+ * Provides the registry that connects LayoutTransition elements (in both
380
+ * the ghost and visible containers) with the LayoutAnimationLayer.
381
+ * One provider per section — wraps ghost + visible + animation layer.
382
+ */
383
+ export function LayoutTransitionProvider({ children }: { children: ReactNode }) {
384
+ const [registry] = useState<LayoutRegistry>(() => {
385
+ const entries = new Set<LayoutEntry>()
386
+ return {
387
+ entries,
388
+ register(entry: LayoutEntry) {
389
+ entries.add(entry)
390
+ return () => {
391
+ entries.delete(entry)
392
+ }
393
+ },
394
+ }
395
+ })
396
+
397
+ return (
398
+ <LayoutRegistryContext.Provider value={registry}>
399
+ {children}
400
+ </LayoutRegistryContext.Provider>
401
+ )
402
+ }
403
+
404
+ /**
405
+ * Marks its subtree as the hidden ghost copy of the previous section.
406
+ * LayoutTransition elements inside register as 'ghost' and are only
407
+ * used as measurement sources, never animated.
408
+ */
409
+ export function LayoutGhost({ children }: { children: ReactNode }) {
410
+ return (
411
+ <LayoutContainerContext.Provider value="ghost">
412
+ {children}
413
+ </LayoutContainerContext.Provider>
414
+ )
415
+ }
416
+
417
+ /**
418
+ * Wraps an element that should keep visual continuity across section
419
+ * boundaries. Matching is by `id`: if the previous section also rendered
420
+ * <LayoutTransition id="x">, the element springs from its old position
421
+ * to its new one at the start of the section.
422
+ *
423
+ * Usage in MDX:
424
+ * ```mdx
425
+ * # Scene 1 duration=5s
426
+ *
427
+ * <LayoutTransition id="title">
428
+ * <BlurReveal text="Hello" />
429
+ * </LayoutTransition>
430
+ *
431
+ * # Scene 2 duration=5s
432
+ *
433
+ * <LayoutTransition id="title" duration={25} bounce={0.2}>
434
+ * <BlurReveal text="Hello" />
435
+ * </LayoutTransition>
436
+ * ```
437
+ *
438
+ * If no matching id exists in the previous section, children render
439
+ * normally with no animation.
440
+ */
441
+ export function LayoutTransition({
442
+ id,
443
+ duration = 20,
444
+ bounce = 0.15,
445
+ easing,
446
+ mode = 'both',
447
+ showFrom,
448
+ showUpTo,
449
+ children,
450
+ }: {
451
+ id: string
452
+ /** Transition duration in frames. Default 20. */
453
+ duration?: number
454
+ /** Spring bounce, 0 = no overshoot, 1 = max. Default 0.15.
455
+ * Ignored when `easing` is set (easing replaces the spring entirely). */
456
+ bounce?: number
457
+ /** Custom easing function. When set, uses interpolate() over `duration`
458
+ * frames instead of the spring. Overrides `bounce`. */
459
+ easing?: (t: number) => number
460
+ /** Which axes to animate. 'position' snaps size instantly; 'size' snaps
461
+ * position instantly; 'both' (default) animates both. */
462
+ mode?: LayoutTransitionMode
463
+ /** Intra-scene: frame at which this instance becomes visible (inclusive).
464
+ * When set (along with showUpTo), enables intra-scene FLIP transitions
465
+ * between multiple instances of the same id within the same section.
466
+ * Ranges must not overlap. */
467
+ showFrom?: number
468
+ /** Intra-scene: frame at which this instance stops being visible (exclusive).
469
+ * Defaults to Infinity (visible until end of section). */
470
+ showUpTo?: number
471
+ children: ReactNode
472
+ }) {
473
+ const ref = useRef<HTMLDivElement>(null)
474
+ const registry = useContext(LayoutRegistryContext)
475
+ const container = useContext(LayoutContainerContext)
476
+ // useCurrentFrame() throws outside Remotion context (SSR tests). The
477
+ // try-catch is safe: the hook is always called unconditionally so hook
478
+ // order is stable; we just handle the missing context gracefully.
479
+ let frame = 0
480
+ try { frame = useCurrentFrame() } catch {}
481
+
482
+ const hasTimeRange = showFrom !== undefined || showUpTo !== undefined
483
+ const rangeFrom = showFrom ?? 0
484
+ const rangeUpTo = showUpTo ?? Infinity
485
+ const isActive = !hasTimeRange || (frame >= rangeFrom && frame < rangeUpTo)
486
+
487
+ // One stable entry object per component instance. Fields are refreshed
488
+ // on every render (cheap own-ref mutation) so the animation layer always
489
+ // reads current props without re-registration.
490
+ const entryRef = useRef<LayoutEntry | null>(null)
491
+ if (entryRef.current === null) {
492
+ entryRef.current = {
493
+ id, container, ref, durationInFrames: duration, bounce, easing: easing ?? null,
494
+ mode,
495
+ showFrom: hasTimeRange ? rangeFrom : undefined,
496
+ showUpTo: hasTimeRange ? rangeUpTo : undefined,
497
+ }
498
+ }
499
+ entryRef.current.id = id
500
+ entryRef.current.container = container
501
+ entryRef.current.durationInFrames = duration
502
+ entryRef.current.bounce = bounce
503
+ entryRef.current.easing = easing ?? null
504
+ entryRef.current.mode = mode
505
+ entryRef.current.showFrom = hasTimeRange ? rangeFrom : undefined
506
+ entryRef.current.showUpTo = hasTimeRange ? rangeUpTo : undefined
507
+
508
+ useLayoutEffect(() => {
509
+ if (!registry) return
510
+ return registry.register(entryRef.current!)
511
+ }, [registry])
512
+
513
+ // transformOrigin '0 0': FLIP deltas are computed from top-left corners,
514
+ // so the scale component must also originate from the top-left.
515
+ //
516
+ // width: fit-content: prevents the wrapper from stretching to 100% width
517
+ // in block-layout parents. Without this, a LayoutTransition in a block
518
+ // parent (e.g. display:block div) measures as full-width even though the
519
+ // content is small. When the matching element in the next section is in a
520
+ // flex parent (content-sized), the FLIP scale ratio becomes enormous
521
+ // (e.g. 1920px / 364px ≈ 5.3x), grossly distorting text. fit-content
522
+ // ensures the wrapper always matches its content dimensions regardless
523
+ // of parent layout, so FLIP scale ratios reflect actual content size
524
+ // differences, not layout context differences.
525
+ //
526
+ // When using time ranges, inactive instances are visibility:hidden (keeps
527
+ // layout footprint for FLIP measurement) instead of display:none.
528
+ return (
529
+ <div
530
+ ref={ref}
531
+ data-layout-id={id}
532
+ style={{
533
+ transformOrigin: '0 0',
534
+ width: 'fit-content',
535
+ ...(hasTimeRange && !isActive
536
+ ? { visibility: 'hidden' as const, pointerEvents: 'none' as const }
537
+ : {}),
538
+ }}
539
+ >
540
+ {children}
541
+ </div>
542
+ )
543
+ }
544
+
545
+ /**
546
+ * Measures ghost vs visible LayoutTransition elements after every render
547
+ * and writes FLIP transforms to the visible wrappers. Must be rendered
548
+ * AFTER both containers (sibling order matters: its layout effect has to
549
+ * run after all LayoutTransition registrations).
550
+ *
551
+ * Renders an empty composition-sized AbsoluteFill used only to measure
552
+ * the Player's scale factor (composition px vs client px).
553
+ */
554
+ export function LayoutAnimationLayer() {
555
+ const registry = useContext(LayoutRegistryContext)
556
+ const frame = useCurrentFrame()
557
+ const { fps, width } = useVideoConfig()
558
+ const rootRef = useRef<HTMLDivElement>(null)
559
+
560
+ // No dependency array: must re-run on every frame (the Player re-renders
561
+ // each frame, and rect positions can change with frame-driven layout).
562
+ useLayoutEffect(() => {
563
+ if (!registry) return
564
+ const entries = [...registry.entries]
565
+
566
+ // Categorize entries by container and timing mode.
567
+ const allGhosts = entries.filter((e) => e.container === 'ghost')
568
+ const allVisible = entries.filter((e) => e.container === 'visible')
569
+ // Timed visible entries for intra-scene FLIP (ghost entries excluded
570
+ // to prevent cross-section pollution).
571
+ const timedVisible = allVisible.filter((e) => e.showFrom !== undefined)
572
+
573
+ // Reset ALL styles BEFORE measuring: getBoundingClientRect() includes
574
+ // transforms, so measuring a transformed element would compound the
575
+ // previous frame's FLIP offset. Also clear interpolated visual styles
576
+ // (borderRadius, backgroundColor, etc.) so getComputedStyle reads the
577
+ // element's natural values, not our previous frame's overrides.
578
+ for (const e of entries) {
579
+ if (e.ref.current) resetFlipStyles(e.ref.current)
580
+ }
581
+
582
+ const rootRect = rootRef.current?.getBoundingClientRect()
583
+ if (!rootRect || rootRect.width === 0) return
584
+ // Player scale: composition is 1920 wide but rendered smaller/larger
585
+ // in the viewport. Translate deltas are measured in client px and
586
+ // applied in composition px, so divide by this scale. Scale ratios
587
+ // (sx, sy) are dimensionless and unaffected.
588
+ const playerScale = rootRect.width / width
589
+
590
+ // --- Cross-section FLIP (ghost → visible) ---
591
+ // Build one "representative" per id from each side so cross-section
592
+ // FLIP works even when the previous section used untimed entries and
593
+ // the current section uses timed entries (or vice versa).
594
+ if (allGhosts.length > 0) {
595
+ // Ghost representatives: for untimed ghosts, use directly. For timed
596
+ // ghosts, find the one that was active at the frozen frame. The ghost
597
+ // container freezes at prevDurationInFrames-1, so the active timed
598
+ // ghost has NO inline visibility:hidden (set by LayoutTransition).
599
+ const ghostReps = new Map<string, LayoutEntry>()
600
+ for (const e of allGhosts) {
601
+ if (ghostReps.has(e.id)) continue
602
+ if (e.showFrom === undefined) {
603
+ ghostReps.set(e.id, e)
604
+ } else if (e.ref.current?.style.visibility !== 'hidden') {
605
+ ghostReps.set(e.id, e)
606
+ }
607
+ }
608
+
609
+ // Visible representatives: untimed visible first, then active timed.
610
+ const visibleReps = new Map<string, LayoutEntry>()
611
+ for (const e of allVisible) {
612
+ if (visibleReps.has(e.id)) continue
613
+ if (e.showFrom === undefined) {
614
+ visibleReps.set(e.id, e)
615
+ } else if (frame >= (e.showFrom ?? 0) && frame < (e.showUpTo ?? Infinity)) {
616
+ visibleReps.set(e.id, e)
617
+ }
618
+ }
619
+
620
+ for (const [id, e] of visibleReps) {
621
+ const el = e.ref.current
622
+ if (!el) continue
623
+ const ghost = ghostReps.get(id)
624
+ const ghostEl = ghost?.ref.current
625
+ if (!ghostEl) continue
626
+
627
+ const progress = e.easing
628
+ ? interpolate(frame, [0, e.durationInFrames], [0, 1], {
629
+ extrapolateLeft: 'clamp',
630
+ extrapolateRight: 'clamp',
631
+ easing: e.easing,
632
+ })
633
+ : dspring(frame, fps, e.durationInFrames / fps, e.bounce)
634
+ // Must check |progress - 1| not just progress > threshold, otherwise
635
+ // bouncy springs that overshoot past 1.0 get their overshoot frames
636
+ // skipped and the bounce is invisible.
637
+ if (Math.abs(progress - 1) < 0.001) continue
638
+
639
+ const fromRect = ghostEl.getBoundingClientRect()
640
+ const toRect = el.getBoundingClientRect()
641
+ if (toRect.width === 0 || toRect.height === 0 || fromRect.width === 0) continue
642
+
643
+ applyFlip({ el, sourceEl: ghostEl, fromRect, toRect, progress, playerScale, entry: e })
644
+ }
645
+ }
646
+
647
+ // --- Intra-scene FLIP (timed entries within same section) ---
648
+ if (timedVisible.length > 0) {
649
+ // Group timed entries by id, sort each group by showFrom.
650
+ const timedById = new Map<string, LayoutEntry[]>()
651
+ for (const e of timedVisible) {
652
+ const list = timedById.get(e.id)
653
+ if (list) list.push(e)
654
+ else timedById.set(e.id, [e])
655
+ }
656
+
657
+ for (const [, group] of timedById) {
658
+ if (group.length < 2) continue
659
+ group.sort((a, b) => (a.showFrom ?? 0) - (b.showFrom ?? 0))
660
+
661
+ // Find the currently active entry.
662
+ const active = group.find(
663
+ (e) => frame >= (e.showFrom ?? 0) && frame < (e.showUpTo ?? Infinity),
664
+ )
665
+ if (!active?.ref.current) continue
666
+
667
+ // Find previous: the entry whose range ended most recently before
668
+ // the active entry's range starts. Since ranges don't overlap and
669
+ // are sorted, it's the last entry with showUpTo <= active.showFrom.
670
+ const activeFrom = active.showFrom ?? 0
671
+ let prev: LayoutEntry | undefined
672
+ for (let i = group.length - 1; i >= 0; i--) {
673
+ const e = group[i]!
674
+ if (e !== active && (e.showUpTo ?? Infinity) <= activeFrom) {
675
+ prev = e
676
+ break
677
+ }
678
+ }
679
+ if (!prev?.ref.current) continue
680
+
681
+ // Transition starts when the active entry's range begins.
682
+ const localFrame = frame - activeFrom
683
+ const progress = active.easing
684
+ ? interpolate(localFrame, [0, active.durationInFrames], [0, 1], {
685
+ extrapolateLeft: 'clamp',
686
+ extrapolateRight: 'clamp',
687
+ easing: active.easing,
688
+ })
689
+ : dspring(localFrame, fps, active.durationInFrames / fps, active.bounce)
690
+ // Must check |progress - 1| not just progress > threshold, otherwise
691
+ // bouncy springs that overshoot past 1.0 get their overshoot frames
692
+ // skipped and the bounce is invisible.
693
+ if (Math.abs(progress - 1) < 0.001) continue
694
+
695
+ // FLIP: measure previous (visibility:hidden, keeps layout) → active.
696
+ const fromRect = prev.ref.current.getBoundingClientRect()
697
+ const toRect = active.ref.current.getBoundingClientRect()
698
+ if (toRect.width === 0 || toRect.height === 0 || fromRect.width === 0) continue
699
+
700
+ applyFlip({ el: active.ref.current, sourceEl: prev.ref.current, fromRect, toRect, progress, playerScale, entry: active })
701
+ }
702
+ }
703
+ })
704
+
705
+ return <AbsoluteFill ref={rootRef} style={{ pointerEvents: 'none' }} />
706
+ }