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