egaki 0.6.0 → 0.7.1

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 +145 -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 +539 -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 +585 -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,1107 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Client component: Remotion Player wrapper + MP4 export UI.
5
+ *
6
+ * Receives pre-rendered JSX sections from the server (via RSC flight).
7
+ * All MDX processing (parsing, module resolution, safe-mdx rendering)
8
+ * is done server-side in app.tsx. This component only handles:
9
+ * - Wrapping sections in Remotion's Series/Sequence composition
10
+ * - Rendering the Player
11
+ * - MP4 export via WebCodecs
12
+ */
13
+
14
+ import './styles.css'
15
+ import { Player, type PlayerRef } from '@remotion/player'
16
+ import React, { Suspense, useCallback, useContext, useEffect, useLayoutEffect, useRef, useSyncExternalStore, useState, type ReactNode } from 'react'
17
+ import {
18
+ AbsoluteFill,
19
+ Freeze,
20
+ Sequence,
21
+ Series,
22
+ useCurrentFrame,
23
+ useDelayRender,
24
+ useVideoConfig,
25
+ } from 'remotion'
26
+ import { renderInBrowser } from './render-client.ts'
27
+ import { createSpiceflowFetch } from 'spiceflow/client'
28
+ import type { app } from './app.tsx'
29
+ import { egakiStore } from './store.ts'
30
+ import { useGenerationStatus, useGenerationErrors, type GenerationStatus } from './store-hooks.ts'
31
+ import { egakiSDK } from './sdk.ts'
32
+ import { LayoutEditor, type SectionMeta } from './layout-editor.tsx'
33
+ import { TweakpaneRoot, SIDEBAR_WIDTH } from './tweakpane-hook.tsx'
34
+ import {
35
+ Fill,
36
+ LayoutAnimationLayer,
37
+ LayoutGhost,
38
+ LayoutTransitionProvider,
39
+ MotionTimingSync,
40
+ ServerSlotsContext,
41
+ } from './mdx-video.tsx'
42
+ import { SectionIndexContext } from './media-duration-store.ts'
43
+ import type { VideoFrontmatter } from './mdx-parse.ts'
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // HMR scene-changed: store pending seek at module scope so it survives
47
+ // component remounts caused by rsc:update (spiceflow payload swap).
48
+ // The vite plugin sends egaki:scene-changed with { sectionIndex } when
49
+ // the user edits a specific section of the entry MDX.
50
+ // ---------------------------------------------------------------------------
51
+ let pendingSceneSeek: { sectionIndex: number; file: string } | null = null
52
+ /** Cancels the auto-pause listener from a previous scene-seek, so a new
53
+ * HMR during playback doesn't leave stale listeners that pause the wrong scene. */
54
+ let cancelPreviousAutoPlay: (() => void) | null = null
55
+ /** Monotonic counter to invalidate stale requestAnimationFrame callbacks
56
+ * when rapid successive HMR edits arrive before the first rAF fires. */
57
+ let sceneSeekVersion = 0
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Generation progress client — fetches /api/generation-progress on page
61
+ // load and on every rsc:update using the typed Spiceflow fetch client.
62
+ // The route is an async generator that yields progress events as SSE;
63
+ // createSpiceflowFetch returns an AsyncGenerator we consume with for-await.
64
+ // Max one connection at a time; new rsc:update aborts the previous one.
65
+ // ---------------------------------------------------------------------------
66
+
67
+ let progressAbortController: AbortController | null = null
68
+
69
+ function connectToProgress() {
70
+ if (typeof window === 'undefined') return
71
+ progressAbortController?.abort()
72
+ const controller = new AbortController()
73
+ progressAbortController = controller
74
+
75
+ // Lazy-init: createSpiceflowFetch needs window.location.origin which
76
+ // is only available in the browser, not during SSR.
77
+ const safeFetch = createSpiceflowFetch<typeof app>(window.location.origin)
78
+
79
+ ;(async () => {
80
+ const result = await safeFetch('/api/generation-progress', {
81
+ signal: controller.signal,
82
+ })
83
+ if (result instanceof Error) return
84
+
85
+ // result is an AsyncGenerator<GenerationProgressEvent> from the streaming route
86
+ try {
87
+ for await (const event of result) {
88
+ // Guard: if a newer connection replaced us, stop writing state
89
+ if (progressAbortController !== controller) return
90
+
91
+ // Log generation errors to the console and show them in the toolbar
92
+ if (event.errors?.length) {
93
+ for (const err of event.errors) {
94
+ console.error(`[egaki] ${err.namespace} generation failed (${Math.round(err.durationMs / 1000)}s): ${err.error}`)
95
+ }
96
+ // Append new errors with unique IDs and auto-clear them after 8 seconds
97
+ const errorIds = new Set<string>()
98
+ const tagged = event.errors.map((e) => {
99
+ const id = `${e.key}:${Date.now()}:${Math.random()}`
100
+ errorIds.add(id)
101
+ return { ...e, _id: id }
102
+ })
103
+ const currentErrors = egakiStore.getState().serverGenerationErrors
104
+ egakiStore.setState({ serverGenerationErrors: [...currentErrors, ...tagged] })
105
+ setTimeout(() => {
106
+ egakiStore.setState({
107
+ serverGenerationErrors: egakiStore.getState().serverGenerationErrors.filter(
108
+ (e) => !errorIds.has((e as any)._id),
109
+ ),
110
+ })
111
+ }, 8000)
112
+ }
113
+ const summary = event.summary
114
+ egakiStore.setState({
115
+ serverGenerationStatus: summary.total > 0 ? summary : null,
116
+ serverGenerationEntries: event.generations,
117
+ })
118
+ if (event.done) break
119
+ }
120
+ } catch {
121
+ // Aborted or network error — non-fatal
122
+ }
123
+ // Guard: only clear state if we're still the active connection
124
+ if (progressAbortController === controller) {
125
+ egakiStore.setState({ serverGenerationStatus: null, serverGenerationEntries: [] })
126
+ }
127
+ })()
128
+ }
129
+
130
+ // Connect on initial page load (guarded for SSR)
131
+ if (typeof window !== 'undefined') connectToProgress()
132
+
133
+ if (import.meta.hot) {
134
+ import.meta.hot.on('egaki:scene-changed', (data: { sectionIndex: number; file: string }) => {
135
+ pendingSceneSeek = data
136
+ })
137
+ // Reconnect on rsc:update — new generations may have started from
138
+ // MDX edits that trigger server-side rendering of <Server> blocks.
139
+ import.meta.hot.on('rsc:update', () => {
140
+ // Small delay to let the server start processing before we connect,
141
+ // so the first SSE event already includes the new generations.
142
+ setTimeout(connectToProgress, 100)
143
+ })
144
+ }
145
+
146
+ // Module-level stable callbacks for useSyncExternalStore (never re-subscribes)
147
+ const subscribeNoop = () => () => {}
148
+ const getClientMounted = () => true
149
+ const getServerMounted = () => false
150
+
151
+ const PLAYBACK_RATES = [0.5, 1, 1.5, 2, 4, 8] as const
152
+
153
+ function ToolbarSeparator() {
154
+ return <div className='w-px h-4 bg-white/15' />
155
+ }
156
+
157
+ function DownloadIcon() {
158
+ return (
159
+ <svg width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'>
160
+ <path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' />
161
+ <polyline points='7 10 12 15 17 10' />
162
+ <line x1='12' y1='15' x2='12' y2='3' />
163
+ </svg>
164
+ )
165
+ }
166
+
167
+ function XIcon() {
168
+ return (
169
+ <svg width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'>
170
+ <line x1='18' y1='6' x2='6' y2='18' />
171
+ <line x1='6' y1='6' x2='18' y2='18' />
172
+ </svg>
173
+ )
174
+ }
175
+
176
+ function ChevronUpIcon() {
177
+ return (
178
+ <svg width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'>
179
+ <polyline points='18 15 12 9 6 15' />
180
+ </svg>
181
+ )
182
+ }
183
+
184
+ function ChevronDownIcon() {
185
+ return (
186
+ <svg width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'>
187
+ <polyline points='6 9 12 15 18 9' />
188
+ </svg>
189
+ )
190
+ }
191
+
192
+ function SpinnerIcon() {
193
+ return (
194
+ <svg
195
+ width='14'
196
+ height='14'
197
+ viewBox='0 0 24 24'
198
+ fill='none'
199
+ stroke='currentColor'
200
+ strokeWidth='2'
201
+ strokeLinecap='round'
202
+ strokeLinejoin='round'
203
+ className='animate-spin'
204
+ >
205
+ <path d='M21 12a9 9 0 1 1-6.219-8.56' />
206
+ </svg>
207
+ )
208
+ }
209
+
210
+ function formatGenerationStatus(status: GenerationStatus): string {
211
+ const parts: string[] = []
212
+ for (const [namespace, count] of Object.entries(status.counts)) {
213
+ if (count <= 0) continue
214
+ // Pluralize: add 's' for most, 'es' for speech
215
+ const plural = count > 1 ? (namespace === 'speech' ? 'es' : 's') : ''
216
+ parts.push(`${count} ${namespace}${plural}`)
217
+ }
218
+ return `Generating ${parts.join(', ')}`
219
+ }
220
+
221
+ /**
222
+ * Remotion-aware Suspense fallback. When a section suspends (throws a promise),
223
+ * this component calls delayRender() to prevent Remotion from taking a
224
+ * screenshot of the incomplete frame. When the suspended component resolves
225
+ * and this fallback unmounts, continueRender() fires and rendering proceeds.
226
+ *
227
+ * This is the same pattern Remotion uses internally in <Composition> (see
228
+ * packages/core/src/Composition.tsx in the Remotion source).
229
+ */
230
+ function SuspenseFallback() {
231
+ const { delayRender, continueRender } = useDelayRender()
232
+ // useLayoutEffect registers the delay handle before paint, preventing
233
+ // the renderer from capturing a frame before the handle exists.
234
+ useLayoutEffect(() => {
235
+ const handle = delayRender('Waiting for section to unsuspend', {
236
+ timeoutInMilliseconds: 10 * 60 * 1000,
237
+ })
238
+ return () => continueRender(handle)
239
+ }, [delayRender, continueRender])
240
+
241
+ // delayRender is a no-op in the Player, so this loading UI is only
242
+ // visible during preview. During export, rendering pauses until the
243
+ // suspended component resolves, then this fallback unmounts before
244
+ // the screenshot is taken.
245
+ return (
246
+ <AbsoluteFill
247
+ style={{
248
+ display: 'flex',
249
+ alignItems: 'center',
250
+ justifyContent: 'center',
251
+ background: '#050505',
252
+ }}
253
+ >
254
+ <span
255
+ style={{
256
+ fontSize: 48,
257
+ fontWeight: 500,
258
+ color: '#52525b',
259
+ fontFamily:
260
+ '-apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif',
261
+ letterSpacing: '-0.01em',
262
+ }}
263
+ >
264
+ Loading…
265
+ </span>
266
+ </AbsoluteFill>
267
+ )
268
+ }
269
+
270
+ interface SectionProps {
271
+ heading: string | null
272
+ durationInFrames: number
273
+ jsx: ReactNode
274
+ }
275
+
276
+ // Shared by the visible section content and the hidden ghost copy of the
277
+ // previous section — element positions only match across the two containers
278
+ // if both use the exact same layout styles.
279
+ const SECTION_CONTENT_STYLE: React.CSSProperties = {
280
+ zIndex: 1,
281
+ gap: 'clamp(1rem, 2vw, 2.5rem)',
282
+ // Force Chrome GPU compositing for subpixel text rendering.
283
+ // Without this, Chrome snaps text positions to whole pixels
284
+ // causing visible stutter on slow translate/scale animations.
285
+ // Not supported by the web-renderer canvas export, but the
286
+ // canvas renderer doesn't have Chrome's pixel snapping issue.
287
+ // See: https://remotion.dev/docs/troubleshooting/subpixel-rendering
288
+ perspective: '1000px',
289
+ willChange: 'transform',
290
+ // Grayscale antialiasing instead of subpixel. Subpixel rendering adds
291
+ // RGB color fringing at text edges which creates shimmering artifacts
292
+ // when text moves or scales in video. Grayscale is also consistent
293
+ // across platforms (subpixel varies by OS and display).
294
+ WebkitFontSmoothing: 'antialiased',
295
+ MozOsxFontSmoothing: 'grayscale',
296
+ } as React.CSSProperties
297
+
298
+ // How long (in seconds) the hidden ghost copy of the previous section stays
299
+ // mounted at the start of a section. LayoutTransition springs (default 20
300
+ // frames) settle well within this window. A constant is used because
301
+ // per-element durations are only known after children render — the ghost
302
+ // mount decision happens before that.
303
+ const GHOST_WINDOW_SECONDS = 3
304
+
305
+ // How many seconds before a section cut to premount the next section.
306
+ // Remotion's premountFor renders the next sequence early (hidden) so
307
+ // <Video> elements start loading before the cut, preventing stutter.
308
+ const PREMOUNT_SECONDS = 1
309
+
310
+ /**
311
+ * Section content wrapper enabling <LayoutTransition> FLIP animations
312
+ * across section boundaries.
313
+ *
314
+ * Renders the PREVIOUS section in a hidden ghost container, pinned at its
315
+ * last frame via <Freeze>, so LayoutAnimationLayer can measure where each
316
+ * LayoutTransition element was when the viewer last saw it. Seek-safe: the
317
+ * ghost is derived purely from the current frame, never from playback order.
318
+ *
319
+ * Known limitation: the ghost's useVideoConfig().durationInFrames is clamped
320
+ * by the CURRENT section's Series.Sequence (Remotion Sequences take
321
+ * min(parent, own) duration). Exit animations in the previous section that
322
+ * depend on durationInFrames measure slightly off when the current section
323
+ * is shorter than the previous one. Position measurement is unaffected for
324
+ * static layouts.
325
+ */
326
+ function SectionWithLayoutTransition({
327
+ jsx,
328
+ prevJsx,
329
+ prevDurationInFrames,
330
+ }: {
331
+ jsx: ReactNode
332
+ prevJsx: ReactNode | null
333
+ prevDurationInFrames: number
334
+ }) {
335
+ const frame = useCurrentFrame()
336
+ const { fps } = useVideoConfig()
337
+ const showGhost =
338
+ prevJsx != null && frame < fps * GHOST_WINDOW_SECONDS
339
+
340
+ return (
341
+ <LayoutTransitionProvider>
342
+ <AbsoluteFill style={{ background: 'transparent' }}>
343
+ {showGhost ? (
344
+ // Ghost: hidden but laid out (visibility:hidden keeps geometry).
345
+ // opacity:0 is added for web-renderer safety, and the ghost comes
346
+ // FIRST in DOM order so the visible section paints over it even
347
+ // if a renderer ignores visibility (no z-index in web-renderer).
348
+ <AbsoluteFill
349
+ style={{ visibility: 'hidden', opacity: 0, pointerEvents: 'none' }}
350
+ aria-hidden
351
+ >
352
+ <LayoutGhost>
353
+ <Freeze frame={prevDurationInFrames - 1}>
354
+ <Sequence
355
+ from={0}
356
+ durationInFrames={prevDurationInFrames}
357
+ layout="none"
358
+ showInTimeline={false}
359
+ >
360
+ <Fill style={SECTION_CONTENT_STYLE}>
361
+ {prevJsx}
362
+ </Fill>
363
+ </Sequence>
364
+ </Freeze>
365
+ </LayoutGhost>
366
+ </AbsoluteFill>
367
+ ) : null}
368
+ <MotionTimingSync>
369
+ <Fill style={SECTION_CONTENT_STYLE}>{jsx}</Fill>
370
+ </MotionTimingSync>
371
+ <LayoutAnimationLayer />
372
+ </AbsoluteFill>
373
+ </LayoutTransitionProvider>
374
+ )
375
+ }
376
+
377
+ function VideoComposition({
378
+ sections,
379
+ totalDuration,
380
+ preamble,
381
+ }: {
382
+ sections: SectionProps[]
383
+ totalDuration: number
384
+ preamble?: ReactNode
385
+ }) {
386
+ const { fps } = useVideoConfig()
387
+ const premountFrames = Math.round(fps * PREMOUNT_SECONDS)
388
+
389
+ return (
390
+ <AbsoluteFill style={{ background: '#050505', fontSize: 60 }}>
391
+ {/* Preamble: MDX content before the first heading. Rendered at
392
+ composition level so it persists across all sections. Runs in the
393
+ background behind the Series (earlier DOM order = behind). */}
394
+ <MotionTimingSync>{preamble}</MotionTimingSync>
395
+ {/* Sequential sections */}
396
+ <Series>
397
+ {sections.map((section, i) => (
398
+ <Series.Sequence
399
+ key={i}
400
+ durationInFrames={section.durationInFrames}
401
+ // @ts-ignore — name prop exists on Series.Sequence
402
+ name={section.heading || `Section ${i}`}
403
+ // Premount the next section N seconds early so <Video> elements
404
+ // start loading before the cut, preventing playback stutter.
405
+ // Remotion renders the premounted section hidden; it receives
406
+ // its own correct frame context (starting at frame 0).
407
+ premountFor={premountFrames}
408
+ >
409
+ <SectionIndexContext.Provider value={i}>
410
+ <Suspense fallback={<SuspenseFallback />}>
411
+ {/* Background components inside jsx self-position as AbsoluteFill
412
+ layers behind content via DOM order (rendered first = behind). */}
413
+ <SectionWithLayoutTransition
414
+ jsx={section.jsx}
415
+ prevJsx={i > 0 ? sections[i - 1]!.jsx : null}
416
+ prevDurationInFrames={i > 0 ? sections[i - 1]!.durationInFrames : 0}
417
+ />
418
+ </Suspense>
419
+ </SectionIndexContext.Provider>
420
+ </Series.Sequence>
421
+ ))}
422
+ </Series>
423
+ </AbsoluteFill>
424
+ )
425
+ }
426
+
427
+ export function PlayerPage({
428
+ sections,
429
+ totalDuration,
430
+ preamble,
431
+ entryPath,
432
+ hasUnresolvedDurations = false,
433
+ frontmatter,
434
+ availableEntries = [],
435
+ currentRoute = '',
436
+ }: {
437
+ sections: SectionProps[]
438
+ totalDuration: number
439
+ preamble?: ReactNode
440
+ /** Absolute path of the MDX entry file, included in copy prompts. */
441
+ entryPath: string
442
+ /** True when auto-duration sections haven't been visited yet (media
443
+ * durations still unknown). Gates the export button. */
444
+ hasUnresolvedDurations?: boolean
445
+ frontmatter: VideoFrontmatter
446
+ /** All available MDX entry route paths for navigation. */
447
+ availableEntries?: string[]
448
+ /** Current route path ('' for default entry). */
449
+ currentRoute?: string
450
+ }) {
451
+ const { fps, width, height, scale } = frontmatter
452
+ const serverSlots = useContext(ServerSlotsContext)
453
+ // Stable component function that reads latest props from a ref.
454
+ // Created once so its identity never changes between renders.
455
+ // Remotion Player doesn't remount when component identity is stable.
456
+ const propsRef = useRef({ sections, totalDuration, preamble, serverSlots })
457
+ propsRef.current = { sections, totalDuration, preamble, serverSlots }
458
+
459
+ // Wrap VideoComposition with ServerSlotsContext so that <Server> slots
460
+ // are available during SDK export/screenshot (renderStillOnWeb /
461
+ // renderMediaOnWeb create a fresh React tree from this component).
462
+ const [Component] = useState(() => () => (
463
+ <ServerSlotsContext.Provider value={propsRef.current.serverSlots}>
464
+ <VideoComposition {...propsRef.current} />
465
+ </ServerSlotsContext.Provider>
466
+ ))
467
+
468
+ const playerRef = useRef<PlayerRef>(null)
469
+ const playerContainerRef = useRef<HTMLDivElement>(null)
470
+
471
+ // Register the composition with the SDK so agents can call
472
+ // window.egakiSDK.seekTo() / .screenshot() / .export() via Playwriter.
473
+ useEffect(() => {
474
+ egakiSDK.register({
475
+ component: Component,
476
+ totalDuration,
477
+ fps,
478
+ width,
479
+ height,
480
+ scale,
481
+ sectionCount: sections.length,
482
+ sections: sections.map((s) => ({
483
+ heading: s.heading,
484
+ durationInFrames: s.durationInFrames,
485
+ })),
486
+ playerRef,
487
+ playerContainerRef,
488
+ })
489
+ }, [Component, totalDuration, sections, fps, width, height, scale])
490
+
491
+ // Consume pending scene seek after the RSC refetch delivers new sections.
492
+ // The module-level pendingSceneSeek is set by import.meta.hot.on before
493
+ // React re-renders; this effect fires after the commit with fresh data.
494
+ // Seeks to the changed section's start frame, plays it, and pauses at
495
+ // the section's end so the user sees exactly the content they edited.
496
+ useEffect(() => {
497
+ if (pendingSceneSeek == null) return
498
+ // Ignore scene changes from a different entry MDX file.
499
+ if (pendingSceneSeek.file !== entryPath) {
500
+ pendingSceneSeek = null
501
+ return
502
+ }
503
+ const idx = pendingSceneSeek.sectionIndex
504
+ pendingSceneSeek = null
505
+
506
+ // Wait one frame for the Remotion Player to initialize after remount.
507
+ // Version token ensures stale rAF callbacks from rapid edits are ignored.
508
+ const version = ++sceneSeekVersion
509
+ requestAnimationFrame(() => {
510
+ if (version !== sceneSeekVersion) return
511
+ const player = playerRef.current
512
+ if (!player || idx < 0 || idx >= sections.length) return
513
+
514
+ // Compute section boundaries from current sections
515
+ let startFrame = 0
516
+ for (let i = 0; i < idx; i++) {
517
+ startFrame += sections[i]!.durationInFrames
518
+ }
519
+ const endFrame = startFrame + sections[idx]!.durationInFrames - 1
520
+
521
+ player.seekTo(startFrame)
522
+
523
+ // Cancel any auto-pause from a previous HMR scene-seek so stale
524
+ // listeners don't pause playback in the wrong section.
525
+ cancelPreviousAutoPlay?.()
526
+ cancelPreviousAutoPlay = null
527
+
528
+ // Only auto-play if currently paused (after rsc:update remount
529
+ // the player always starts paused at frame 0)
530
+ if (!player.isPlaying()) {
531
+ player.play()
532
+
533
+ // Pause at section end so only the edited section plays.
534
+ // If the user manually seeks (clicks timeline, arrow keys, etc.)
535
+ // cancel the auto-pause so we don't randomly stop playback later.
536
+ let cancelled = false
537
+ const cleanup = () => {
538
+ if (cancelled) return
539
+ cancelled = true
540
+ cancelPreviousAutoPlay = null
541
+ player.removeEventListener('frameupdate', onFrame as any)
542
+ player.removeEventListener('seeked', onSeek as any)
543
+ }
544
+ cancelPreviousAutoPlay = cleanup
545
+ const onFrame = () => {
546
+ if (cancelled) return
547
+ if (player.getCurrentFrame() >= endFrame) {
548
+ player.pause()
549
+ cleanup()
550
+ }
551
+ }
552
+ const onSeek = (e: any) => {
553
+ // User seeked away — cancel auto-pause. Ignore seeks within
554
+ // the target section range (could be our own seekTo settling).
555
+ const frame = e?.detail?.frame ?? player.getCurrentFrame()
556
+ if (frame < startFrame || frame > endFrame) {
557
+ cleanup()
558
+ }
559
+ }
560
+ player.addEventListener('frameupdate', onFrame as any)
561
+ player.addEventListener('seeked', onSeek as any)
562
+ }
563
+ })
564
+ }, [sections])
565
+
566
+ // Defer Player mount to client only. Remotion's Player and all composition
567
+ // components (Series, AbsoluteFill, Audio, Video) use React context provided
568
+ // by Player at runtime. During SSR there's no Remotion context, so hooks like
569
+ // useCurrentFrame/useVideoConfig crash with "Cannot read properties of null
570
+ // (reading 'useContext')". useSyncExternalStore returns false on the server
571
+ // and true on the client synchronously during hydration (no useEffect tick).
572
+ const mounted = useSyncExternalStore(subscribeNoop, getClientMounted, getServerMounted)
573
+
574
+ const generationStatus = useGenerationStatus()
575
+ const generationErrors = useGenerationErrors()
576
+
577
+ const [editing, setEditing] = useState(false)
578
+ const [resetKey, setResetKey] = useState(0)
579
+ const [rendering, setRendering] = useState(false)
580
+ const [progress, setProgress] = useState(0)
581
+ const abortRef = useRef<AbortController | null>(null)
582
+ const [playbackRate, setPlaybackRate] = useState(1)
583
+ const [currentFrame, setCurrentFrame] = useState(0)
584
+
585
+ // Track current frame for scene navigation button states
586
+ useEffect(() => {
587
+ const player = playerRef.current
588
+ if (!player) return
589
+ const onFrame = () => setCurrentFrame(player.getCurrentFrame())
590
+ // Remotion Player fires 'frameupdate' on seek and during playback
591
+ player.addEventListener('frameupdate', onFrame as any)
592
+ return () => player.removeEventListener('frameupdate', onFrame as any)
593
+ }, [mounted])
594
+
595
+ // Precompute section start frames for navigation
596
+ const sectionStarts: number[] = []
597
+ {
598
+ let acc = 0
599
+ for (const s of sections) {
600
+ sectionStarts.push(acc)
601
+ acc += s.durationInFrames
602
+ }
603
+ }
604
+
605
+ // Determine which section the current frame is in
606
+ let currentSectionIdx = 0
607
+ for (let i = sectionStarts.length - 1; i >= 0; i--) {
608
+ if (currentFrame >= sectionStarts[i]!) {
609
+ currentSectionIdx = i
610
+ break
611
+ }
612
+ }
613
+ const hasPrevScene = currentSectionIdx > 0 || currentFrame > 0
614
+ const hasNextScene = currentSectionIdx < sections.length - 1
615
+
616
+ // Single tap: seek to start of current section.
617
+ // Double tap (within 400ms): jump to previous section start. Each
618
+ // additional rapid tap keeps going one section further back.
619
+ const lastPrevTapRef = useRef<{ time: number; targetFrame: number }>({ time: 0, targetFrame: -1 })
620
+
621
+ const goToPrevScene = useCallback(() => {
622
+ const player = playerRef.current
623
+ if (!player) return
624
+ const wasPlaying = player.isPlaying()
625
+ if (wasPlaying) player.pause()
626
+ const frame = player.getCurrentFrame()
627
+
628
+ // Find the start of the current section (largest sectionStart <= frame)
629
+ let currentStart = 0
630
+ for (let i = sectionStarts.length - 1; i >= 0; i--) {
631
+ if (sectionStarts[i]! <= frame) {
632
+ currentStart = sectionStarts[i]!
633
+ break
634
+ }
635
+ }
636
+
637
+ const now = Date.now()
638
+ const last = lastPrevTapRef.current
639
+
640
+ // Double-tap: if pressed again within 400ms, go to the section before
641
+ // the last target (so repeated taps keep stepping backwards)
642
+ if (now - last.time < 400) {
643
+ let prevTarget = 0
644
+ for (let i = sectionStarts.length - 1; i >= 0; i--) {
645
+ if (sectionStarts[i]! < last.targetFrame) {
646
+ prevTarget = sectionStarts[i]!
647
+ break
648
+ }
649
+ }
650
+ lastPrevTapRef.current = { time: now, targetFrame: prevTarget }
651
+ player.seekTo(prevTarget)
652
+ } else {
653
+ // Single tap: go to start of current section
654
+ lastPrevTapRef.current = { time: now, targetFrame: currentStart }
655
+ player.seekTo(currentStart)
656
+ }
657
+
658
+ if (wasPlaying) player.play()
659
+ }, [sectionStarts])
660
+
661
+ const goToNextScene = useCallback(() => {
662
+ const player = playerRef.current
663
+ if (!player) return
664
+ const wasPlaying = player.isPlaying()
665
+ if (wasPlaying) player.pause()
666
+ const frame = player.getCurrentFrame()
667
+ let target = totalDuration - 1
668
+ for (const start of sectionStarts) {
669
+ if (start > frame) {
670
+ target = start
671
+ break
672
+ }
673
+ }
674
+ player.seekTo(target)
675
+ if (wasPlaying) player.play()
676
+ }, [sectionStarts, totalDuration])
677
+
678
+ // Sync playbackRate state when user changes it via the gear menu
679
+ useEffect(() => {
680
+ const player = playerRef.current
681
+ if (!player) return
682
+ const onRateChange: EventListener = (e: any) => {
683
+ setPlaybackRate(e.detail?.playbackRate ?? 1)
684
+ }
685
+ player.addEventListener('ratechange', onRateChange as any)
686
+ return () => player.removeEventListener('ratechange', onRateChange as any)
687
+ }, [mounted])
688
+
689
+ // Keyboard shortcuts — modeled after After Effects / Premiere / DaVinci.
690
+ // Disabled when the layout editor is active (it may use arrow keys).
691
+ //
692
+ // Space play/pause
693
+ // Left/Right ±1 frame
694
+ // Shift+Left/Right ±10 frames
695
+ // Down/Up section start (×2 = prev) / next section
696
+ // , / . ±1 frame (Premiere convention)
697
+ // Home / End first / last frame
698
+ // J / K / L rewind 2s / pause / play (L×2=2x, ×3=4x, ×4=8x)
699
+ // 0-9 jump to 0%-90% of timeline
700
+ useEffect(() => {
701
+ if (editing) return
702
+
703
+ const clamp = (frame: number) => Math.max(0, Math.min(totalDuration - 1, frame))
704
+
705
+ const onKeyDown = (e: KeyboardEvent) => {
706
+ const player = playerRef.current
707
+ if (!player) return
708
+ // Ignore when focus is on an input/textarea/contenteditable
709
+ const tag = (e.target as HTMLElement)?.tagName
710
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return
711
+
712
+ const frame = player.getCurrentFrame()
713
+
714
+ // --- Frame stepping ---
715
+ if (e.key === 'ArrowLeft' && !e.shiftKey) {
716
+ e.preventDefault()
717
+ if (player.isPlaying()) player.pause()
718
+ player.seekTo(clamp(frame - 1))
719
+ return
720
+ }
721
+ if (e.key === 'ArrowRight' && !e.shiftKey) {
722
+ e.preventDefault()
723
+ if (player.isPlaying()) player.pause()
724
+ player.seekTo(clamp(frame + 1))
725
+ return
726
+ }
727
+ if (e.key === ',') {
728
+ e.preventDefault()
729
+ if (player.isPlaying()) player.pause()
730
+ player.seekTo(clamp(frame - 1))
731
+ return
732
+ }
733
+ if (e.key === '.') {
734
+ e.preventDefault()
735
+ if (player.isPlaying()) player.pause()
736
+ player.seekTo(clamp(frame + 1))
737
+ return
738
+ }
739
+
740
+ // --- 10-frame jump (Shift+Arrow) ---
741
+ if (e.key === 'ArrowLeft' && e.shiftKey) {
742
+ e.preventDefault()
743
+ if (player.isPlaying()) player.pause()
744
+ player.seekTo(clamp(frame - 10))
745
+ return
746
+ }
747
+ if (e.key === 'ArrowRight' && e.shiftKey) {
748
+ e.preventDefault()
749
+ if (player.isPlaying()) player.pause()
750
+ player.seekTo(clamp(frame + 10))
751
+ return
752
+ }
753
+
754
+ // --- Section navigation (Down/Up) — delegates to goToPrev/NextScene
755
+ // which preserve playback state and support double-tap prev ---
756
+ if (e.key === 'ArrowDown') {
757
+ e.preventDefault()
758
+ goToPrevScene()
759
+ return
760
+ }
761
+ if (e.key === 'ArrowUp') {
762
+ e.preventDefault()
763
+ goToNextScene()
764
+ return
765
+ }
766
+
767
+ // --- Space: play/pause ---
768
+ if (e.key === ' ') {
769
+ e.preventDefault()
770
+ if (player.isPlaying()) player.pause()
771
+ else player.play()
772
+ return
773
+ }
774
+
775
+ // --- Home / End ---
776
+ if (e.key === 'Home') {
777
+ e.preventDefault()
778
+ if (player.isPlaying()) player.pause()
779
+ player.seekTo(0)
780
+ return
781
+ }
782
+ if (e.key === 'End') {
783
+ e.preventDefault()
784
+ if (player.isPlaying()) player.pause()
785
+ player.seekTo(totalDuration - 1)
786
+ return
787
+ }
788
+
789
+ // --- J / K / L (NLE transport) ---
790
+ // L = play, tap again to double speed (1→2→4→8 cap)
791
+ // K = pause, reset rate to 1
792
+ // J = rewind 2 seconds (reverse playback is unreliable in browsers)
793
+ if (e.key === 'l' || e.key === 'L') {
794
+ e.preventDefault()
795
+ if (!player.isPlaying()) {
796
+ setPlaybackRate(1)
797
+ player.play()
798
+ } else {
799
+ // Cycle to next rate in the list
800
+ setPlaybackRate((prev) => {
801
+ const idx = PLAYBACK_RATES.indexOf(prev as any)
802
+ return PLAYBACK_RATES[Math.min((idx === -1 ? 1 : idx) + 1, PLAYBACK_RATES.length - 1)]!
803
+ })
804
+ }
805
+ return
806
+ }
807
+ if (e.key === 'k' || e.key === 'K') {
808
+ e.preventDefault()
809
+ player.pause()
810
+ setPlaybackRate(1)
811
+ return
812
+ }
813
+ if (e.key === 'j' || e.key === 'J') {
814
+ e.preventDefault()
815
+ // Seek backward 2 seconds
816
+ if (player.isPlaying()) player.pause()
817
+ player.seekTo(clamp(frame - Math.round(fps * 2)))
818
+ return
819
+ }
820
+
821
+ // --- 0-9: jump to percentage of timeline ---
822
+ if (e.key >= '0' && e.key <= '9' && !e.metaKey && !e.ctrlKey && !e.altKey) {
823
+ e.preventDefault()
824
+ const pct = parseInt(e.key, 10) / 10
825
+ player.seekTo(Math.floor(totalDuration * pct))
826
+ return
827
+ }
828
+ }
829
+ window.addEventListener('keydown', onKeyDown)
830
+ return () => window.removeEventListener('keydown', onKeyDown)
831
+ }, [editing, totalDuration, sections, goToPrevScene, goToNextScene, fps])
832
+
833
+ const handleExport = useCallback(async () => {
834
+ setRendering(true)
835
+ setProgress(0)
836
+ const controller = new AbortController()
837
+ abortRef.current = controller
838
+
839
+ try {
840
+ const blob = await renderInBrowser({
841
+ component: Component,
842
+ durationInFrames: totalDuration,
843
+ fps,
844
+ width,
845
+ height,
846
+ scale,
847
+ onProgress: (p) => setProgress(p),
848
+ signal: controller.signal,
849
+ })
850
+
851
+ const url = URL.createObjectURL(blob)
852
+ const a = document.createElement('a')
853
+ a.href = url
854
+ a.download = 'video.mp4'
855
+ a.click()
856
+ URL.revokeObjectURL(url)
857
+ } catch (err) {
858
+ if ((err as Error).message?.includes('cancelled')) {
859
+ console.log('Export cancelled')
860
+ } else {
861
+ console.error('Export failed:', err)
862
+ }
863
+ } finally {
864
+ setRendering(false)
865
+ abortRef.current = null
866
+ }
867
+ }, [Component, totalDuration, fps, width, height, scale])
868
+
869
+ const handleCancel = useCallback(() => {
870
+ abortRef.current?.abort()
871
+ }, [])
872
+
873
+ return (
874
+ <div className='flex gap-3 min-h-screen bg-black'>
875
+ {/* Main area — player centered vertically, takes remaining space */}
876
+ <div
877
+ className='flex flex-col items-center justify-center flex-1 min-w-0'
878
+ style={{ paddingBottom: 96, paddingTop: 24 }}
879
+ >
880
+ {/* Player — fills available width, but capped so the aspect ratio height
881
+ never exceeds the viewport height minus toolbar space (bottom-6 padding +
882
+ toolbar height ≈ 96px). */}
883
+ <div
884
+ ref={playerContainerRef}
885
+ style={{ maxWidth: `calc((100vh - 120px) * ${width / height})` }}
886
+ className='w-full overflow-hidden'
887
+ >
888
+ {mounted ? (
889
+ <Player
890
+ key={resetKey}
891
+ ref={playerRef}
892
+ component={Component}
893
+ durationInFrames={totalDuration}
894
+ fps={fps}
895
+ compositionWidth={width}
896
+ compositionHeight={height}
897
+ loop
898
+ controls
899
+ clickToPlay={!editing}
900
+ spaceKeyToPlayOrPause
901
+ playbackRate={playbackRate}
902
+ style={{ width: '100%' }}
903
+ errorFallback={({ error }) => (
904
+ <AbsoluteFill
905
+ style={{
906
+ display: 'flex',
907
+ alignItems: 'center',
908
+ justifyContent: 'center',
909
+ background: '#050505',
910
+ padding: '5% 8%',
911
+ }}
912
+ >
913
+ <div
914
+ style={{
915
+ display: 'flex',
916
+ flexDirection: 'column',
917
+ alignItems: 'center',
918
+ gap: '1rem',
919
+ maxWidth: '80%',
920
+ textAlign: 'center',
921
+ }}
922
+ >
923
+ <span
924
+ style={{
925
+ fontSize: 48,
926
+ fontWeight: 600,
927
+ color: '#ef4444',
928
+ fontFamily:
929
+ '-apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif',
930
+ letterSpacing: '-0.02em',
931
+ }}
932
+ >
933
+ Render Error
934
+ </span>
935
+ <pre
936
+ style={{
937
+ fontSize: 14,
938
+ fontWeight: 400,
939
+ color: '#a1a1aa',
940
+ fontFamily:
941
+ 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
942
+ whiteSpace: 'pre-wrap',
943
+ wordBreak: 'break-word',
944
+ textAlign: 'left',
945
+ maxHeight: '50%',
946
+ overflow: 'auto',
947
+ width: '100%',
948
+ margin: 0,
949
+ lineHeight: 1.5,
950
+ }}
951
+ >
952
+ {error.stack || error.message}
953
+ </pre>
954
+ </div>
955
+ </AbsoluteFill>
956
+ )}
957
+ />
958
+ ) : (
959
+ <div className='aspect-video bg-[#050505]' />
960
+ )}
961
+ </div>
962
+ </div>
963
+
964
+ {/* Right sidebar — always visible, hosts tweakpane controls */}
965
+ <TweakpaneRoot playerRef={playerRef} fps={fps} sections={sections} entryPath={entryPath} />
966
+
967
+ {/* Floating toolbar — fixed at bottom, offset left to account for sidebar */}
968
+ <div
969
+ className='fixed bottom-6 flex items-center gap-1.5 rounded-full bg-[#1c1c1c] border border-white/10 px-2 py-1.5 shadow-2xl'
970
+ style={{
971
+ left: `calc((100% - ${SIDEBAR_WIDTH}px - 0.75rem) / 2)`,
972
+ transform: 'translateX(-50%)',
973
+ }}
974
+ >
975
+ {/* Entry selector — only shown when multiple MDX files exist */}
976
+ {availableEntries.length > 1 && (
977
+ <>
978
+ <select
979
+ value={currentRoute}
980
+ onChange={(e) => {
981
+ const route = e.target.value
982
+ const base = import.meta.env.BASE_URL ?? '/'
983
+ window.location.href = `${base}${route}`
984
+ }}
985
+ className='appearance-none rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-300 bg-transparent hover:bg-white/5 transition-colors cursor-pointer outline-none'
986
+ style={{ maxWidth: 150 }}
987
+ >
988
+ {availableEntries.map((route) => (
989
+ <option key={route} value={route} style={{ background: '#1c1c1c', color: '#d4d4d8' }}>
990
+ {route || '(default)'}
991
+ </option>
992
+ ))}
993
+ </select>
994
+ <ToolbarSeparator />
995
+ </>
996
+ )}
997
+
998
+ {rendering ? (
999
+ <>
1000
+ <button
1001
+ onClick={handleCancel}
1002
+ className='flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[13px] font-medium text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer'
1003
+ >
1004
+ <XIcon />
1005
+ Cancel
1006
+ </button>
1007
+ <ToolbarSeparator />
1008
+ <div className='flex items-center gap-2.5 px-2'>
1009
+ <div className='w-24 h-1 rounded-full bg-white/10 overflow-hidden'>
1010
+ <div
1011
+ className='h-full rounded-full bg-sky-400 transition-[width] duration-300'
1012
+ style={{ width: `${Math.round(progress * 100)}%` }}
1013
+ />
1014
+ </div>
1015
+ <span className='text-[13px] text-zinc-500 tabular-nums'>
1016
+ {Math.round(progress * 100)}%
1017
+ </span>
1018
+ </div>
1019
+ </>
1020
+ ) : (
1021
+ <button
1022
+ onClick={handleExport}
1023
+ className='flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[13px] font-medium text-sky-950 bg-sky-200 hover:bg-sky-100 transition-colors cursor-pointer'
1024
+ >
1025
+ <DownloadIcon />
1026
+ Export MP4
1027
+ </button>
1028
+ )}
1029
+
1030
+ <ToolbarSeparator />
1031
+
1032
+ {/* Playback rate — click to cycle, synced with J/K/L shortcuts */}
1033
+ <button
1034
+ onClick={() => {
1035
+ setPlaybackRate((prev) => {
1036
+ const idx = PLAYBACK_RATES.indexOf(prev as any)
1037
+ const next = PLAYBACK_RATES[(idx + 1) % PLAYBACK_RATES.length]!
1038
+ return next
1039
+ })
1040
+ }}
1041
+ className='flex items-center rounded-full px-2.5 py-1.5 text-[13px] font-medium tabular-nums text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors cursor-pointer min-w-[3rem] justify-center'
1042
+ title='Playback speed (click to cycle)'
1043
+ >
1044
+ {playbackRate === 1 ? '1x' : `${playbackRate}x`}
1045
+ </button>
1046
+
1047
+ <ToolbarSeparator />
1048
+
1049
+ {/* Scene navigation — prev (Down) / next (Up) */}
1050
+ <div className='flex items-center'>
1051
+ <button
1052
+ onClick={goToPrevScene}
1053
+ disabled={!hasPrevScene}
1054
+ className='flex items-center justify-center rounded-full w-7 h-7 text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-zinc-400'
1055
+ title='Previous scene (↓)'
1056
+ >
1057
+ <ChevronDownIcon />
1058
+ </button>
1059
+ <button
1060
+ onClick={goToNextScene}
1061
+ disabled={!hasNextScene}
1062
+ className='flex items-center justify-center rounded-full w-7 h-7 text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-zinc-400'
1063
+ title='Next scene (↑)'
1064
+ >
1065
+ <ChevronUpIcon />
1066
+ </button>
1067
+ </div>
1068
+
1069
+ <ToolbarSeparator />
1070
+
1071
+ <LayoutEditor
1072
+ playerContainerRef={playerContainerRef}
1073
+ playerRef={playerRef}
1074
+ editing={editing}
1075
+ onEditingChange={setEditing}
1076
+ onReset={() => setResetKey((k) => k + 1)}
1077
+ sections={sections}
1078
+ fps={fps}
1079
+ entryPath={entryPath}
1080
+ />
1081
+
1082
+ {generationStatus && (
1083
+ <>
1084
+ <ToolbarSeparator />
1085
+ <div className='flex items-center gap-1.5 px-2.5 py-1.5 text-[13px] font-medium text-amber-400/90'>
1086
+ <SpinnerIcon />
1087
+ <span>{formatGenerationStatus(generationStatus)}</span>
1088
+ </div>
1089
+ </>
1090
+ )}
1091
+
1092
+ {generationErrors.length > 0 && (
1093
+ <>
1094
+ <ToolbarSeparator />
1095
+ <div className='flex items-center gap-1.5 px-2.5 py-1.5 text-[13px] font-medium text-red-400/90'>
1096
+ <span style={{ fontSize: 14, lineHeight: 1 }}>✕</span>
1097
+ <span>{generationErrors.length === 1
1098
+ ? `${generationErrors[0]!.namespace} failed: ${generationErrors[0]!.error.slice(0, 60)}`
1099
+ : `${generationErrors.length} generations failed`
1100
+ }</span>
1101
+ </div>
1102
+ </>
1103
+ )}
1104
+ </div>
1105
+ </div>
1106
+ )
1107
+ }