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