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.
- package/CHANGELOG.md +140 -9
- package/dist/cli/cache-utils.d.ts +33 -0
- package/dist/cli/cache-utils.d.ts.map +1 -0
- package/dist/cli/cache-utils.js +157 -0
- package/dist/cli/cached-generate.d.ts +105 -0
- package/dist/cli/cached-generate.d.ts.map +1 -0
- package/dist/cli/cached-generate.js +208 -0
- package/dist/cli/cached-generate.test.d.ts +11 -0
- package/dist/cli/cached-generate.test.d.ts.map +1 -0
- package/dist/cli/cached-generate.test.js +431 -0
- package/dist/cli/cartesia-provider.d.ts +16 -0
- package/dist/cli/cartesia-provider.d.ts.map +1 -0
- package/dist/cli/cartesia-provider.js +184 -0
- package/dist/cli/chatgpt-auth.d.ts.map +1 -0
- package/dist/cli/cli.d.ts +4 -0
- package/dist/cli/cli.d.ts.map +1 -0
- package/dist/{cli.js → cli/cli.js} +626 -600
- package/dist/cli/clone-generate.d.ts +26 -0
- package/dist/cli/clone-generate.d.ts.map +1 -0
- package/dist/cli/clone-generate.js +148 -0
- package/dist/cli/credentials.d.ts.map +1 -0
- package/dist/{credentials.js → cli/credentials.js} +20 -0
- package/dist/cli/elevenlabs-speech-provider.d.ts +26 -0
- package/dist/cli/elevenlabs-speech-provider.d.ts.map +1 -0
- package/dist/cli/elevenlabs-speech-provider.js +199 -0
- package/dist/cli/generate.d.ts +140 -0
- package/dist/cli/generate.d.ts.map +1 -0
- package/dist/cli/generate.js +604 -0
- package/dist/cli/generate.test.d.ts +2 -0
- package/dist/cli/generate.test.d.ts.map +1 -0
- package/dist/cli/generate.test.js +131 -0
- package/dist/cli/login.d.ts.map +1 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +7 -0
- package/dist/{model-catalog.d.ts → cli/model-catalog.d.ts} +6 -0
- package/dist/cli/model-catalog.d.ts.map +1 -0
- package/dist/{model-catalog.js → cli/model-catalog.js} +714 -4
- package/dist/{models.d.ts → cli/models.d.ts} +12 -5
- package/dist/cli/models.d.ts.map +1 -0
- package/dist/{models.js → cli/models.js} +58 -57
- package/dist/cli/open-browser.d.ts.map +1 -0
- package/dist/cli/openai-speech-provider.d.ts +3 -0
- package/dist/cli/openai-speech-provider.d.ts.map +1 -0
- package/dist/cli/openai-speech-provider.js +28 -0
- package/dist/cli/separation-generate.d.ts +34 -0
- package/dist/cli/separation-generate.d.ts.map +1 -0
- package/dist/cli/separation-generate.js +117 -0
- package/dist/cli/speech-catalog.d.ts +35 -0
- package/dist/cli/speech-catalog.d.ts.map +1 -0
- package/dist/cli/speech-catalog.js +195 -0
- package/dist/cli/speech-generate.d.ts +86 -0
- package/dist/cli/speech-generate.d.ts.map +1 -0
- package/dist/cli/speech-generate.js +156 -0
- package/dist/cli/speech-models.d.ts +8 -0
- package/dist/cli/speech-models.d.ts.map +1 -0
- package/dist/cli/speech-models.js +109 -0
- package/dist/cli/speech-provider.test.d.ts +2 -0
- package/dist/cli/speech-provider.test.d.ts.map +1 -0
- package/dist/cli/speech-provider.test.js +197 -0
- package/dist/cli/subscription.d.ts.map +1 -0
- package/dist/cli/transcription-catalog.d.ts +35 -0
- package/dist/cli/transcription-catalog.d.ts.map +1 -0
- package/dist/cli/transcription-catalog.js +192 -0
- package/dist/cli/transcription-generate.d.ts +62 -0
- package/dist/cli/transcription-generate.d.ts.map +1 -0
- package/dist/cli/transcription-generate.js +144 -0
- package/dist/cli/transcription-models.d.ts +9 -0
- package/dist/cli/transcription-models.d.ts.map +1 -0
- package/dist/cli/transcription-models.js +100 -0
- package/dist/cli/transcription-providers.d.ts +22 -0
- package/dist/cli/transcription-providers.d.ts.map +1 -0
- package/dist/cli/transcription-providers.js +290 -0
- package/dist/cli/video-model-catalog.d.ts.map +1 -0
- package/dist/cli/xai-auth.d.ts.map +1 -0
- package/dist/{xai-auth.js → cli/xai-auth.js} +1 -1
- package/dist/vite/angled-screen.d.ts +57 -0
- package/dist/vite/angled-screen.d.ts.map +1 -0
- package/dist/vite/angled-screen.js +115 -0
- package/dist/vite/app.d.ts +51 -0
- package/dist/vite/app.d.ts.map +1 -0
- package/dist/vite/app.js +150 -0
- package/dist/vite/bands-shader.d.ts +47 -0
- package/dist/vite/bands-shader.d.ts.map +1 -0
- package/dist/vite/bands-shader.js +267 -0
- package/dist/vite/code-block.d.ts +107 -0
- package/dist/vite/code-block.d.ts.map +1 -0
- package/dist/vite/code-block.js +641 -0
- package/dist/vite/components.d.ts +36 -0
- package/dist/vite/components.d.ts.map +1 -0
- package/dist/vite/components.js +113 -0
- package/dist/vite/dispersion-rings-shader.d.ts +44 -0
- package/dist/vite/dispersion-rings-shader.d.ts.map +1 -0
- package/dist/vite/dispersion-rings-shader.js +282 -0
- package/dist/vite/easing-curves.d.ts +129 -0
- package/dist/vite/easing-curves.d.ts.map +1 -0
- package/dist/vite/easing-curves.js +584 -0
- package/dist/vite/easing-curves.test.d.ts +2 -0
- package/dist/vite/easing-curves.test.d.ts.map +1 -0
- package/dist/vite/easing-curves.test.js +200 -0
- package/dist/vite/generated-media-client.d.ts +35 -0
- package/dist/vite/generated-media-client.d.ts.map +1 -0
- package/dist/vite/generated-media-client.js +76 -0
- package/dist/vite/keyframes.d.ts +129 -0
- package/dist/vite/keyframes.d.ts.map +1 -0
- package/dist/vite/keyframes.js +194 -0
- package/dist/vite/layout-editor.d.ts +45 -0
- package/dist/vite/layout-editor.d.ts.map +1 -0
- package/dist/vite/layout-editor.js +587 -0
- package/dist/vite/layout-transition.d.ts +136 -0
- package/dist/vite/layout-transition.d.ts.map +1 -0
- package/dist/vite/layout-transition.js +581 -0
- package/dist/vite/liquid-gradient-shader.d.ts +62 -0
- package/dist/vite/liquid-gradient-shader.d.ts.map +1 -0
- package/dist/vite/liquid-gradient-shader.js +413 -0
- package/dist/vite/mdx-client.d.ts +15 -0
- package/dist/vite/mdx-client.d.ts.map +1 -0
- package/dist/vite/mdx-client.js +265 -0
- package/dist/vite/mdx-parse.d.ts +102 -0
- package/dist/vite/mdx-parse.d.ts.map +1 -0
- package/dist/vite/mdx-parse.js +251 -0
- package/dist/vite/mdx-provided-components.d.ts +18 -0
- package/dist/vite/mdx-provided-components.d.ts.map +1 -0
- package/dist/vite/mdx-provided-components.js +14 -0
- package/dist/vite/mdx-video.d.ts +327 -0
- package/dist/vite/mdx-video.d.ts.map +1 -0
- package/dist/vite/mdx-video.js +596 -0
- package/dist/vite/mdx-video.test.d.ts +9 -0
- package/dist/vite/mdx-video.test.d.ts.map +1 -0
- package/dist/vite/mdx-video.test.js +1945 -0
- package/dist/vite/media-components.d.ts +45 -0
- package/dist/vite/media-components.d.ts.map +1 -0
- package/dist/vite/media-components.js +355 -0
- package/dist/vite/media-duration-store.d.ts +66 -0
- package/dist/vite/media-duration-store.d.ts.map +1 -0
- package/dist/vite/media-duration-store.js +204 -0
- package/dist/vite/player-page.d.ts +35 -0
- package/dist/vite/player-page.d.ts.map +1 -0
- package/dist/vite/player-page.js +720 -0
- package/dist/vite/render-client.d.ts +18 -0
- package/dist/vite/render-client.d.ts.map +1 -0
- package/dist/vite/render-client.js +67 -0
- package/dist/vite/sdk.d.ts +221 -0
- package/dist/vite/sdk.d.ts.map +1 -0
- package/dist/vite/sdk.js +355 -0
- package/dist/vite/server-components.d.ts +55 -0
- package/dist/vite/server-components.d.ts.map +1 -0
- package/dist/vite/server-components.js +156 -0
- package/dist/vite/server-mdx.d.ts +74 -0
- package/dist/vite/server-mdx.d.ts.map +1 -0
- package/dist/vite/server-mdx.js +259 -0
- package/dist/vite/shader-renderer.d.ts +48 -0
- package/dist/vite/shader-renderer.d.ts.map +1 -0
- package/dist/vite/shader-renderer.js +411 -0
- package/dist/vite/store-hooks.d.ts +12 -0
- package/dist/vite/store-hooks.d.ts.map +1 -0
- package/dist/vite/store-hooks.js +93 -0
- package/dist/vite/store.d.ts +75 -0
- package/dist/vite/store.d.ts.map +1 -0
- package/dist/vite/store.js +52 -0
- package/dist/vite/store.test.d.ts +6 -0
- package/dist/vite/store.test.d.ts.map +1 -0
- package/dist/vite/store.test.js +172 -0
- package/dist/vite/tweakpane-hook.d.ts +94 -0
- package/dist/vite/tweakpane-hook.d.ts.map +1 -0
- package/dist/vite/tweakpane-hook.js +450 -0
- package/dist/vite/vite-plugin.d.ts +28 -0
- package/dist/vite/vite-plugin.d.ts.map +1 -0
- package/dist/vite/vite-plugin.js +538 -0
- package/dist/vite/wave-gradient-shader.d.ts +41 -0
- package/dist/vite/wave-gradient-shader.d.ts.map +1 -0
- package/dist/vite/wave-gradient-shader.js +207 -0
- package/package.json +101 -24
- package/skills/egaki/SKILL.md +162 -1
- package/src/cli/cache-utils.ts +169 -0
- package/src/cli/cached-generate.test.ts +531 -0
- package/src/cli/cached-generate.ts +348 -0
- package/src/cli/cartesia-provider.ts +249 -0
- package/src/{cli.ts → cli/cli.ts} +836 -884
- package/src/cli/clone-generate.ts +202 -0
- package/src/{credentials.ts → cli/credentials.ts} +20 -0
- package/src/cli/elevenlabs-speech-provider.ts +243 -0
- package/src/cli/generate.test.ts +166 -0
- package/src/cli/generate.ts +912 -0
- package/src/cli/main.ts +8 -0
- package/src/{model-catalog.ts → cli/model-catalog.ts} +787 -58
- package/src/{models.ts → cli/models.ts} +65 -86
- package/src/cli/openai-speech-provider.ts +34 -0
- package/src/cli/separation-generate.ts +187 -0
- package/src/cli/speech-catalog.ts +253 -0
- package/src/cli/speech-generate.ts +275 -0
- package/src/cli/speech-models.ts +146 -0
- package/src/cli/speech-provider.test.ts +226 -0
- package/src/cli/transcription-catalog.ts +250 -0
- package/src/cli/transcription-generate.ts +214 -0
- package/src/cli/transcription-models.ts +136 -0
- package/src/cli/transcription-providers.ts +427 -0
- package/src/{xai-auth.ts → cli/xai-auth.ts} +1 -1
- package/src/vite/angled-screen.tsx +214 -0
- package/src/vite/app.tsx +197 -0
- package/src/vite/bands-shader.tsx +319 -0
- package/src/vite/code-block.tsx +939 -0
- package/src/vite/components.tsx +278 -0
- package/src/vite/dispersion-rings-shader.tsx +331 -0
- package/src/vite/easing-curves.test.ts +232 -0
- package/src/vite/easing-curves.ts +743 -0
- package/src/vite/generated-media-client.tsx +121 -0
- package/src/vite/keyframes.tsx +347 -0
- package/src/vite/layout-editor.tsx +709 -0
- package/src/vite/layout-transition.tsx +706 -0
- package/src/vite/liquid-gradient-shader.tsx +474 -0
- package/src/vite/mdx-client.tsx +420 -0
- package/src/vite/mdx-parse.ts +343 -0
- package/src/vite/mdx-provided-components.ts +19 -0
- package/src/vite/mdx-video.test.tsx +2219 -0
- package/src/vite/mdx-video.tsx +976 -0
- package/src/vite/media-components.tsx +473 -0
- package/src/vite/media-duration-store.ts +225 -0
- package/src/vite/motion-timing.ts +194 -0
- package/src/vite/player-page.tsx +1107 -0
- package/src/vite/render-client.ts +84 -0
- package/src/vite/sdk.ts +598 -0
- package/src/vite/server-components.tsx +241 -0
- package/src/vite/server-mdx.ts +285 -0
- package/src/vite/shader-renderer.tsx +535 -0
- package/src/vite/store-hooks.ts +117 -0
- package/src/vite/store.test.ts +202 -0
- package/src/vite/store.ts +115 -0
- package/src/vite/styles.css +21 -0
- package/src/vite/tweakpane-hook.tsx +593 -0
- package/src/vite/virtual-modules.d.ts +30 -0
- package/src/vite/vite-plugin.ts +584 -0
- package/src/vite/wave-gradient-shader.tsx +253 -0
- package/README.md +0 -370
- package/dist/antigravity-auth.d.ts +0 -15
- package/dist/antigravity-auth.d.ts.map +0 -1
- package/dist/antigravity-auth.js +0 -544
- package/dist/antigravity-image.d.ts +0 -11
- package/dist/antigravity-image.d.ts.map +0 -1
- package/dist/antigravity-image.js +0 -285
- package/dist/chatgpt-auth.d.ts.map +0 -1
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/credentials.d.ts.map +0 -1
- package/dist/gateway-model-candidates.generated.d.ts +0 -11
- package/dist/gateway-model-candidates.generated.d.ts.map +0 -1
- package/dist/gateway-model-candidates.generated.js +0 -64
- package/dist/login.d.ts.map +0 -1
- package/dist/model-catalog.d.ts.map +0 -1
- package/dist/models.d.ts.map +0 -1
- package/dist/open-browser.d.ts.map +0 -1
- package/dist/subscription.d.ts.map +0 -1
- package/dist/video-model-catalog.d.ts.map +0 -1
- package/dist/xai-auth.d.ts.map +0 -1
- /package/dist/{chatgpt-auth.d.ts → cli/chatgpt-auth.d.ts} +0 -0
- /package/dist/{chatgpt-auth.js → cli/chatgpt-auth.js} +0 -0
- /package/dist/{credentials.d.ts → cli/credentials.d.ts} +0 -0
- /package/dist/{login.d.ts → cli/login.d.ts} +0 -0
- /package/dist/{login.js → cli/login.js} +0 -0
- /package/dist/{open-browser.d.ts → cli/open-browser.d.ts} +0 -0
- /package/dist/{open-browser.js → cli/open-browser.js} +0 -0
- /package/dist/{subscription.d.ts → cli/subscription.d.ts} +0 -0
- /package/dist/{subscription.js → cli/subscription.js} +0 -0
- /package/dist/{video-model-catalog.d.ts → cli/video-model-catalog.d.ts} +0 -0
- /package/dist/{video-model-catalog.js → cli/video-model-catalog.js} +0 -0
- /package/dist/{xai-auth.d.ts → cli/xai-auth.d.ts} +0 -0
- /package/src/{chatgpt-auth.ts → cli/chatgpt-auth.ts} +0 -0
- /package/src/{login.ts → cli/login.ts} +0 -0
- /package/src/{open-browser.ts → cli/open-browser.ts} +0 -0
- /package/src/{subscription.ts → cli/subscription.ts} +0 -0
- /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
|
+
}
|