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,473 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Ghost-aware media components for egaki video.
5
+ *
6
+ * Wraps Remotion's Img and @remotion/media's Audio/Video with:
7
+ * - LayoutTransition ghost neutralization (Audio renders nothing in ghost,
8
+ * Video renders a lightweight div placeholder for FLIP measurement)
9
+ * - Promise<string> src support via Suspense + React 19 use()
10
+ * - Auto duration reporting to the per-section duration store
11
+ * - Tweakpane trim controls for interactive Video editing
12
+ * - Export-aware delayRender for frame capture blocking
13
+ *
14
+ * Also defines ExportContext for detecting export mode (renderMediaOnWeb).
15
+ */
16
+
17
+ import {
18
+ createContext,
19
+ Suspense,
20
+ use,
21
+ useContext,
22
+ useEffect,
23
+ useId,
24
+ useLayoutEffect,
25
+ useRef,
26
+ useState,
27
+ type ComponentProps,
28
+ type ReactNode,
29
+ } from 'react'
30
+ import {
31
+ Img as RemotionImg,
32
+ Sequence,
33
+ useCurrentFrame,
34
+ useDelayRender,
35
+ useVideoConfig,
36
+ } from 'remotion'
37
+ import { Audio as MediaAudio, Video as MediaVideo } from '@remotion/media'
38
+ import { LayoutContainerContext } from './layout-transition.tsx'
39
+ import {
40
+ getCachedRawDuration,
41
+ cacheRawDuration,
42
+ computeEffectiveDuration,
43
+ reportSectionDuration,
44
+ useSectionIndex,
45
+ } from './media-duration-store.ts'
46
+ import { useTweakpane } from './tweakpane-hook.tsx'
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Export context — lets components detect when they're inside a render export
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export const ExportContext = createContext(false)
53
+
54
+ /** Returns true when the component is rendering inside an export (renderMediaOnWeb). */
55
+ export function useIsExporting(): boolean {
56
+ return useContext(ExportContext)
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // useReportMediaDuration
61
+ //
62
+ // Shared hook for Audio/Video: fetches raw media duration, computes
63
+ // effective playback duration (accounting for trim + playbackRate), and
64
+ // reports to the per-section duration store.
65
+ //
66
+ // Priority:
67
+ // 1. Both trimBefore + trimAfter set → compute from props, no fetch.
68
+ // 2. Raw src cached → compute effective from cached raw + trim + rate.
69
+ // 3. Cache miss → delayRender, fetch via mediabunny, cache raw, compute
70
+ // effective, report, continueRender.
71
+ //
72
+ // Returns the RAW source duration (for tweakpane trim controls). null until
73
+ // known. Reports the EFFECTIVE duration to the section store.
74
+ //
75
+ // Uses delayRender to block export rendering until the duration is known.
76
+ //
77
+ // Reports are NOT cleared on unmount. Remotion's Series.Sequence unmounts
78
+ // inactive sections during normal seek/playback; clearing on unmount would
79
+ // cause an infinite loop (report → duration changes → remount → report).
80
+ // Reports persist for the lifetime of the composition and are reset when
81
+ // the MDX/modules change (see resetSectionDurations in mdx-client.tsx).
82
+ // ---------------------------------------------------------------------------
83
+
84
+ function useReportMediaDuration(props: {
85
+ src?: string
86
+ trimBefore?: number
87
+ trimAfter?: number
88
+ playbackRate?: number
89
+ startInFrames?: number
90
+ }, skip?: boolean): number | null {
91
+ const instanceId = useId()
92
+ const sectionIndex = useSectionIndex()
93
+ const { fps } = useVideoConfig()
94
+ const { delayRender, continueRender } = useDelayRender()
95
+ const isExporting = useIsExporting()
96
+ const [rawDuration, setRawDuration] = useState<number | null>(null)
97
+
98
+ // useLayoutEffect ensures delayRender is registered before paint,
99
+ // preventing the renderer from capturing a frame before the handle exists.
100
+ useLayoutEffect(() => {
101
+ if (!props.src || skip) return
102
+
103
+ let delayHandle: number | null = null
104
+ let disposed = false
105
+
106
+ const reportEffective = (raw: number) => {
107
+ const effective = computeEffectiveDuration({
108
+ rawSeconds: raw,
109
+ fps,
110
+ trimBefore: props.trimBefore,
111
+ trimAfter: props.trimAfter,
112
+ playbackRate: props.playbackRate,
113
+ startInFrames: props.startInFrames,
114
+ })
115
+ if (effective != null && effective > 0) {
116
+ reportSectionDuration(sectionIndex, instanceId, effective)
117
+ }
118
+ }
119
+
120
+ // Fast path: both trim bounds set → effective duration fully determined.
121
+ const effectiveFromProps = computeEffectiveDuration({
122
+ fps,
123
+ trimBefore: props.trimBefore,
124
+ trimAfter: props.trimAfter,
125
+ playbackRate: props.playbackRate,
126
+ startInFrames: props.startInFrames,
127
+ })
128
+ if (effectiveFromProps != null) {
129
+ reportSectionDuration(sectionIndex, instanceId, effectiveFromProps)
130
+ return
131
+ }
132
+
133
+ // Check raw src cache
134
+ const cachedRaw = getCachedRawDuration(props.src)
135
+ if (cachedRaw !== undefined) {
136
+ setRawDuration(cachedRaw)
137
+ reportEffective(cachedRaw)
138
+ return
139
+ }
140
+
141
+ // Cache miss: during export, block rendering until metadata is known.
142
+ // In the interactive Player, delayRender still creates global handles even
143
+ // though playback buffering is handled by Remotion's separate BufferState.
144
+ // Keeping it export-only avoids stale render-ready state during trim seeks.
145
+ if (isExporting) {
146
+ delayHandle = delayRender('Fetching media duration for ' + props.src)
147
+ }
148
+
149
+ void (async () => {
150
+ try {
151
+ const { Input, UrlSource, ALL_FORMATS } = await import('mediabunny')
152
+ if (disposed) return
153
+ const input = new Input({
154
+ formats: ALL_FORMATS,
155
+ source: new UrlSource(props.src!),
156
+ })
157
+ const duration =
158
+ (await input.getDurationFromMetadata()) ??
159
+ (await input.computeDuration())
160
+ input.dispose()
161
+ if (!disposed && isFinite(duration) && duration > 0) {
162
+ cacheRawDuration(props.src!, duration)
163
+ setRawDuration(duration)
164
+ reportEffective(duration)
165
+ }
166
+ } catch {
167
+ // Source unreadable or unsupported format; skip
168
+ } finally {
169
+ if (delayHandle != null) {
170
+ continueRender(delayHandle)
171
+ delayHandle = null
172
+ }
173
+ }
174
+ })()
175
+
176
+ return () => {
177
+ disposed = true
178
+ if (delayHandle != null) {
179
+ continueRender(delayHandle)
180
+ delayHandle = null
181
+ }
182
+ }
183
+ }, [props.src, skip, sectionIndex, instanceId, fps, props.trimBefore, props.trimAfter, props.playbackRate, props.startInFrames, isExporting])
184
+
185
+ return rawDuration
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // ExportDelayFallback — Suspense fallback that blocks Remotion frame capture
190
+ // during export when a promise src is still pending. In the interactive Player,
191
+ // renders nothing (no blocking needed).
192
+ // ---------------------------------------------------------------------------
193
+
194
+ function ExportDelayFallback() {
195
+ const isExporting = useIsExporting()
196
+ const { delayRender, continueRender } = useDelayRender()
197
+
198
+ useLayoutEffect(() => {
199
+ if (!isExporting) return
200
+ const handle = delayRender('Waiting for async media src')
201
+ return () => continueRender(handle)
202
+ }, [isExporting, delayRender, continueRender])
203
+
204
+ return null
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Promise-aware media components — Audio, Video, Img all accept
209
+ // src: string | Promise<string>. When a promise is passed, the component
210
+ // wraps internally in Suspense + use() to resolve it. During export,
211
+ // delayRender blocks frame capture until the promise resolves.
212
+ // ---------------------------------------------------------------------------
213
+
214
+ type ImgProps = Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {
215
+ src?: string | Promise<string>
216
+ }
217
+
218
+ function ResolvedImg({ srcPromise, ...rest }: { srcPromise: Promise<string> } & Omit<ImgProps, 'src'>) {
219
+ const src = use(srcPromise)
220
+ return <Img src={src} {...rest} />
221
+ }
222
+
223
+ export function Img(props: ImgProps) {
224
+ const { style, src, ...rest } = props
225
+ if (!src) return null
226
+ if (src instanceof Promise) {
227
+ return (
228
+ <Suspense fallback={<ExportDelayFallback />}>
229
+ <ResolvedImg srcPromise={src} style={style} {...rest} />
230
+ </Suspense>
231
+ )
232
+ }
233
+ // eslint-disable-next-line jsx-a11y/alt-text
234
+ return <RemotionImg src={src} style={{ display: 'block', ...style }} {...rest} />
235
+ }
236
+
237
+ /** Extra props accepted by egaki's Audio and Video wrappers. */
238
+ type MediaOffsetProps = {
239
+ /**
240
+ * Frame offset for when the media starts playing.
241
+ *
242
+ * Positive or zero: delays playback from section start.
243
+ * `startInFrames={1 * FPS}` waits 1 second then starts.
244
+ *
245
+ * Negative: offset from section end.
246
+ * `startInFrames={-2 * FPS}` starts playing 2 seconds before the section ends.
247
+ */
248
+ startInFrames?: number
249
+ }
250
+
251
+ type AudioProps = Omit<ComponentProps<typeof MediaAudio>, 'src'> & MediaOffsetProps & {
252
+ src?: string | Promise<string>
253
+ }
254
+
255
+ function ResolvedAudio({ srcPromise, ...rest }: { srcPromise: Promise<string> } & Omit<AudioProps, 'src'>) {
256
+ const src = use(srcPromise)
257
+ return <Audio src={src} {...rest} />
258
+ }
259
+
260
+ /**
261
+ * Resolve startInFrames to a Sequence `from` value.
262
+ * Positive: use directly. Negative: offset from section end (can resolve to
263
+ * a negative `from`, which Remotion handles as pre-roll — the media starts
264
+ * mid-playback at the section's first frame).
265
+ * Returns 0 (no wrapping needed) when startInFrames is 0 or undefined.
266
+ */
267
+ function resolveMediaStartFrame(startInFrames: number | undefined, durationInFrames: number): number {
268
+ if (startInFrames == null || startInFrames === 0) return 0
269
+ if (startInFrames < 0) return durationInFrames + startInFrames
270
+ return startInFrames
271
+ }
272
+
273
+ export function Audio(props: AudioProps) {
274
+ const { startInFrames, src, ...mediaProps } = props
275
+ const container = useContext(LayoutContainerContext)
276
+ const { durationInFrames } = useVideoConfig()
277
+ // Skip duration reporting when src is a promise (not yet resolved)
278
+ useReportMediaDuration(
279
+ { ...props, src: typeof src === 'string' ? src : undefined },
280
+ container === 'ghost' || src instanceof Promise,
281
+ )
282
+ if (container === 'ghost') return null
283
+
284
+ if (src instanceof Promise) {
285
+ return (
286
+ <Suspense fallback={<ExportDelayFallback />}>
287
+ <ResolvedAudio srcPromise={src} startInFrames={startInFrames} {...mediaProps} />
288
+ </Suspense>
289
+ )
290
+ }
291
+
292
+ const resolvedFrom = resolveMediaStartFrame(startInFrames, durationInFrames)
293
+ if (resolvedFrom) {
294
+ return <Sequence from={resolvedFrom} layout="none"><MediaAudio src={src} {...mediaProps} /></Sequence>
295
+ }
296
+ return <MediaAudio src={src} {...mediaProps} />
297
+ }
298
+
299
+ type VideoProps = Omit<ComponentProps<typeof MediaVideo>, 'src'> & MediaOffsetProps & {
300
+ src?: string | Promise<string>
301
+ }
302
+
303
+ function ResolvedVideo({ srcPromise, ...rest }: { srcPromise: Promise<string> } & Omit<VideoProps, 'src'>) {
304
+ const src = use(srcPromise)
305
+ return <Video src={src} {...rest} />
306
+ }
307
+
308
+ export function Video(props: VideoProps) {
309
+ const { startInFrames, src, ...mediaProps } = props
310
+ const container = useContext(LayoutContainerContext)
311
+ const isExporting = useIsExporting()
312
+ const { durationInFrames } = useVideoConfig()
313
+
314
+ // Promise src: wrap in Suspense, resolve, then re-render with string src
315
+ if (src instanceof Promise) {
316
+ return (
317
+ <Suspense fallback={<ExportDelayFallback />}>
318
+ <ResolvedVideo srcPromise={src} startInFrames={startInFrames} {...mediaProps} />
319
+ </Suspense>
320
+ )
321
+ }
322
+
323
+ // Default to filling the container. Remotion's canvas renders at the
324
+ // video's native resolution without explicit width/height, which causes
325
+ // the video to appear tiny in flex layouts. safe-mdx also drops the
326
+ // style prop when a component is passed as an expression prop (e.g.
327
+ // background={<Video style={{...}} />}), so explicit user styles are
328
+ // unreliable in MDX. Defaulting to 100% matches how users expect a
329
+ // video to behave in a layout system. An explicit style prop overrides.
330
+ const filledProps = {
331
+ ...mediaProps,
332
+ src,
333
+ style: { width: '100%', height: '100%', ...mediaProps.style },
334
+ }
335
+
336
+ const resolvedFrom = resolveMediaStartFrame(startInFrames, durationInFrames)
337
+ const wrapWithOffset = (el: ReactNode) =>
338
+ resolvedFrom ? <Sequence from={resolvedFrom} layout="none">{el}</Sequence> : el
339
+
340
+ if (container === 'ghost') {
341
+ return wrapWithOffset(<div style={filledProps.style} />)
342
+ }
343
+
344
+ // During export, report duration but skip tweakpane UI
345
+ if (isExporting) {
346
+ return wrapWithOffset(<VideoExportDuration {...filledProps} startInFrames={startInFrames} />)
347
+ }
348
+
349
+ return wrapWithOffset(<VideoWithTweakpane {...filledProps} startInFrames={startInFrames} />)
350
+ }
351
+
352
+ /** Export mode: reports duration to section store, renders plain MediaVideo. */
353
+ function VideoExportDuration(props: ComponentProps<typeof MediaVideo> & MediaOffsetProps) {
354
+ const { startInFrames, ...mediaProps } = props
355
+ useReportMediaDuration(props)
356
+ return <MediaVideo {...mediaProps} />
357
+ }
358
+
359
+ /**
360
+ * Loads the source video's duration via the shared hook, then delegates
361
+ * to VideoTrimControls for tweakpane trim sliders. Until duration is known,
362
+ * renders the video without trim controls.
363
+ */
364
+ function VideoWithTweakpane(props: ComponentProps<typeof MediaVideo> & MediaOffsetProps) {
365
+ const rawDuration = useReportMediaDuration(props)
366
+ const { startInFrames, ...mediaProps } = props
367
+
368
+ if (rawDuration === null) {
369
+ return <MediaVideo {...mediaProps} />
370
+ }
371
+
372
+ return <VideoTrimControls {...mediaProps} mediaDuration={rawDuration} />
373
+ }
374
+
375
+ /**
376
+ * Registers tweakpane start/end sliders (in seconds) and converts
377
+ * them to Remotion's trimBefore/trimAfter frame props. The folder label
378
+ * is the video filename extracted from src.
379
+ *
380
+ * When the user drags a slider, the player pauses and seeks to the
381
+ * corresponding frame so the user can see exactly where the cut lands.
382
+ * Seeks are debounced to 50ms to avoid flooding the player during
383
+ * continuous dragging.
384
+ *
385
+ * Section offset computation: this component lives inside a Remotion
386
+ * Series.Sequence. useCurrentFrame() returns the frame relative to the
387
+ * sequence, while egakiSDK.getCurrentFrame() returns the absolute
388
+ * composition frame. The difference (absolute - relative) gives the
389
+ * section's start frame in the composition, cached on first render.
390
+ */
391
+ function VideoTrimControls(
392
+ props: ComponentProps<typeof MediaVideo> & { mediaDuration: number },
393
+ ) {
394
+ const { mediaDuration, ...videoProps } = props
395
+ const { fps, durationInFrames: sectionDuration } = useVideoConfig()
396
+ const relativeFrame = useCurrentFrame()
397
+
398
+ // Compute the absolute frame offset of this section once on first render.
399
+ // useCurrentFrame() = relative, egakiSDK.getCurrentFrame() = absolute.
400
+ const sectionOffsetRef = useRef<number | null>(null)
401
+ if (sectionOffsetRef.current === null) {
402
+ try {
403
+ const absoluteFrame = window.egakiSDK?.getCurrentFrame() ?? 0
404
+ sectionOffsetRef.current = absoluteFrame - relativeFrame
405
+ } catch {
406
+ sectionOffsetRef.current = 0
407
+ }
408
+ }
409
+
410
+ // Use the filename from src as the tweakpane folder label
411
+ const src = typeof props.src === 'string' ? props.src : 'Video'
412
+ const label = src.split('/').pop()?.split('?')[0] || 'Video'
413
+
414
+ // Convert any existing trim props (in frames) to seconds for defaults
415
+ const defaultStart = props.trimBefore != null ? props.trimBefore / fps : 0
416
+ const defaultEnd = props.trimAfter != null ? props.trimAfter / fps : mediaDuration
417
+
418
+ const tp = useTweakpane(label, {
419
+ trim: { type: 'range' as const, value: { min: defaultStart, max: defaultEnd }, min: 0, max: mediaDuration, step: 0.1 },
420
+ })
421
+
422
+ // Debounced seek: pause the player and seek to the trim point so the
423
+ // user sees the exact frame they're cutting to. Debounce at 50ms so
424
+ // continuous slider dragging doesn't flood the player with seeks.
425
+ const seekTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
426
+ const prevTrimStartRef = useRef(tp.trim.min)
427
+ const prevTrimEndRef = useRef(tp.trim.max)
428
+
429
+ useEffect(() => {
430
+ const sdk = window.egakiSDK
431
+ if (!sdk) return
432
+
433
+ const offset = sectionOffsetRef.current ?? 0
434
+ let targetFrame: number | null = null
435
+
436
+ if (tp.trim.min !== prevTrimStartRef.current) {
437
+ // start changed → seek to section start (where source shows start)
438
+ targetFrame = offset
439
+ prevTrimStartRef.current = tp.trim.min
440
+ } else if (tp.trim.max !== prevTrimEndRef.current) {
441
+ // end changed → seek to the section-relative frame where source
442
+ // shows the end point: F = (end - start) * fps - 1
443
+ // Seek away from the cut. Browser/media decoders are often
444
+ // flaky at the exact final decodable frame of a trimmed range, and this
445
+ // seek is only preview feedback; trimAfter itself remains exact.
446
+ const endRelative = Math.round((tp.trim.max - tp.trim.min) * fps) - Math.round(fps * 0.25)
447
+ targetFrame = offset + Math.max(0, Math.min(endRelative, sectionDuration - 1))
448
+ prevTrimEndRef.current = tp.trim.max
449
+ }
450
+
451
+ if (targetFrame === null) return
452
+
453
+ if (seekTimerRef.current) clearTimeout(seekTimerRef.current)
454
+ const frame = targetFrame
455
+ seekTimerRef.current = setTimeout(() => {
456
+ try {
457
+ sdk.seekTo(Math.max(0, frame))
458
+ } catch {
459
+ // SDK not ready, ignore
460
+ }
461
+ }, 50)
462
+
463
+ return () => {
464
+ if (seekTimerRef.current) clearTimeout(seekTimerRef.current)
465
+ }
466
+ }, [tp.trim.min, tp.trim.max, fps, sectionDuration])
467
+
468
+ // Convert seconds back to frames for Remotion
469
+ const trimBefore = tp.trim.min > 0 ? Math.round(tp.trim.min * fps) : undefined
470
+ const trimAfter = tp.trim.max < mediaDuration ? Math.round(tp.trim.max * fps) : undefined
471
+
472
+ return <MediaVideo {...videoProps} trimBefore={trimBefore} trimAfter={trimAfter} />
473
+ }
@@ -0,0 +1,225 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Media duration cache and section duration state.
5
+ *
6
+ * Two layers:
7
+ *
8
+ * 1. **Raw src cache** (persistent) — `Map<string, number>` keyed by media
9
+ * src URL → RAW source duration in seconds (before trim/playbackRate).
10
+ * Backed by localStorage. Never stores effective/trimmed durations so
11
+ * the same src used with different trim ranges doesn't get poisoned.
12
+ *
13
+ * 2. **Per-instance section reports** (ephemeral) — each mounted Audio/Video
14
+ * component reports its EFFECTIVE playback duration (after trim + speed)
15
+ * keyed by `(sectionIndex, instanceId)`. Per-section max is derived from
16
+ * active reports. When a component unmounts, its report is cleared and
17
+ * the max recomputes — durations shrink correctly when media is removed,
18
+ * src changes, or trim bounds change.
19
+ *
20
+ * `useMediaDurations()` returns per-section maxes as
21
+ * `Record<string, number>` (section-index-as-string → seconds) for
22
+ * `resolveAutoDurations`. Subscribes via the centralized zustand store.
23
+ */
24
+
25
+ import { createContext, useContext } from 'react'
26
+ import { egakiStore } from './store.ts'
27
+ import { useMediaDurations } from './store-hooks.ts'
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Section index context
31
+ //
32
+ // Provided by player-page.tsx around each section's content. Audio/Video
33
+ // components read this to know which section they belong to, then report
34
+ // their effective media duration grouped by section index.
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export const SectionIndexContext = createContext<number>(-1)
38
+
39
+ export function useSectionIndex(): number {
40
+ return useContext(SectionIndexContext)
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Persistent raw src cache (localStorage)
45
+ //
46
+ // Stores RAW source duration only — the full media length before any trim
47
+ // or speed adjustments. This prevents cache poisoning when the same src
48
+ // is used with different trim ranges in different sections.
49
+ // ---------------------------------------------------------------------------
50
+
51
+ const STORAGE_KEY = 'egaki:media-src-durations'
52
+
53
+ /** src URL → RAW duration in seconds (before trim/speed) */
54
+ const rawSrcCache = new Map<string, number>()
55
+
56
+ function loadSrcCache() {
57
+ try {
58
+ const raw = localStorage.getItem(STORAGE_KEY)
59
+ if (!raw) return
60
+ const parsed = JSON.parse(raw) as Record<string, number>
61
+ for (const [src, dur] of Object.entries(parsed)) {
62
+ if (typeof dur === 'number' && dur > 0) rawSrcCache.set(src, dur)
63
+ }
64
+ } catch {
65
+ // localStorage unavailable or corrupted; start fresh
66
+ }
67
+ }
68
+
69
+ function persistSrcCache() {
70
+ try {
71
+ const obj: Record<string, number> = {}
72
+ for (const [src, dur] of rawSrcCache) obj[src] = dur
73
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(obj))
74
+ } catch {
75
+ // localStorage full or unavailable, ignore
76
+ }
77
+ }
78
+
79
+ // Load on module init (runs once in the browser)
80
+ if (typeof localStorage !== 'undefined') loadSrcCache()
81
+
82
+ /**
83
+ * Read a cached RAW source duration by src URL.
84
+ * Returns undefined on cache miss. Does NOT trigger any fetch.
85
+ */
86
+ export function getCachedRawDuration(src: string): number | undefined {
87
+ return rawSrcCache.get(src)
88
+ }
89
+
90
+ /**
91
+ * Cache a RAW source duration. Only updates if the new value is larger
92
+ * (metadata reads should be deterministic, but multiple formats may
93
+ * report slightly different values).
94
+ */
95
+ export function cacheRawDuration(src: string, seconds: number) {
96
+ if (!isFinite(seconds) || seconds <= 0) return
97
+ const prev = rawSrcCache.get(src)
98
+ if (prev === undefined || prev < seconds) {
99
+ rawSrcCache.set(src, seconds)
100
+ persistSrcCache()
101
+ }
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Effective duration: derive from raw + trim + playbackRate
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * Compute effective media playback duration in seconds from Remotion trim,
110
+ * speed, and gap props.
111
+ *
112
+ * `rawSeconds` is the full source duration (from cache or mediabunny).
113
+ * When both trimBefore and trimAfter are set, rawSeconds is not needed.
114
+ *
115
+ * trimBefore/trimAfter are in FRAMES (Remotion convention).
116
+ * startInFrames offsets playback start: positive delays from section start,
117
+ * negative counts from section end. Only positive values add to auto-duration
118
+ * (negative values anchor to section end, so the section must have an explicit
119
+ * duration or another media element determining length).
120
+ * playbackRate defaults to 1.
121
+ *
122
+ * Returns null when duration cannot be determined (missing bounds and
123
+ * no rawSeconds).
124
+ */
125
+ export function computeEffectiveDuration({
126
+ rawSeconds,
127
+ fps,
128
+ trimBefore,
129
+ trimAfter,
130
+ playbackRate,
131
+ startInFrames,
132
+ }: {
133
+ rawSeconds?: number
134
+ fps: number
135
+ trimBefore?: number
136
+ trimAfter?: number
137
+ playbackRate?: number
138
+ startInFrames?: number
139
+ }): number | null {
140
+ const rate = playbackRate ?? 1
141
+ if (rate <= 0) return null
142
+
143
+ const startFrame = trimBefore ?? 0
144
+ const endFrame = trimAfter ?? (rawSeconds != null ? Math.round(rawSeconds * fps) : null)
145
+ if (endFrame == null) return null
146
+ const mediaFrames = endFrame - startFrame
147
+ if (mediaFrames <= 0) return null
148
+ // Only positive startInFrames adds to effective duration (delay before media).
149
+ // Negative startInFrames anchors to section end and doesn't extend auto-duration.
150
+ // The delay is timeline-fixed (not affected by playbackRate), so add it separately.
151
+ const positiveOffsetFrames = startInFrames != null && startInFrames > 0 ? startInFrames : 0
152
+ return mediaFrames / fps / rate + positiveOffsetFrames / fps
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Per-instance section reports — stored in the centralized zustand store.
157
+ // ---------------------------------------------------------------------------
158
+
159
+ export { useMediaDurations }
160
+
161
+ /**
162
+ * Report an effective media duration for a section from a specific
163
+ * component instance. The per-section max is derived from all active
164
+ * reports via selectMediaDurations. Called by the useReportMediaDuration hook.
165
+ */
166
+ export function reportSectionDuration(sectionIndex: number, instanceId: string, seconds: number) {
167
+ if (sectionIndex < 0 || !isFinite(seconds) || seconds <= 0) return
168
+ egakiStore.setState((state) => {
169
+ const prev = state.sectionReports.get(sectionIndex)?.get(instanceId)
170
+ if (prev === seconds) return state
171
+ const newReports = new Map(state.sectionReports)
172
+ const sectionMap = new Map(newReports.get(sectionIndex) ?? [])
173
+ sectionMap.set(instanceId, seconds)
174
+ newReports.set(sectionIndex, sectionMap)
175
+ return { sectionReports: newReports }
176
+ })
177
+ }
178
+
179
+ /**
180
+ * Clear a component instance's report. Called on unmount so the
181
+ * per-section max shrinks when media elements are removed.
182
+ */
183
+ export function clearSectionDuration(sectionIndex: number, instanceId: string) {
184
+ egakiStore.setState((state) => {
185
+ const existing = state.sectionReports.get(sectionIndex)
186
+ if (!existing || !existing.has(instanceId)) return state
187
+ const newReports = new Map(state.sectionReports)
188
+ const sectionMap = new Map(existing)
189
+ sectionMap.delete(instanceId)
190
+ if (sectionMap.size === 0) {
191
+ newReports.delete(sectionIndex)
192
+ } else {
193
+ newReports.set(sectionIndex, sectionMap)
194
+ }
195
+ return { sectionReports: newReports }
196
+ })
197
+ }
198
+
199
+ /**
200
+ * Reset all section duration reports. Called when the MDX composition
201
+ * changes (HMR, MDX edit, module update) so stale reports don't persist.
202
+ * Does NOT clear the raw src cache (localStorage).
203
+ */
204
+ export function resetSectionDurations() {
205
+ if (egakiStore.getState().sectionReports.size === 0) return
206
+ egakiStore.setState({ sectionReports: new Map() })
207
+ }
208
+
209
+ /**
210
+ * Returns the count of null-duration sections that have no reports yet.
211
+ * Used by the export button to block export until all media durations
212
+ * are resolved.
213
+ */
214
+ export function countUnresolvedSections(
215
+ sections: { durationInFrames: number | null }[],
216
+ ): number {
217
+ const { sectionReports } = egakiStore.getState()
218
+ let count = 0
219
+ for (let i = 0; i < sections.length; i++) {
220
+ if (sections[i]!.durationInFrames !== null) continue
221
+ const reports = sectionReports.get(i)
222
+ if (!reports || reports.size === 0) count++
223
+ }
224
+ return count
225
+ }