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,720 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
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
|
+
import './styles.css';
|
|
14
|
+
import { Player } from '@remotion/player';
|
|
15
|
+
import React, { Suspense, useCallback, useContext, useEffect, useLayoutEffect, useRef, useSyncExternalStore, useState } from 'react';
|
|
16
|
+
import { AbsoluteFill, Freeze, Sequence, Series, useCurrentFrame, useDelayRender, useVideoConfig, } from 'remotion';
|
|
17
|
+
import { renderInBrowser } from "./render-client.js";
|
|
18
|
+
import { createSpiceflowFetch } from 'spiceflow/client';
|
|
19
|
+
import { egakiStore } from "./store.js";
|
|
20
|
+
import { useGenerationStatus, useGenerationErrors } from "./store-hooks.js";
|
|
21
|
+
import { egakiSDK } from "./sdk.js";
|
|
22
|
+
import { LayoutEditor } from "./layout-editor.js";
|
|
23
|
+
import { TweakpaneRoot, SIDEBAR_WIDTH } from "./tweakpane-hook.js";
|
|
24
|
+
import { Fill, LayoutAnimationLayer, LayoutGhost, LayoutTransitionProvider, MotionTimingSync, ServerSlotsContext, } from "./mdx-video.js";
|
|
25
|
+
import { SectionIndexContext } from "./media-duration-store.js";
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// HMR scene-changed: store pending seek at module scope so it survives
|
|
28
|
+
// component remounts caused by rsc:update (spiceflow payload swap).
|
|
29
|
+
// The vite plugin sends egaki:scene-changed with { sectionIndex } when
|
|
30
|
+
// the user edits a specific section of the entry MDX.
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
let pendingSceneSeek = null;
|
|
33
|
+
/** Cancels the auto-pause listener from a previous scene-seek, so a new
|
|
34
|
+
* HMR during playback doesn't leave stale listeners that pause the wrong scene. */
|
|
35
|
+
let cancelPreviousAutoPlay = null;
|
|
36
|
+
/** Monotonic counter to invalidate stale requestAnimationFrame callbacks
|
|
37
|
+
* when rapid successive HMR edits arrive before the first rAF fires. */
|
|
38
|
+
let sceneSeekVersion = 0;
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Generation progress client — fetches /api/generation-progress on page
|
|
41
|
+
// load and on every rsc:update using the typed Spiceflow fetch client.
|
|
42
|
+
// The route is an async generator that yields progress events as SSE;
|
|
43
|
+
// createSpiceflowFetch returns an AsyncGenerator we consume with for-await.
|
|
44
|
+
// Max one connection at a time; new rsc:update aborts the previous one.
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
let progressAbortController = null;
|
|
47
|
+
function connectToProgress() {
|
|
48
|
+
if (typeof window === 'undefined')
|
|
49
|
+
return;
|
|
50
|
+
progressAbortController?.abort();
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
progressAbortController = controller;
|
|
53
|
+
// Lazy-init: createSpiceflowFetch needs window.location.origin which
|
|
54
|
+
// is only available in the browser, not during SSR.
|
|
55
|
+
const safeFetch = createSpiceflowFetch(window.location.origin);
|
|
56
|
+
(async () => {
|
|
57
|
+
const result = await safeFetch('/api/generation-progress', {
|
|
58
|
+
signal: controller.signal,
|
|
59
|
+
});
|
|
60
|
+
if (result instanceof Error)
|
|
61
|
+
return;
|
|
62
|
+
// result is an AsyncGenerator<GenerationProgressEvent> from the streaming route
|
|
63
|
+
try {
|
|
64
|
+
for await (const event of result) {
|
|
65
|
+
// Guard: if a newer connection replaced us, stop writing state
|
|
66
|
+
if (progressAbortController !== controller)
|
|
67
|
+
return;
|
|
68
|
+
// Log generation errors to the console and show them in the toolbar
|
|
69
|
+
if (event.errors?.length) {
|
|
70
|
+
for (const err of event.errors) {
|
|
71
|
+
console.error(`[egaki] ${err.namespace} generation failed (${Math.round(err.durationMs / 1000)}s): ${err.error}`);
|
|
72
|
+
}
|
|
73
|
+
// Append new errors with unique IDs and auto-clear them after 8 seconds
|
|
74
|
+
const errorIds = new Set();
|
|
75
|
+
const tagged = event.errors.map((e) => {
|
|
76
|
+
const id = `${e.key}:${Date.now()}:${Math.random()}`;
|
|
77
|
+
errorIds.add(id);
|
|
78
|
+
return { ...e, _id: id };
|
|
79
|
+
});
|
|
80
|
+
const currentErrors = egakiStore.getState().serverGenerationErrors;
|
|
81
|
+
egakiStore.setState({ serverGenerationErrors: [...currentErrors, ...tagged] });
|
|
82
|
+
setTimeout(() => {
|
|
83
|
+
egakiStore.setState({
|
|
84
|
+
serverGenerationErrors: egakiStore.getState().serverGenerationErrors.filter((e) => !errorIds.has(e._id)),
|
|
85
|
+
});
|
|
86
|
+
}, 8000);
|
|
87
|
+
}
|
|
88
|
+
const summary = event.summary;
|
|
89
|
+
egakiStore.setState({
|
|
90
|
+
serverGenerationStatus: summary.total > 0 ? summary : null,
|
|
91
|
+
serverGenerationEntries: event.generations,
|
|
92
|
+
});
|
|
93
|
+
if (event.done)
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Aborted or network error — non-fatal
|
|
99
|
+
}
|
|
100
|
+
// Guard: only clear state if we're still the active connection
|
|
101
|
+
if (progressAbortController === controller) {
|
|
102
|
+
egakiStore.setState({ serverGenerationStatus: null, serverGenerationEntries: [] });
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
}
|
|
106
|
+
// Connect on initial page load (guarded for SSR)
|
|
107
|
+
if (typeof window !== 'undefined')
|
|
108
|
+
connectToProgress();
|
|
109
|
+
if (import.meta.hot) {
|
|
110
|
+
import.meta.hot.on('egaki:scene-changed', (data) => {
|
|
111
|
+
pendingSceneSeek = data;
|
|
112
|
+
});
|
|
113
|
+
// Reconnect on rsc:update — new generations may have started from
|
|
114
|
+
// MDX edits that trigger server-side rendering of <Server> blocks.
|
|
115
|
+
import.meta.hot.on('rsc:update', () => {
|
|
116
|
+
// Small delay to let the server start processing before we connect,
|
|
117
|
+
// so the first SSE event already includes the new generations.
|
|
118
|
+
setTimeout(connectToProgress, 100);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
// Module-level stable callbacks for useSyncExternalStore (never re-subscribes)
|
|
122
|
+
const subscribeNoop = () => () => { };
|
|
123
|
+
const getClientMounted = () => true;
|
|
124
|
+
const getServerMounted = () => false;
|
|
125
|
+
const PLAYBACK_RATES = [0.5, 1, 1.5, 2, 4, 8];
|
|
126
|
+
function ToolbarSeparator() {
|
|
127
|
+
return _jsx("div", { className: 'w-px h-4 bg-white/15' });
|
|
128
|
+
}
|
|
129
|
+
function DownloadIcon() {
|
|
130
|
+
return (_jsxs("svg", { width: '14', height: '14', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round', children: [_jsx("path", { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' }), _jsx("polyline", { points: '7 10 12 15 17 10' }), _jsx("line", { x1: '12', y1: '15', x2: '12', y2: '3' })] }));
|
|
131
|
+
}
|
|
132
|
+
function XIcon() {
|
|
133
|
+
return (_jsxs("svg", { width: '14', height: '14', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round', children: [_jsx("line", { x1: '18', y1: '6', x2: '6', y2: '18' }), _jsx("line", { x1: '6', y1: '6', x2: '18', y2: '18' })] }));
|
|
134
|
+
}
|
|
135
|
+
function ChevronUpIcon() {
|
|
136
|
+
return (_jsx("svg", { width: '14', height: '14', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round', children: _jsx("polyline", { points: '18 15 12 9 6 15' }) }));
|
|
137
|
+
}
|
|
138
|
+
function ChevronDownIcon() {
|
|
139
|
+
return (_jsx("svg", { width: '14', height: '14', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round', children: _jsx("polyline", { points: '6 9 12 15 18 9' }) }));
|
|
140
|
+
}
|
|
141
|
+
function SpinnerIcon() {
|
|
142
|
+
return (_jsx("svg", { width: '14', height: '14', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round', className: 'animate-spin', children: _jsx("path", { d: 'M21 12a9 9 0 1 1-6.219-8.56' }) }));
|
|
143
|
+
}
|
|
144
|
+
function formatGenerationStatus(status) {
|
|
145
|
+
const parts = [];
|
|
146
|
+
for (const [namespace, count] of Object.entries(status.counts)) {
|
|
147
|
+
if (count <= 0)
|
|
148
|
+
continue;
|
|
149
|
+
// Pluralize: add 's' for most, 'es' for speech
|
|
150
|
+
const plural = count > 1 ? (namespace === 'speech' ? 'es' : 's') : '';
|
|
151
|
+
parts.push(`${count} ${namespace}${plural}`);
|
|
152
|
+
}
|
|
153
|
+
return `Generating ${parts.join(', ')}`;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Remotion-aware Suspense fallback. When a section suspends (throws a promise),
|
|
157
|
+
* this component calls delayRender() to prevent Remotion from taking a
|
|
158
|
+
* screenshot of the incomplete frame. When the suspended component resolves
|
|
159
|
+
* and this fallback unmounts, continueRender() fires and rendering proceeds.
|
|
160
|
+
*
|
|
161
|
+
* This is the same pattern Remotion uses internally in <Composition> (see
|
|
162
|
+
* packages/core/src/Composition.tsx in the Remotion source).
|
|
163
|
+
*/
|
|
164
|
+
function SuspenseFallback() {
|
|
165
|
+
const { delayRender, continueRender } = useDelayRender();
|
|
166
|
+
// useLayoutEffect registers the delay handle before paint, preventing
|
|
167
|
+
// the renderer from capturing a frame before the handle exists.
|
|
168
|
+
useLayoutEffect(() => {
|
|
169
|
+
const handle = delayRender('Waiting for section to unsuspend', {
|
|
170
|
+
timeoutInMilliseconds: 10 * 60 * 1000,
|
|
171
|
+
});
|
|
172
|
+
return () => continueRender(handle);
|
|
173
|
+
}, [delayRender, continueRender]);
|
|
174
|
+
// delayRender is a no-op in the Player, so this loading UI is only
|
|
175
|
+
// visible during preview. During export, rendering pauses until the
|
|
176
|
+
// suspended component resolves, then this fallback unmounts before
|
|
177
|
+
// the screenshot is taken.
|
|
178
|
+
return (_jsx(AbsoluteFill, { style: {
|
|
179
|
+
display: 'flex',
|
|
180
|
+
alignItems: 'center',
|
|
181
|
+
justifyContent: 'center',
|
|
182
|
+
background: '#050505',
|
|
183
|
+
}, children: _jsx("span", { style: {
|
|
184
|
+
fontSize: 48,
|
|
185
|
+
fontWeight: 500,
|
|
186
|
+
color: '#52525b',
|
|
187
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif',
|
|
188
|
+
letterSpacing: '-0.01em',
|
|
189
|
+
}, children: "Loading\u2026" }) }));
|
|
190
|
+
}
|
|
191
|
+
// Shared by the visible section content and the hidden ghost copy of the
|
|
192
|
+
// previous section — element positions only match across the two containers
|
|
193
|
+
// if both use the exact same layout styles.
|
|
194
|
+
const SECTION_CONTENT_STYLE = {
|
|
195
|
+
zIndex: 1,
|
|
196
|
+
gap: 'clamp(1rem, 2vw, 2.5rem)',
|
|
197
|
+
// Force Chrome GPU compositing for subpixel text rendering.
|
|
198
|
+
// Without this, Chrome snaps text positions to whole pixels
|
|
199
|
+
// causing visible stutter on slow translate/scale animations.
|
|
200
|
+
// Not supported by the web-renderer canvas export, but the
|
|
201
|
+
// canvas renderer doesn't have Chrome's pixel snapping issue.
|
|
202
|
+
// See: https://remotion.dev/docs/troubleshooting/subpixel-rendering
|
|
203
|
+
perspective: '1000px',
|
|
204
|
+
willChange: 'transform',
|
|
205
|
+
// Grayscale antialiasing instead of subpixel. Subpixel rendering adds
|
|
206
|
+
// RGB color fringing at text edges which creates shimmering artifacts
|
|
207
|
+
// when text moves or scales in video. Grayscale is also consistent
|
|
208
|
+
// across platforms (subpixel varies by OS and display).
|
|
209
|
+
WebkitFontSmoothing: 'antialiased',
|
|
210
|
+
MozOsxFontSmoothing: 'grayscale',
|
|
211
|
+
};
|
|
212
|
+
// How long (in seconds) the hidden ghost copy of the previous section stays
|
|
213
|
+
// mounted at the start of a section. LayoutTransition springs (default 20
|
|
214
|
+
// frames) settle well within this window. A constant is used because
|
|
215
|
+
// per-element durations are only known after children render — the ghost
|
|
216
|
+
// mount decision happens before that.
|
|
217
|
+
const GHOST_WINDOW_SECONDS = 3;
|
|
218
|
+
// How many seconds before a section cut to premount the next section.
|
|
219
|
+
// Remotion's premountFor renders the next sequence early (hidden) so
|
|
220
|
+
// <Video> elements start loading before the cut, preventing stutter.
|
|
221
|
+
const PREMOUNT_SECONDS = 1;
|
|
222
|
+
/**
|
|
223
|
+
* Section content wrapper enabling <LayoutTransition> FLIP animations
|
|
224
|
+
* across section boundaries.
|
|
225
|
+
*
|
|
226
|
+
* Renders the PREVIOUS section in a hidden ghost container, pinned at its
|
|
227
|
+
* last frame via <Freeze>, so LayoutAnimationLayer can measure where each
|
|
228
|
+
* LayoutTransition element was when the viewer last saw it. Seek-safe: the
|
|
229
|
+
* ghost is derived purely from the current frame, never from playback order.
|
|
230
|
+
*
|
|
231
|
+
* Known limitation: the ghost's useVideoConfig().durationInFrames is clamped
|
|
232
|
+
* by the CURRENT section's Series.Sequence (Remotion Sequences take
|
|
233
|
+
* min(parent, own) duration). Exit animations in the previous section that
|
|
234
|
+
* depend on durationInFrames measure slightly off when the current section
|
|
235
|
+
* is shorter than the previous one. Position measurement is unaffected for
|
|
236
|
+
* static layouts.
|
|
237
|
+
*/
|
|
238
|
+
function SectionWithLayoutTransition({ jsx, prevJsx, prevDurationInFrames, }) {
|
|
239
|
+
const frame = useCurrentFrame();
|
|
240
|
+
const { fps } = useVideoConfig();
|
|
241
|
+
const showGhost = prevJsx != null && frame < fps * GHOST_WINDOW_SECONDS;
|
|
242
|
+
return (_jsx(LayoutTransitionProvider, { children: _jsxs(AbsoluteFill, { style: { background: 'transparent' }, children: [showGhost ? (
|
|
243
|
+
// Ghost: hidden but laid out (visibility:hidden keeps geometry).
|
|
244
|
+
// opacity:0 is added for web-renderer safety, and the ghost comes
|
|
245
|
+
// FIRST in DOM order so the visible section paints over it even
|
|
246
|
+
// if a renderer ignores visibility (no z-index in web-renderer).
|
|
247
|
+
_jsx(AbsoluteFill, { style: { visibility: 'hidden', opacity: 0, pointerEvents: 'none' }, "aria-hidden": true, children: _jsx(LayoutGhost, { children: _jsx(Freeze, { frame: prevDurationInFrames - 1, children: _jsx(Sequence, { from: 0, durationInFrames: prevDurationInFrames, layout: "none", showInTimeline: false, children: _jsx(Fill, { style: SECTION_CONTENT_STYLE, children: prevJsx }) }) }) }) })) : null, _jsx(MotionTimingSync, { children: _jsx(Fill, { style: SECTION_CONTENT_STYLE, children: jsx }) }), _jsx(LayoutAnimationLayer, {})] }) }));
|
|
248
|
+
}
|
|
249
|
+
function VideoComposition({ sections, totalDuration, preamble, }) {
|
|
250
|
+
const { fps } = useVideoConfig();
|
|
251
|
+
const premountFrames = Math.round(fps * PREMOUNT_SECONDS);
|
|
252
|
+
return (_jsxs(AbsoluteFill, { style: { background: '#050505', fontSize: 60 }, children: [_jsx(MotionTimingSync, { children: preamble }), _jsx(Series, { children: sections.map((section, i) => (_jsx(Series.Sequence, { durationInFrames: section.durationInFrames,
|
|
253
|
+
// @ts-ignore — name prop exists on Series.Sequence
|
|
254
|
+
name: section.heading || `Section ${i}`,
|
|
255
|
+
// Premount the next section N seconds early so <Video> elements
|
|
256
|
+
// start loading before the cut, preventing playback stutter.
|
|
257
|
+
// Remotion renders the premounted section hidden; it receives
|
|
258
|
+
// its own correct frame context (starting at frame 0).
|
|
259
|
+
premountFor: premountFrames, children: _jsx(SectionIndexContext.Provider, { value: i, children: _jsx(Suspense, { fallback: _jsx(SuspenseFallback, {}), children: _jsx(SectionWithLayoutTransition, { jsx: section.jsx, prevJsx: i > 0 ? sections[i - 1].jsx : null, prevDurationInFrames: i > 0 ? sections[i - 1].durationInFrames : 0 }) }) }) }, i))) })] }));
|
|
260
|
+
}
|
|
261
|
+
export function PlayerPage({ sections, totalDuration, preamble, entryPath, hasUnresolvedDurations = false, frontmatter, availableEntries = [], currentRoute = '', }) {
|
|
262
|
+
const { fps, width, height, scale } = frontmatter;
|
|
263
|
+
const serverSlots = useContext(ServerSlotsContext);
|
|
264
|
+
// Stable component function that reads latest props from a ref.
|
|
265
|
+
// Created once so its identity never changes between renders.
|
|
266
|
+
// Remotion Player doesn't remount when component identity is stable.
|
|
267
|
+
const propsRef = useRef({ sections, totalDuration, preamble, serverSlots });
|
|
268
|
+
propsRef.current = { sections, totalDuration, preamble, serverSlots };
|
|
269
|
+
// Wrap VideoComposition with ServerSlotsContext so that <Server> slots
|
|
270
|
+
// are available during SDK export/screenshot (renderStillOnWeb /
|
|
271
|
+
// renderMediaOnWeb create a fresh React tree from this component).
|
|
272
|
+
const [Component] = useState(() => () => (_jsx(ServerSlotsContext.Provider, { value: propsRef.current.serverSlots, children: _jsx(VideoComposition, { ...propsRef.current }) })));
|
|
273
|
+
const playerRef = useRef(null);
|
|
274
|
+
const playerContainerRef = useRef(null);
|
|
275
|
+
// Register the composition with the SDK so agents can call
|
|
276
|
+
// window.egakiSDK.seekTo() / .screenshot() / .export() via Playwriter.
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
egakiSDK.register({
|
|
279
|
+
component: Component,
|
|
280
|
+
totalDuration,
|
|
281
|
+
fps,
|
|
282
|
+
width,
|
|
283
|
+
height,
|
|
284
|
+
scale,
|
|
285
|
+
sectionCount: sections.length,
|
|
286
|
+
sections: sections.map((s) => ({
|
|
287
|
+
heading: s.heading,
|
|
288
|
+
durationInFrames: s.durationInFrames,
|
|
289
|
+
})),
|
|
290
|
+
playerRef,
|
|
291
|
+
playerContainerRef,
|
|
292
|
+
});
|
|
293
|
+
}, [Component, totalDuration, sections, fps, width, height, scale]);
|
|
294
|
+
// Consume pending scene seek after the RSC refetch delivers new sections.
|
|
295
|
+
// The module-level pendingSceneSeek is set by import.meta.hot.on before
|
|
296
|
+
// React re-renders; this effect fires after the commit with fresh data.
|
|
297
|
+
// Seeks to the changed section's start frame, plays it, and pauses at
|
|
298
|
+
// the section's end so the user sees exactly the content they edited.
|
|
299
|
+
useEffect(() => {
|
|
300
|
+
if (pendingSceneSeek == null)
|
|
301
|
+
return;
|
|
302
|
+
// Ignore scene changes from a different entry MDX file.
|
|
303
|
+
if (pendingSceneSeek.file !== entryPath) {
|
|
304
|
+
pendingSceneSeek = null;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const idx = pendingSceneSeek.sectionIndex;
|
|
308
|
+
pendingSceneSeek = null;
|
|
309
|
+
// Wait one frame for the Remotion Player to initialize after remount.
|
|
310
|
+
// Version token ensures stale rAF callbacks from rapid edits are ignored.
|
|
311
|
+
const version = ++sceneSeekVersion;
|
|
312
|
+
requestAnimationFrame(() => {
|
|
313
|
+
if (version !== sceneSeekVersion)
|
|
314
|
+
return;
|
|
315
|
+
const player = playerRef.current;
|
|
316
|
+
if (!player || idx < 0 || idx >= sections.length)
|
|
317
|
+
return;
|
|
318
|
+
// Compute section boundaries from current sections
|
|
319
|
+
let startFrame = 0;
|
|
320
|
+
for (let i = 0; i < idx; i++) {
|
|
321
|
+
startFrame += sections[i].durationInFrames;
|
|
322
|
+
}
|
|
323
|
+
const endFrame = startFrame + sections[idx].durationInFrames - 1;
|
|
324
|
+
player.seekTo(startFrame);
|
|
325
|
+
// Cancel any auto-pause from a previous HMR scene-seek so stale
|
|
326
|
+
// listeners don't pause playback in the wrong section.
|
|
327
|
+
cancelPreviousAutoPlay?.();
|
|
328
|
+
cancelPreviousAutoPlay = null;
|
|
329
|
+
// Only auto-play if currently paused (after rsc:update remount
|
|
330
|
+
// the player always starts paused at frame 0)
|
|
331
|
+
if (!player.isPlaying()) {
|
|
332
|
+
player.play();
|
|
333
|
+
// Pause at section end so only the edited section plays.
|
|
334
|
+
// If the user manually seeks (clicks timeline, arrow keys, etc.)
|
|
335
|
+
// cancel the auto-pause so we don't randomly stop playback later.
|
|
336
|
+
let cancelled = false;
|
|
337
|
+
const cleanup = () => {
|
|
338
|
+
if (cancelled)
|
|
339
|
+
return;
|
|
340
|
+
cancelled = true;
|
|
341
|
+
cancelPreviousAutoPlay = null;
|
|
342
|
+
player.removeEventListener('frameupdate', onFrame);
|
|
343
|
+
player.removeEventListener('seeked', onSeek);
|
|
344
|
+
};
|
|
345
|
+
cancelPreviousAutoPlay = cleanup;
|
|
346
|
+
const onFrame = () => {
|
|
347
|
+
if (cancelled)
|
|
348
|
+
return;
|
|
349
|
+
if (player.getCurrentFrame() >= endFrame) {
|
|
350
|
+
player.pause();
|
|
351
|
+
cleanup();
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
const onSeek = (e) => {
|
|
355
|
+
// User seeked away — cancel auto-pause. Ignore seeks within
|
|
356
|
+
// the target section range (could be our own seekTo settling).
|
|
357
|
+
const frame = e?.detail?.frame ?? player.getCurrentFrame();
|
|
358
|
+
if (frame < startFrame || frame > endFrame) {
|
|
359
|
+
cleanup();
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
player.addEventListener('frameupdate', onFrame);
|
|
363
|
+
player.addEventListener('seeked', onSeek);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
}, [sections]);
|
|
367
|
+
// Defer Player mount to client only. Remotion's Player and all composition
|
|
368
|
+
// components (Series, AbsoluteFill, Audio, Video) use React context provided
|
|
369
|
+
// by Player at runtime. During SSR there's no Remotion context, so hooks like
|
|
370
|
+
// useCurrentFrame/useVideoConfig crash with "Cannot read properties of null
|
|
371
|
+
// (reading 'useContext')". useSyncExternalStore returns false on the server
|
|
372
|
+
// and true on the client synchronously during hydration (no useEffect tick).
|
|
373
|
+
const mounted = useSyncExternalStore(subscribeNoop, getClientMounted, getServerMounted);
|
|
374
|
+
const generationStatus = useGenerationStatus();
|
|
375
|
+
const generationErrors = useGenerationErrors();
|
|
376
|
+
const [editing, setEditing] = useState(false);
|
|
377
|
+
const [resetKey, setResetKey] = useState(0);
|
|
378
|
+
const [rendering, setRendering] = useState(false);
|
|
379
|
+
const [progress, setProgress] = useState(0);
|
|
380
|
+
const abortRef = useRef(null);
|
|
381
|
+
const [playbackRate, setPlaybackRate] = useState(1);
|
|
382
|
+
const [currentFrame, setCurrentFrame] = useState(0);
|
|
383
|
+
// Track current frame for scene navigation button states
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
const player = playerRef.current;
|
|
386
|
+
if (!player)
|
|
387
|
+
return;
|
|
388
|
+
const onFrame = () => setCurrentFrame(player.getCurrentFrame());
|
|
389
|
+
// Remotion Player fires 'frameupdate' on seek and during playback
|
|
390
|
+
player.addEventListener('frameupdate', onFrame);
|
|
391
|
+
return () => player.removeEventListener('frameupdate', onFrame);
|
|
392
|
+
}, [mounted]);
|
|
393
|
+
// Precompute section start frames for navigation
|
|
394
|
+
const sectionStarts = [];
|
|
395
|
+
{
|
|
396
|
+
let acc = 0;
|
|
397
|
+
for (const s of sections) {
|
|
398
|
+
sectionStarts.push(acc);
|
|
399
|
+
acc += s.durationInFrames;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// Determine which section the current frame is in
|
|
403
|
+
let currentSectionIdx = 0;
|
|
404
|
+
for (let i = sectionStarts.length - 1; i >= 0; i--) {
|
|
405
|
+
if (currentFrame >= sectionStarts[i]) {
|
|
406
|
+
currentSectionIdx = i;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const hasPrevScene = currentSectionIdx > 0 || currentFrame > 0;
|
|
411
|
+
const hasNextScene = currentSectionIdx < sections.length - 1;
|
|
412
|
+
// Single tap: seek to start of current section.
|
|
413
|
+
// Double tap (within 400ms): jump to previous section start. Each
|
|
414
|
+
// additional rapid tap keeps going one section further back.
|
|
415
|
+
const lastPrevTapRef = useRef({ time: 0, targetFrame: -1 });
|
|
416
|
+
const goToPrevScene = useCallback(() => {
|
|
417
|
+
const player = playerRef.current;
|
|
418
|
+
if (!player)
|
|
419
|
+
return;
|
|
420
|
+
const wasPlaying = player.isPlaying();
|
|
421
|
+
if (wasPlaying)
|
|
422
|
+
player.pause();
|
|
423
|
+
const frame = player.getCurrentFrame();
|
|
424
|
+
// Find the start of the current section (largest sectionStart <= frame)
|
|
425
|
+
let currentStart = 0;
|
|
426
|
+
for (let i = sectionStarts.length - 1; i >= 0; i--) {
|
|
427
|
+
if (sectionStarts[i] <= frame) {
|
|
428
|
+
currentStart = sectionStarts[i];
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const now = Date.now();
|
|
433
|
+
const last = lastPrevTapRef.current;
|
|
434
|
+
// Double-tap: if pressed again within 400ms, go to the section before
|
|
435
|
+
// the last target (so repeated taps keep stepping backwards)
|
|
436
|
+
if (now - last.time < 400) {
|
|
437
|
+
let prevTarget = 0;
|
|
438
|
+
for (let i = sectionStarts.length - 1; i >= 0; i--) {
|
|
439
|
+
if (sectionStarts[i] < last.targetFrame) {
|
|
440
|
+
prevTarget = sectionStarts[i];
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
lastPrevTapRef.current = { time: now, targetFrame: prevTarget };
|
|
445
|
+
player.seekTo(prevTarget);
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
// Single tap: go to start of current section
|
|
449
|
+
lastPrevTapRef.current = { time: now, targetFrame: currentStart };
|
|
450
|
+
player.seekTo(currentStart);
|
|
451
|
+
}
|
|
452
|
+
if (wasPlaying)
|
|
453
|
+
player.play();
|
|
454
|
+
}, [sectionStarts]);
|
|
455
|
+
const goToNextScene = useCallback(() => {
|
|
456
|
+
const player = playerRef.current;
|
|
457
|
+
if (!player)
|
|
458
|
+
return;
|
|
459
|
+
const wasPlaying = player.isPlaying();
|
|
460
|
+
if (wasPlaying)
|
|
461
|
+
player.pause();
|
|
462
|
+
const frame = player.getCurrentFrame();
|
|
463
|
+
let target = totalDuration - 1;
|
|
464
|
+
for (const start of sectionStarts) {
|
|
465
|
+
if (start > frame) {
|
|
466
|
+
target = start;
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
player.seekTo(target);
|
|
471
|
+
if (wasPlaying)
|
|
472
|
+
player.play();
|
|
473
|
+
}, [sectionStarts, totalDuration]);
|
|
474
|
+
// Sync playbackRate state when user changes it via the gear menu
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
const player = playerRef.current;
|
|
477
|
+
if (!player)
|
|
478
|
+
return;
|
|
479
|
+
const onRateChange = (e) => {
|
|
480
|
+
setPlaybackRate(e.detail?.playbackRate ?? 1);
|
|
481
|
+
};
|
|
482
|
+
player.addEventListener('ratechange', onRateChange);
|
|
483
|
+
return () => player.removeEventListener('ratechange', onRateChange);
|
|
484
|
+
}, [mounted]);
|
|
485
|
+
// Keyboard shortcuts — modeled after After Effects / Premiere / DaVinci.
|
|
486
|
+
// Disabled when the layout editor is active (it may use arrow keys).
|
|
487
|
+
//
|
|
488
|
+
// Space play/pause
|
|
489
|
+
// Left/Right ±1 frame
|
|
490
|
+
// Shift+Left/Right ±10 frames
|
|
491
|
+
// Down/Up section start (×2 = prev) / next section
|
|
492
|
+
// , / . ±1 frame (Premiere convention)
|
|
493
|
+
// Home / End first / last frame
|
|
494
|
+
// J / K / L rewind 2s / pause / play (L×2=2x, ×3=4x, ×4=8x)
|
|
495
|
+
// 0-9 jump to 0%-90% of timeline
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
if (editing)
|
|
498
|
+
return;
|
|
499
|
+
const clamp = (frame) => Math.max(0, Math.min(totalDuration - 1, frame));
|
|
500
|
+
const onKeyDown = (e) => {
|
|
501
|
+
const player = playerRef.current;
|
|
502
|
+
if (!player)
|
|
503
|
+
return;
|
|
504
|
+
// Ignore when focus is on an input/textarea/contenteditable
|
|
505
|
+
const tag = e.target?.tagName;
|
|
506
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target?.isContentEditable)
|
|
507
|
+
return;
|
|
508
|
+
const frame = player.getCurrentFrame();
|
|
509
|
+
// --- Frame stepping ---
|
|
510
|
+
if (e.key === 'ArrowLeft' && !e.shiftKey) {
|
|
511
|
+
e.preventDefault();
|
|
512
|
+
if (player.isPlaying())
|
|
513
|
+
player.pause();
|
|
514
|
+
player.seekTo(clamp(frame - 1));
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (e.key === 'ArrowRight' && !e.shiftKey) {
|
|
518
|
+
e.preventDefault();
|
|
519
|
+
if (player.isPlaying())
|
|
520
|
+
player.pause();
|
|
521
|
+
player.seekTo(clamp(frame + 1));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (e.key === ',') {
|
|
525
|
+
e.preventDefault();
|
|
526
|
+
if (player.isPlaying())
|
|
527
|
+
player.pause();
|
|
528
|
+
player.seekTo(clamp(frame - 1));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (e.key === '.') {
|
|
532
|
+
e.preventDefault();
|
|
533
|
+
if (player.isPlaying())
|
|
534
|
+
player.pause();
|
|
535
|
+
player.seekTo(clamp(frame + 1));
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
// --- 10-frame jump (Shift+Arrow) ---
|
|
539
|
+
if (e.key === 'ArrowLeft' && e.shiftKey) {
|
|
540
|
+
e.preventDefault();
|
|
541
|
+
if (player.isPlaying())
|
|
542
|
+
player.pause();
|
|
543
|
+
player.seekTo(clamp(frame - 10));
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (e.key === 'ArrowRight' && e.shiftKey) {
|
|
547
|
+
e.preventDefault();
|
|
548
|
+
if (player.isPlaying())
|
|
549
|
+
player.pause();
|
|
550
|
+
player.seekTo(clamp(frame + 10));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
// --- Section navigation (Down/Up) — delegates to goToPrev/NextScene
|
|
554
|
+
// which preserve playback state and support double-tap prev ---
|
|
555
|
+
if (e.key === 'ArrowDown') {
|
|
556
|
+
e.preventDefault();
|
|
557
|
+
goToPrevScene();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (e.key === 'ArrowUp') {
|
|
561
|
+
e.preventDefault();
|
|
562
|
+
goToNextScene();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
// --- Space: play/pause ---
|
|
566
|
+
if (e.key === ' ') {
|
|
567
|
+
e.preventDefault();
|
|
568
|
+
if (player.isPlaying())
|
|
569
|
+
player.pause();
|
|
570
|
+
else
|
|
571
|
+
player.play();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
// --- Home / End ---
|
|
575
|
+
if (e.key === 'Home') {
|
|
576
|
+
e.preventDefault();
|
|
577
|
+
if (player.isPlaying())
|
|
578
|
+
player.pause();
|
|
579
|
+
player.seekTo(0);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (e.key === 'End') {
|
|
583
|
+
e.preventDefault();
|
|
584
|
+
if (player.isPlaying())
|
|
585
|
+
player.pause();
|
|
586
|
+
player.seekTo(totalDuration - 1);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
// --- J / K / L (NLE transport) ---
|
|
590
|
+
// L = play, tap again to double speed (1→2→4→8 cap)
|
|
591
|
+
// K = pause, reset rate to 1
|
|
592
|
+
// J = rewind 2 seconds (reverse playback is unreliable in browsers)
|
|
593
|
+
if (e.key === 'l' || e.key === 'L') {
|
|
594
|
+
e.preventDefault();
|
|
595
|
+
if (!player.isPlaying()) {
|
|
596
|
+
setPlaybackRate(1);
|
|
597
|
+
player.play();
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
// Cycle to next rate in the list
|
|
601
|
+
setPlaybackRate((prev) => {
|
|
602
|
+
const idx = PLAYBACK_RATES.indexOf(prev);
|
|
603
|
+
return PLAYBACK_RATES[Math.min((idx === -1 ? 1 : idx) + 1, PLAYBACK_RATES.length - 1)];
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (e.key === 'k' || e.key === 'K') {
|
|
609
|
+
e.preventDefault();
|
|
610
|
+
player.pause();
|
|
611
|
+
setPlaybackRate(1);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (e.key === 'j' || e.key === 'J') {
|
|
615
|
+
e.preventDefault();
|
|
616
|
+
// Seek backward 2 seconds
|
|
617
|
+
if (player.isPlaying())
|
|
618
|
+
player.pause();
|
|
619
|
+
player.seekTo(clamp(frame - Math.round(fps * 2)));
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
// --- 0-9: jump to percentage of timeline ---
|
|
623
|
+
if (e.key >= '0' && e.key <= '9' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
624
|
+
e.preventDefault();
|
|
625
|
+
const pct = parseInt(e.key, 10) / 10;
|
|
626
|
+
player.seekTo(Math.floor(totalDuration * pct));
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
window.addEventListener('keydown', onKeyDown);
|
|
631
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
632
|
+
}, [editing, totalDuration, sections, goToPrevScene, goToNextScene, fps]);
|
|
633
|
+
const handleExport = useCallback(async () => {
|
|
634
|
+
setRendering(true);
|
|
635
|
+
setProgress(0);
|
|
636
|
+
const controller = new AbortController();
|
|
637
|
+
abortRef.current = controller;
|
|
638
|
+
try {
|
|
639
|
+
const blob = await renderInBrowser({
|
|
640
|
+
component: Component,
|
|
641
|
+
durationInFrames: totalDuration,
|
|
642
|
+
fps,
|
|
643
|
+
width,
|
|
644
|
+
height,
|
|
645
|
+
scale,
|
|
646
|
+
onProgress: (p) => setProgress(p),
|
|
647
|
+
signal: controller.signal,
|
|
648
|
+
});
|
|
649
|
+
const url = URL.createObjectURL(blob);
|
|
650
|
+
const a = document.createElement('a');
|
|
651
|
+
a.href = url;
|
|
652
|
+
a.download = 'video.mp4';
|
|
653
|
+
a.click();
|
|
654
|
+
URL.revokeObjectURL(url);
|
|
655
|
+
}
|
|
656
|
+
catch (err) {
|
|
657
|
+
if (err.message?.includes('cancelled')) {
|
|
658
|
+
console.log('Export cancelled');
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
console.error('Export failed:', err);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
finally {
|
|
665
|
+
setRendering(false);
|
|
666
|
+
abortRef.current = null;
|
|
667
|
+
}
|
|
668
|
+
}, [Component, totalDuration, fps, width, height, scale]);
|
|
669
|
+
const handleCancel = useCallback(() => {
|
|
670
|
+
abortRef.current?.abort();
|
|
671
|
+
}, []);
|
|
672
|
+
return (_jsxs("div", { className: 'flex gap-3 min-h-screen bg-black', children: [_jsx("div", { className: 'flex flex-col items-center justify-center flex-1 min-w-0', style: { paddingBottom: 96, paddingTop: 24 }, children: _jsx("div", { ref: playerContainerRef, style: { maxWidth: `calc((100vh - 120px) * ${width / height})` }, className: 'w-full overflow-hidden', children: mounted ? (_jsx(Player, { ref: playerRef, component: Component, durationInFrames: totalDuration, fps: fps, compositionWidth: width, compositionHeight: height, loop: true, controls: true, clickToPlay: !editing, spaceKeyToPlayOrPause: true, playbackRate: playbackRate, style: { width: '100%' }, errorFallback: ({ error }) => (_jsx(AbsoluteFill, { style: {
|
|
673
|
+
display: 'flex',
|
|
674
|
+
alignItems: 'center',
|
|
675
|
+
justifyContent: 'center',
|
|
676
|
+
background: '#050505',
|
|
677
|
+
padding: '5% 8%',
|
|
678
|
+
}, children: _jsxs("div", { style: {
|
|
679
|
+
display: 'flex',
|
|
680
|
+
flexDirection: 'column',
|
|
681
|
+
alignItems: 'center',
|
|
682
|
+
gap: '1rem',
|
|
683
|
+
maxWidth: '80%',
|
|
684
|
+
textAlign: 'center',
|
|
685
|
+
}, children: [_jsx("span", { style: {
|
|
686
|
+
fontSize: 48,
|
|
687
|
+
fontWeight: 600,
|
|
688
|
+
color: '#ef4444',
|
|
689
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif',
|
|
690
|
+
letterSpacing: '-0.02em',
|
|
691
|
+
}, children: "Render Error" }), _jsx("pre", { style: {
|
|
692
|
+
fontSize: 14,
|
|
693
|
+
fontWeight: 400,
|
|
694
|
+
color: '#a1a1aa',
|
|
695
|
+
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
|
696
|
+
whiteSpace: 'pre-wrap',
|
|
697
|
+
wordBreak: 'break-word',
|
|
698
|
+
textAlign: 'left',
|
|
699
|
+
maxHeight: '50%',
|
|
700
|
+
overflow: 'auto',
|
|
701
|
+
width: '100%',
|
|
702
|
+
margin: 0,
|
|
703
|
+
lineHeight: 1.5,
|
|
704
|
+
}, children: error.stack || error.message })] }) })) }, resetKey)) : (_jsx("div", { className: 'aspect-video bg-[#050505]' })) }) }), _jsx(TweakpaneRoot, { playerRef: playerRef, fps: fps, sections: sections, entryPath: entryPath }), _jsxs("div", { 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', style: {
|
|
705
|
+
left: `calc((100% - ${SIDEBAR_WIDTH}px - 0.75rem) / 2)`,
|
|
706
|
+
transform: 'translateX(-50%)',
|
|
707
|
+
}, children: [availableEntries.length > 1 && (_jsxs(_Fragment, { children: [_jsx("select", { value: currentRoute, onChange: (e) => {
|
|
708
|
+
const route = e.target.value;
|
|
709
|
+
const base = import.meta.env.BASE_URL ?? '/';
|
|
710
|
+
window.location.href = `${base}${route}`;
|
|
711
|
+
}, 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', style: { maxWidth: 150 }, children: availableEntries.map((route) => (_jsx("option", { value: route, style: { background: '#1c1c1c', color: '#d4d4d8' }, children: route || '(default)' }, route))) }), _jsx(ToolbarSeparator, {})] })), rendering ? (_jsxs(_Fragment, { children: [_jsxs("button", { onClick: handleCancel, 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', children: [_jsx(XIcon, {}), "Cancel"] }), _jsx(ToolbarSeparator, {}), _jsxs("div", { className: 'flex items-center gap-2.5 px-2', children: [_jsx("div", { className: 'w-24 h-1 rounded-full bg-white/10 overflow-hidden', children: _jsx("div", { className: 'h-full rounded-full bg-sky-400 transition-[width] duration-300', style: { width: `${Math.round(progress * 100)}%` } }) }), _jsxs("span", { className: 'text-[13px] text-zinc-500 tabular-nums', children: [Math.round(progress * 100), "%"] })] })] })) : (_jsxs("button", { onClick: handleExport, 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', children: [_jsx(DownloadIcon, {}), "Export MP4"] })), _jsx(ToolbarSeparator, {}), _jsx("button", { onClick: () => {
|
|
712
|
+
setPlaybackRate((prev) => {
|
|
713
|
+
const idx = PLAYBACK_RATES.indexOf(prev);
|
|
714
|
+
const next = PLAYBACK_RATES[(idx + 1) % PLAYBACK_RATES.length];
|
|
715
|
+
return next;
|
|
716
|
+
});
|
|
717
|
+
}, 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', title: 'Playback speed (click to cycle)', children: playbackRate === 1 ? '1x' : `${playbackRate}x` }), _jsx(ToolbarSeparator, {}), _jsxs("div", { className: 'flex items-center', children: [_jsx("button", { onClick: goToPrevScene, disabled: !hasPrevScene, 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', title: 'Previous scene (\u2193)', children: _jsx(ChevronDownIcon, {}) }), _jsx("button", { onClick: goToNextScene, disabled: !hasNextScene, 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', title: 'Next scene (\u2191)', children: _jsx(ChevronUpIcon, {}) })] }), _jsx(ToolbarSeparator, {}), _jsx(LayoutEditor, { playerContainerRef: playerContainerRef, playerRef: playerRef, editing: editing, onEditingChange: setEditing, onReset: () => setResetKey((k) => k + 1), sections: sections, fps: fps, entryPath: entryPath }), generationStatus && (_jsxs(_Fragment, { children: [_jsx(ToolbarSeparator, {}), _jsxs("div", { className: 'flex items-center gap-1.5 px-2.5 py-1.5 text-[13px] font-medium text-amber-400/90', children: [_jsx(SpinnerIcon, {}), _jsx("span", { children: formatGenerationStatus(generationStatus) })] })] })), generationErrors.length > 0 && (_jsxs(_Fragment, { children: [_jsx(ToolbarSeparator, {}), _jsxs("div", { className: 'flex items-center gap-1.5 px-2.5 py-1.5 text-[13px] font-medium text-red-400/90', children: [_jsx("span", { style: { fontSize: 14, lineHeight: 1 }, children: "\u2715" }), _jsx("span", { children: generationErrors.length === 1
|
|
718
|
+
? `${generationErrors[0].namespace} failed: ${generationErrors[0].error.slice(0, 60)}`
|
|
719
|
+
: `${generationErrors.length} generations failed` })] })] }))] })] }));
|
|
720
|
+
}
|