egaki 0.7.2 → 0.8.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 +28 -0
- package/dist/cli/bpm-detect.d.ts +7 -0
- package/dist/cli/bpm-detect.d.ts.map +1 -0
- package/dist/cli/bpm-detect.js +123 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +115 -0
- package/dist/cli/loudness-detect.d.ts +13 -0
- package/dist/cli/loudness-detect.d.ts.map +1 -0
- package/dist/cli/loudness-detect.js +96 -0
- package/dist/vite/code-block.d.ts.map +1 -1
- package/dist/vite/code-block.js +26 -5
- package/dist/vite/layout-editor.js +1 -1
- package/dist/vite/mdx-video.test.js +0 -79
- package/dist/vite/player-page.d.ts.map +1 -1
- package/dist/vite/player-page.js +6 -94
- package/dist/vite/store-hooks.d.ts +2 -0
- package/dist/vite/store-hooks.d.ts.map +1 -1
- package/dist/vite/store-hooks.js +8 -0
- package/dist/vite/store.d.ts +5 -0
- package/dist/vite/store.d.ts.map +1 -1
- package/dist/vite/store.js +5 -0
- package/dist/vite/tweakpane-hook.d.ts +6 -3
- package/dist/vite/tweakpane-hook.d.ts.map +1 -1
- package/dist/vite/tweakpane-hook.js +27 -16
- package/dist/vite/vite-plugin.d.ts +0 -6
- package/dist/vite/vite-plugin.d.ts.map +1 -1
- package/dist/vite/vite-plugin.js +1 -92
- package/package.json +4 -1
- package/skills/egaki/SKILL.md +26 -224
- package/src/cli/bpm-detect.ts +165 -0
- package/src/cli/cli.ts +149 -0
- package/src/cli/loudness-detect.ts +129 -0
- package/src/vite/code-block.tsx +28 -6
- package/src/vite/layout-editor.tsx +1 -1
- package/src/vite/mdx-video.test.tsx +0 -94
- package/src/vite/player-page.tsx +28 -117
- package/src/vite/store-hooks.ts +11 -0
- package/src/vite/store.ts +10 -0
- package/src/vite/tweakpane-hook.tsx +71 -24
- package/src/vite/vite-plugin.ts +1 -91
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// LUFS (Loudness Units Full Scale) measurement for audio files.
|
|
2
|
+
// Ported from meltica. Uses node-web-audio-api (native NAPI Rust binding)
|
|
3
|
+
// as an optional dependency. If missing, returns an Error with install instructions.
|
|
4
|
+
//
|
|
5
|
+
// Implements a simplified LUFS measurement inspired by EBU R128 / ITU-R BS.1770:
|
|
6
|
+
// K-weighting via biquad filters (approximate, not exact BS.1770 coefficients),
|
|
7
|
+
// 400ms blocks with 100ms hop, two-pass gating (absolute -70 LUFS gate, then
|
|
8
|
+
// relative -10 LU gate). Energy averaging is done in the linear domain (mean
|
|
9
|
+
// square) before converting to LUFS, which is the correct approach.
|
|
10
|
+
//
|
|
11
|
+
// The K-weighting filters are simplified (highpass 60Hz + highshelf 1500Hz)
|
|
12
|
+
// rather than the exact BS.1770 pre-filter + RLB coefficients, so results
|
|
13
|
+
// may differ slightly from reference implementations.
|
|
14
|
+
|
|
15
|
+
interface LoudnessResult {
|
|
16
|
+
/** Integrated loudness in LUFS (two-pass gated average). */
|
|
17
|
+
integrated: number
|
|
18
|
+
/** Maximum momentary loudness (single 400ms block). */
|
|
19
|
+
max: number
|
|
20
|
+
/** Minimum momentary loudness above the absolute gate. */
|
|
21
|
+
min: number
|
|
22
|
+
/** Loudness range (max - min) in LU. */
|
|
23
|
+
range: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function lufsFromMeanSquare(ms: number): number {
|
|
27
|
+
return -0.691 + 10 * Math.log10(ms)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function detectLoudness(buffer: ArrayBuffer): Promise<Error | LoudnessResult> {
|
|
31
|
+
let webAudioMod: any
|
|
32
|
+
try {
|
|
33
|
+
webAudioMod = await import('node-web-audio-api')
|
|
34
|
+
} catch {
|
|
35
|
+
return new Error(
|
|
36
|
+
'node-web-audio-api is required for loudness measurement. Install it:\n\n' +
|
|
37
|
+
' pnpm add node-web-audio-api\n',
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const context = new webAudioMod.AudioContext()
|
|
42
|
+
try {
|
|
43
|
+
const audioBuffer: AudioBuffer = await context.decodeAudioData(buffer)
|
|
44
|
+
|
|
45
|
+
const offlineContext = new webAudioMod.OfflineAudioContext(
|
|
46
|
+
audioBuffer.numberOfChannels,
|
|
47
|
+
audioBuffer.length,
|
|
48
|
+
audioBuffer.sampleRate,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const source = offlineContext.createBufferSource()
|
|
52
|
+
source.buffer = audioBuffer
|
|
53
|
+
|
|
54
|
+
// Simplified K-weighting: high-pass at 60Hz + high-shelf boost at 1500Hz
|
|
55
|
+
const highPass = offlineContext.createBiquadFilter()
|
|
56
|
+
highPass.type = 'highpass'
|
|
57
|
+
highPass.frequency.value = 60
|
|
58
|
+
highPass.Q.value = 0.7
|
|
59
|
+
|
|
60
|
+
const highShelf = offlineContext.createBiquadFilter()
|
|
61
|
+
highShelf.type = 'highshelf'
|
|
62
|
+
highShelf.frequency.value = 1500
|
|
63
|
+
highShelf.gain.value = 4
|
|
64
|
+
|
|
65
|
+
source.connect(highPass)
|
|
66
|
+
highPass.connect(highShelf)
|
|
67
|
+
highShelf.connect(offlineContext.destination)
|
|
68
|
+
|
|
69
|
+
source.start(0)
|
|
70
|
+
const rendered: AudioBuffer = await offlineContext.startRendering()
|
|
71
|
+
|
|
72
|
+
// 400ms blocks, 100ms hop
|
|
73
|
+
const blockSize = Math.floor(0.4 * rendered.sampleRate)
|
|
74
|
+
const hopSize = Math.floor(0.1 * rendered.sampleRate)
|
|
75
|
+
|
|
76
|
+
if (rendered.length < blockSize) {
|
|
77
|
+
return new Error('Audio must be at least 400ms for loudness measurement.')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Collect mean square energy per block
|
|
81
|
+
const blocks: number[] = []
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i <= rendered.length - blockSize; i += hopSize) {
|
|
84
|
+
let sumSquared = 0
|
|
85
|
+
for (let ch = 0; ch < rendered.numberOfChannels; ch++) {
|
|
86
|
+
const data = rendered.getChannelData(ch)
|
|
87
|
+
for (let j = 0; j < blockSize; j++) {
|
|
88
|
+
sumSquared += data[i + j]! * data[i + j]!
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
blocks.push(sumSquared / (blockSize * rendered.numberOfChannels))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Two-pass gating per EBU R128:
|
|
95
|
+
// Pass 1: absolute gate at -70 LUFS
|
|
96
|
+
const absoluteGate = -70
|
|
97
|
+
const absoluteGated = blocks.filter((ms) => lufsFromMeanSquare(ms) > absoluteGate)
|
|
98
|
+
|
|
99
|
+
if (absoluteGated.length === 0) {
|
|
100
|
+
return new Error('Audio is silent or below the -70 LUFS gate threshold.')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Pass 2: relative gate at -10 LU below the absolute-gated integrated loudness
|
|
104
|
+
const preliminaryMs = absoluteGated.reduce((s, v) => s + v, 0) / absoluteGated.length
|
|
105
|
+
const relativeGate = lufsFromMeanSquare(preliminaryMs) - 10
|
|
106
|
+
const relativeGated = absoluteGated.filter((ms) => lufsFromMeanSquare(ms) > relativeGate)
|
|
107
|
+
|
|
108
|
+
if (relativeGated.length === 0) {
|
|
109
|
+
return new Error('Audio is too quiet for reliable loudness measurement.')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Integrated loudness: average energy in linear domain, then convert to LUFS
|
|
113
|
+
const integratedMs = relativeGated.reduce((s, v) => s + v, 0) / relativeGated.length
|
|
114
|
+
const integrated = lufsFromMeanSquare(integratedMs)
|
|
115
|
+
|
|
116
|
+
// Momentary loudness values for range calculation (from absolute-gated blocks)
|
|
117
|
+
const momentaryLufs = absoluteGated.map(lufsFromMeanSquare)
|
|
118
|
+
const max = Math.max(...momentaryLufs)
|
|
119
|
+
const min = Math.min(...momentaryLufs)
|
|
120
|
+
const range = max - min
|
|
121
|
+
|
|
122
|
+
return { integrated, max, min, range }
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err instanceof Error) return err
|
|
125
|
+
return new Error(String(err))
|
|
126
|
+
} finally {
|
|
127
|
+
await context.close().catch(() => {})
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/vite/code-block.tsx
CHANGED
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
* - Cloudflare frame: https://github.com/raycast/ray-so/blob/main/app/(navigation)/(code)/components/frames/CloudflareFrame.tsx
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import { createContext, useContext, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
|
36
|
-
import { interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion'
|
|
35
|
+
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
|
36
|
+
import { interpolate, spring, useCurrentFrame, useDelayRender, useVideoConfig } from 'remotion'
|
|
37
37
|
import { useTweakpane } from './tweakpane-hook.tsx'
|
|
38
38
|
|
|
39
39
|
// ---------------------------------------------------------------------------
|
|
@@ -422,12 +422,30 @@ function detectLanguage(title?: string, language?: string): string {
|
|
|
422
422
|
|
|
423
423
|
function useHighlightedHtml(code: string, lang: string, highlightLines: number[]): string {
|
|
424
424
|
const [html, setHtml] = useState('')
|
|
425
|
-
|
|
426
|
-
|
|
425
|
+
const { delayRender, continueRender } = useDelayRender()
|
|
426
|
+
|
|
427
|
+
// delayRender registered during the first render (useState initializer) and
|
|
428
|
+
// continued only AFTER the highlighted html has been committed to the DOM
|
|
429
|
+
// (layout effect below). Continuing from inside the shiki .then() is too
|
|
430
|
+
// early: React batches the setHtml update, so the renderer could capture the
|
|
431
|
+
// frame between continueRender and the commit, producing an empty block in
|
|
432
|
+
// screenshots/filmstrips/exports.
|
|
433
|
+
const [handle] = useState(() => delayRender('shiki highlight'))
|
|
434
|
+
useLayoutEffect(() => {
|
|
435
|
+
if (html) continueRender(handle)
|
|
436
|
+
}, [html, handle, continueRender])
|
|
437
|
+
// Unmount safety: never leave a dangling handle.
|
|
438
|
+
useLayoutEffect(() => {
|
|
439
|
+
return () => continueRender(handle)
|
|
440
|
+
}, [handle, continueRender])
|
|
441
|
+
|
|
442
|
+
useLayoutEffect(() => {
|
|
427
443
|
let cancelled = false
|
|
428
444
|
|
|
429
445
|
void getHighlighter().then((h) => {
|
|
430
|
-
if (cancelled)
|
|
446
|
+
if (cancelled) {
|
|
447
|
+
return
|
|
448
|
+
}
|
|
431
449
|
|
|
432
450
|
// Load language on demand if not already loaded
|
|
433
451
|
const loaded = h.getLoadedLanguages()
|
|
@@ -449,9 +467,13 @@ function useHighlightedHtml(code: string, lang: string, highlightLines: number[]
|
|
|
449
467
|
}],
|
|
450
468
|
})
|
|
451
469
|
if (!cancelled) setHtml(result)
|
|
470
|
+
}).catch(() => {
|
|
471
|
+
continueRender(handle)
|
|
452
472
|
})
|
|
453
473
|
|
|
454
|
-
return () => {
|
|
474
|
+
return () => {
|
|
475
|
+
cancelled = true
|
|
476
|
+
}
|
|
455
477
|
}, [code, lang, highlightLines.join(',')])
|
|
456
478
|
|
|
457
479
|
return html
|
|
@@ -687,7 +687,7 @@ export function LayoutEditor({ playerContainerRef, playerRef, editing, onEditing
|
|
|
687
687
|
<div data-layout-editor-toolbar className='flex items-center gap-1.5 text-[13px]'>
|
|
688
688
|
<ToolbarButton onClick={() => onEditingChange(!editing)} active={editing}
|
|
689
689
|
title={editing ? 'Exit editing mode' : 'Edit layout — click to select, drag to move, corners to scale, double-click text to edit'}>
|
|
690
|
-
{editing ? '
|
|
690
|
+
{editing ? 'Editing' : 'Edit'}
|
|
691
691
|
</ToolbarButton>
|
|
692
692
|
|
|
693
693
|
{editing && changesCount > 0 && (
|
|
@@ -14,7 +14,6 @@ import { mdxParse, extractImports, resolveModulePath } from 'safe-mdx/parse'
|
|
|
14
14
|
import type { EagerModules } from 'safe-mdx/parse'
|
|
15
15
|
import { MdastToJsx } from 'safe-mdx'
|
|
16
16
|
import { splitIntoSections, calculateTotalDuration, resolveAutoDurations, parseFrontmatter, aspectRatioFromDimensions, buildMdxScope } from './mdx-parse.ts'
|
|
17
|
-
import { findChangedSectionIndex } from './vite-plugin.ts'
|
|
18
17
|
import { computeEffectiveDuration } from './media-duration-store.ts'
|
|
19
18
|
import { findServerNodes, blankServerContents, collectServerImportSources, wrapGenerateNodes, collectServerFileImportNames } from './server-mdx.ts'
|
|
20
19
|
import { stableJsonKey, hashKey, promptPrefix } from './server-components.tsx'
|
|
@@ -1978,99 +1977,6 @@ describe('computeEffectiveDuration with startInFrames', () => {
|
|
|
1978
1977
|
})
|
|
1979
1978
|
})
|
|
1980
1979
|
|
|
1981
|
-
describe('findChangedSectionIndex', () => {
|
|
1982
|
-
const base = [
|
|
1983
|
-
'---',
|
|
1984
|
-
'fps: 30',
|
|
1985
|
-
'bpm: 120',
|
|
1986
|
-
'---',
|
|
1987
|
-
'',
|
|
1988
|
-
'# Intro duration=3s',
|
|
1989
|
-
'',
|
|
1990
|
-
'Hello world',
|
|
1991
|
-
'',
|
|
1992
|
-
'# Middle duration=2s',
|
|
1993
|
-
'',
|
|
1994
|
-
'Some content here',
|
|
1995
|
-
'',
|
|
1996
|
-
'# Outro duration=1s',
|
|
1997
|
-
'',
|
|
1998
|
-
'Goodbye',
|
|
1999
|
-
].join('\n')
|
|
2000
|
-
|
|
2001
|
-
test('identical content returns null', () => {
|
|
2002
|
-
expect(findChangedSectionIndex(base, base)).toBe(null)
|
|
2003
|
-
})
|
|
2004
|
-
|
|
2005
|
-
test('editing first section returns 0', () => {
|
|
2006
|
-
const edited = base.replace('Hello world', 'Hello EDITED')
|
|
2007
|
-
expect(findChangedSectionIndex(base, edited)).toBe(0)
|
|
2008
|
-
})
|
|
2009
|
-
|
|
2010
|
-
test('editing second section returns 1', () => {
|
|
2011
|
-
const edited = base.replace('Some content here', 'New content')
|
|
2012
|
-
expect(findChangedSectionIndex(base, edited)).toBe(1)
|
|
2013
|
-
})
|
|
2014
|
-
|
|
2015
|
-
test('editing last section returns 2', () => {
|
|
2016
|
-
const edited = base.replace('Goodbye', 'See ya')
|
|
2017
|
-
expect(findChangedSectionIndex(base, edited)).toBe(2)
|
|
2018
|
-
})
|
|
2019
|
-
|
|
2020
|
-
test('adding a new section returns its index', () => {
|
|
2021
|
-
const added = base + '\n\n# Bonus duration=1s\n\nExtra stuff'
|
|
2022
|
-
expect(findChangedSectionIndex(base, added)).toBe(3)
|
|
2023
|
-
})
|
|
2024
|
-
|
|
2025
|
-
test('deleting last section returns null (skip deletions)', () => {
|
|
2026
|
-
const deleted = base.replace('\n# Outro duration=1s\n\nGoodbye', '')
|
|
2027
|
-
expect(findChangedSectionIndex(base, deleted)).toBe(null)
|
|
2028
|
-
})
|
|
2029
|
-
|
|
2030
|
-
test('deleting middle section returns null (skip deletions)', () => {
|
|
2031
|
-
const deleted = base.replace('\n# Middle duration=2s\n\nSome content here\n', '\n')
|
|
2032
|
-
expect(findChangedSectionIndex(base, deleted)).toBe(null)
|
|
2033
|
-
})
|
|
2034
|
-
|
|
2035
|
-
test('editing heading text returns that section index', () => {
|
|
2036
|
-
const edited = base.replace('# Middle duration=2s', '# Middle Renamed duration=2s')
|
|
2037
|
-
expect(findChangedSectionIndex(base, edited)).toBe(1)
|
|
2038
|
-
})
|
|
2039
|
-
|
|
2040
|
-
test('preamble-only change returns null', () => {
|
|
2041
|
-
const withPreamble = '<Audio src="/bg.mp3" />\n\n' + base.split('\n').slice(4).join('\n')
|
|
2042
|
-
const oldPreamble = '---\nfps: 30\nbpm: 120\n---\n\n' + withPreamble
|
|
2043
|
-
const newPreamble = oldPreamble.replace('/bg.mp3', '/new-bg.mp3')
|
|
2044
|
-
expect(findChangedSectionIndex(oldPreamble, newPreamble)).toBe(null)
|
|
2045
|
-
})
|
|
2046
|
-
|
|
2047
|
-
test('frontmatter-only change returns null', () => {
|
|
2048
|
-
const edited = base.replace('fps: 30', 'fps: 60')
|
|
2049
|
-
expect(findChangedSectionIndex(base, edited)).toBe(null)
|
|
2050
|
-
})
|
|
2051
|
-
|
|
2052
|
-
test('editing heading duration returns that section index', () => {
|
|
2053
|
-
const edited = base.replace('duration=3s', 'duration=4s')
|
|
2054
|
-
expect(findChangedSectionIndex(base, edited)).toBe(0)
|
|
2055
|
-
})
|
|
2056
|
-
|
|
2057
|
-
test('editing heading transition returns that section index', () => {
|
|
2058
|
-
const old = '# Intro duration=3s transition=10\n\nHello'
|
|
2059
|
-
const edited = '# Intro duration=3s transition=20\n\nHello'
|
|
2060
|
-
expect(findChangedSectionIndex(old, edited)).toBe(0)
|
|
2061
|
-
})
|
|
2062
|
-
|
|
2063
|
-
test('broken MDX returns null (does not throw)', () => {
|
|
2064
|
-
const valid = '# Intro duration=3s\n\nHello world'
|
|
2065
|
-
const broken = '# Intro duration=3s\n\n<Component'
|
|
2066
|
-
expect(findChangedSectionIndex(valid, broken)).toBe(null)
|
|
2067
|
-
})
|
|
2068
|
-
|
|
2069
|
-
test('both sources broken returns null', () => {
|
|
2070
|
-
expect(findChangedSectionIndex('<Foo', '<Bar')).toBe(null)
|
|
2071
|
-
})
|
|
2072
|
-
})
|
|
2073
|
-
|
|
2074
1980
|
// ---------------------------------------------------------------------------
|
|
2075
1981
|
// Parse error recovery
|
|
2076
1982
|
// ---------------------------------------------------------------------------
|
package/src/vite/player-page.tsx
CHANGED
|
@@ -27,7 +27,7 @@ import { renderInBrowser } from './render-client.ts'
|
|
|
27
27
|
import { createSpiceflowFetch } from 'spiceflow/client'
|
|
28
28
|
import type { app } from './app.tsx'
|
|
29
29
|
import { egakiStore } from './store.ts'
|
|
30
|
-
import { useGenerationStatus, useGenerationErrors, type GenerationStatus } from './store-hooks.ts'
|
|
30
|
+
import { useGenerationStatus, useGenerationErrors, useSidebarOpen, type GenerationStatus } from './store-hooks.ts'
|
|
31
31
|
import { egakiSDK } from './sdk.ts'
|
|
32
32
|
import { LayoutEditor, type SectionMeta } from './layout-editor.tsx'
|
|
33
33
|
import { TweakpaneRoot, SIDEBAR_WIDTH } from './tweakpane-hook.tsx'
|
|
@@ -42,20 +42,6 @@ import {
|
|
|
42
42
|
import { SectionIndexContext } from './media-duration-store.ts'
|
|
43
43
|
import type { VideoFrontmatter } from './mdx-parse.ts'
|
|
44
44
|
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
// HMR scene-changed: store pending seek at module scope so it survives
|
|
47
|
-
// component remounts caused by rsc:update (spiceflow payload swap).
|
|
48
|
-
// The vite plugin sends egaki:scene-changed with { sectionIndex } when
|
|
49
|
-
// the user edits a specific section of the entry MDX.
|
|
50
|
-
// ---------------------------------------------------------------------------
|
|
51
|
-
let pendingSceneSeek: { sectionIndex: number; file: string } | null = null
|
|
52
|
-
/** Cancels the auto-pause listener from a previous scene-seek, so a new
|
|
53
|
-
* HMR during playback doesn't leave stale listeners that pause the wrong scene. */
|
|
54
|
-
let cancelPreviousAutoPlay: (() => void) | null = null
|
|
55
|
-
/** Monotonic counter to invalidate stale requestAnimationFrame callbacks
|
|
56
|
-
* when rapid successive HMR edits arrive before the first rAF fires. */
|
|
57
|
-
let sceneSeekVersion = 0
|
|
58
|
-
|
|
59
45
|
// ---------------------------------------------------------------------------
|
|
60
46
|
// Generation progress client — fetches /api/generation-progress on page
|
|
61
47
|
// load and on every rsc:update using the typed Spiceflow fetch client.
|
|
@@ -131,9 +117,6 @@ function connectToProgress() {
|
|
|
131
117
|
if (typeof window !== 'undefined') connectToProgress()
|
|
132
118
|
|
|
133
119
|
if (import.meta.hot) {
|
|
134
|
-
import.meta.hot.on('egaki:scene-changed', (data: { sectionIndex: number; file: string }) => {
|
|
135
|
-
pendingSceneSeek = data
|
|
136
|
-
})
|
|
137
120
|
// Reconnect on rsc:update — new generations may have started from
|
|
138
121
|
// MDX edits that trigger server-side rendering of <Server> blocks.
|
|
139
122
|
import.meta.hot.on('rsc:update', () => {
|
|
@@ -488,81 +471,6 @@ export function PlayerPage({
|
|
|
488
471
|
})
|
|
489
472
|
}, [Component, totalDuration, sections, fps, width, height, scale])
|
|
490
473
|
|
|
491
|
-
// Consume pending scene seek after the RSC refetch delivers new sections.
|
|
492
|
-
// The module-level pendingSceneSeek is set by import.meta.hot.on before
|
|
493
|
-
// React re-renders; this effect fires after the commit with fresh data.
|
|
494
|
-
// Seeks to the changed section's start frame, plays it, and pauses at
|
|
495
|
-
// the section's end so the user sees exactly the content they edited.
|
|
496
|
-
useEffect(() => {
|
|
497
|
-
if (pendingSceneSeek == null) return
|
|
498
|
-
// Ignore scene changes from a different entry MDX file.
|
|
499
|
-
if (pendingSceneSeek.file !== entryPath) {
|
|
500
|
-
pendingSceneSeek = null
|
|
501
|
-
return
|
|
502
|
-
}
|
|
503
|
-
const idx = pendingSceneSeek.sectionIndex
|
|
504
|
-
pendingSceneSeek = null
|
|
505
|
-
|
|
506
|
-
// Wait one frame for the Remotion Player to initialize after remount.
|
|
507
|
-
// Version token ensures stale rAF callbacks from rapid edits are ignored.
|
|
508
|
-
const version = ++sceneSeekVersion
|
|
509
|
-
requestAnimationFrame(() => {
|
|
510
|
-
if (version !== sceneSeekVersion) return
|
|
511
|
-
const player = playerRef.current
|
|
512
|
-
if (!player || idx < 0 || idx >= sections.length) return
|
|
513
|
-
|
|
514
|
-
// Compute section boundaries from current sections
|
|
515
|
-
let startFrame = 0
|
|
516
|
-
for (let i = 0; i < idx; i++) {
|
|
517
|
-
startFrame += sections[i]!.durationInFrames
|
|
518
|
-
}
|
|
519
|
-
const endFrame = startFrame + sections[idx]!.durationInFrames - 1
|
|
520
|
-
|
|
521
|
-
player.seekTo(startFrame)
|
|
522
|
-
|
|
523
|
-
// Cancel any auto-pause from a previous HMR scene-seek so stale
|
|
524
|
-
// listeners don't pause playback in the wrong section.
|
|
525
|
-
cancelPreviousAutoPlay?.()
|
|
526
|
-
cancelPreviousAutoPlay = null
|
|
527
|
-
|
|
528
|
-
// Only auto-play if currently paused (after rsc:update remount
|
|
529
|
-
// the player always starts paused at frame 0)
|
|
530
|
-
if (!player.isPlaying()) {
|
|
531
|
-
player.play()
|
|
532
|
-
|
|
533
|
-
// Pause at section end so only the edited section plays.
|
|
534
|
-
// If the user manually seeks (clicks timeline, arrow keys, etc.)
|
|
535
|
-
// cancel the auto-pause so we don't randomly stop playback later.
|
|
536
|
-
let cancelled = false
|
|
537
|
-
const cleanup = () => {
|
|
538
|
-
if (cancelled) return
|
|
539
|
-
cancelled = true
|
|
540
|
-
cancelPreviousAutoPlay = null
|
|
541
|
-
player.removeEventListener('frameupdate', onFrame as any)
|
|
542
|
-
player.removeEventListener('seeked', onSeek as any)
|
|
543
|
-
}
|
|
544
|
-
cancelPreviousAutoPlay = cleanup
|
|
545
|
-
const onFrame = () => {
|
|
546
|
-
if (cancelled) return
|
|
547
|
-
if (player.getCurrentFrame() >= endFrame) {
|
|
548
|
-
player.pause()
|
|
549
|
-
cleanup()
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
const onSeek = (e: any) => {
|
|
553
|
-
// User seeked away — cancel auto-pause. Ignore seeks within
|
|
554
|
-
// the target section range (could be our own seekTo settling).
|
|
555
|
-
const frame = e?.detail?.frame ?? player.getCurrentFrame()
|
|
556
|
-
if (frame < startFrame || frame > endFrame) {
|
|
557
|
-
cleanup()
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
player.addEventListener('frameupdate', onFrame as any)
|
|
561
|
-
player.addEventListener('seeked', onSeek as any)
|
|
562
|
-
}
|
|
563
|
-
})
|
|
564
|
-
}, [sections])
|
|
565
|
-
|
|
566
474
|
// Defer Player mount to client only. Remotion's Player and all composition
|
|
567
475
|
// components (Series, AbsoluteFill, Audio, Video) use React context provided
|
|
568
476
|
// by Player at runtime. During SSR there's no Remotion context, so hooks like
|
|
@@ -573,6 +481,7 @@ export function PlayerPage({
|
|
|
573
481
|
|
|
574
482
|
const generationStatus = useGenerationStatus()
|
|
575
483
|
const generationErrors = useGenerationErrors()
|
|
484
|
+
const sidebarOpen = useSidebarOpen()
|
|
576
485
|
|
|
577
486
|
const [editing, setEditing] = useState(false)
|
|
578
487
|
const [resetKey, setResetKey] = useState(0)
|
|
@@ -961,17 +870,29 @@ export function PlayerPage({
|
|
|
961
870
|
</div>
|
|
962
871
|
</div>
|
|
963
872
|
|
|
964
|
-
{/* Right sidebar —
|
|
873
|
+
{/* Right sidebar — collapsible, hosts tweakpane controls */}
|
|
965
874
|
<TweakpaneRoot playerRef={playerRef} fps={fps} sections={sections} entryPath={entryPath} />
|
|
966
875
|
|
|
967
876
|
{/* Floating toolbar — fixed at bottom, offset left to account for sidebar */}
|
|
968
877
|
<div
|
|
969
878
|
className='fixed bottom-6 flex items-center gap-1.5 rounded-full bg-[#1c1c1c] border border-white/10 px-2 py-1.5 shadow-2xl'
|
|
970
879
|
style={{
|
|
971
|
-
left: `calc((100% - ${SIDEBAR_WIDTH}px - 0.75rem) / 2)
|
|
880
|
+
left: sidebarOpen ? `calc((100% - ${SIDEBAR_WIDTH}px - 0.75rem) / 2)` : '50%',
|
|
972
881
|
transform: 'translateX(-50%)',
|
|
973
882
|
}}
|
|
974
883
|
>
|
|
884
|
+
{/* Previous scene (Down) — first item in the toolbar */}
|
|
885
|
+
<button
|
|
886
|
+
onClick={goToPrevScene}
|
|
887
|
+
disabled={!hasPrevScene}
|
|
888
|
+
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'
|
|
889
|
+
title='Previous scene (↓)'
|
|
890
|
+
>
|
|
891
|
+
<ChevronDownIcon />
|
|
892
|
+
</button>
|
|
893
|
+
|
|
894
|
+
<ToolbarSeparator />
|
|
895
|
+
|
|
975
896
|
{/* Entry selector — only shown when multiple MDX files exist */}
|
|
976
897
|
{availableEntries.length > 1 && (
|
|
977
898
|
<>
|
|
@@ -1046,28 +967,6 @@ export function PlayerPage({
|
|
|
1046
967
|
|
|
1047
968
|
<ToolbarSeparator />
|
|
1048
969
|
|
|
1049
|
-
{/* Scene navigation — prev (Down) / next (Up) */}
|
|
1050
|
-
<div className='flex items-center'>
|
|
1051
|
-
<button
|
|
1052
|
-
onClick={goToPrevScene}
|
|
1053
|
-
disabled={!hasPrevScene}
|
|
1054
|
-
className='flex items-center justify-center rounded-full w-7 h-7 text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-zinc-400'
|
|
1055
|
-
title='Previous scene (↓)'
|
|
1056
|
-
>
|
|
1057
|
-
<ChevronDownIcon />
|
|
1058
|
-
</button>
|
|
1059
|
-
<button
|
|
1060
|
-
onClick={goToNextScene}
|
|
1061
|
-
disabled={!hasNextScene}
|
|
1062
|
-
className='flex items-center justify-center rounded-full w-7 h-7 text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-zinc-400'
|
|
1063
|
-
title='Next scene (↑)'
|
|
1064
|
-
>
|
|
1065
|
-
<ChevronUpIcon />
|
|
1066
|
-
</button>
|
|
1067
|
-
</div>
|
|
1068
|
-
|
|
1069
|
-
<ToolbarSeparator />
|
|
1070
|
-
|
|
1071
970
|
<LayoutEditor
|
|
1072
971
|
playerContainerRef={playerContainerRef}
|
|
1073
972
|
playerRef={playerRef}
|
|
@@ -1101,6 +1000,18 @@ export function PlayerPage({
|
|
|
1101
1000
|
</div>
|
|
1102
1001
|
</>
|
|
1103
1002
|
)}
|
|
1003
|
+
|
|
1004
|
+
<ToolbarSeparator />
|
|
1005
|
+
|
|
1006
|
+
{/* Next scene (Up) — last item in the toolbar */}
|
|
1007
|
+
<button
|
|
1008
|
+
onClick={goToNextScene}
|
|
1009
|
+
disabled={!hasNextScene}
|
|
1010
|
+
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'
|
|
1011
|
+
title='Next scene (↑)'
|
|
1012
|
+
>
|
|
1013
|
+
<ChevronUpIcon />
|
|
1014
|
+
</button>
|
|
1104
1015
|
</div>
|
|
1105
1016
|
</div>
|
|
1106
1017
|
)
|
package/src/vite/store-hooks.ts
CHANGED
|
@@ -85,6 +85,12 @@ const getGenerationErrors = () => egakiStore.getState().serverGenerationErrors
|
|
|
85
85
|
const EMPTY_ERRORS: GenerationError[] = []
|
|
86
86
|
const getGenerationErrorsServer = () => EMPTY_ERRORS
|
|
87
87
|
|
|
88
|
+
// Sidebar open state
|
|
89
|
+
const subscribeSidebarOpen = (callback: () => void) =>
|
|
90
|
+
egakiStore.subscribe((state) => state.sidebarOpen, callback)
|
|
91
|
+
const getSidebarOpen = () => egakiStore.getState().sidebarOpen
|
|
92
|
+
const getSidebarOpenServer = () => false
|
|
93
|
+
|
|
88
94
|
// Modules
|
|
89
95
|
const subscribeModules = (callback: () => void) =>
|
|
90
96
|
egakiStore.subscribe((state) => state.modules, callback)
|
|
@@ -109,6 +115,11 @@ export function useGenerationErrors(): GenerationError[] {
|
|
|
109
115
|
return useSyncExternalStore(subscribeGenerationErrors, getGenerationErrors, getGenerationErrorsServer)
|
|
110
116
|
}
|
|
111
117
|
|
|
118
|
+
/** Hook: whether the right tweakpane sidebar is open. */
|
|
119
|
+
export function useSidebarOpen(): boolean {
|
|
120
|
+
return useSyncExternalStore(subscribeSidebarOpen, getSidebarOpen, getSidebarOpenServer)
|
|
121
|
+
}
|
|
122
|
+
|
|
112
123
|
/** Hook: user modules from virtual:egaki-modules. */
|
|
113
124
|
export function useModules(): EagerModules {
|
|
114
125
|
return useSyncExternalStore(subscribeModules, getModules, getModules)
|
package/src/vite/store.ts
CHANGED
|
@@ -70,6 +70,10 @@ export interface EgakiClientState {
|
|
|
70
70
|
/** Recent generation errors from the server progress stream.
|
|
71
71
|
* Errors auto-clear after ERROR_DISPLAY_DURATION_MS. */
|
|
72
72
|
serverGenerationErrors: GenerationError[]
|
|
73
|
+
|
|
74
|
+
/** Whether the right tweakpane sidebar is open. Closed by default so the
|
|
75
|
+
* video takes the full width. */
|
|
76
|
+
sidebarOpen: boolean
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
// ---------------------------------------------------------------------------
|
|
@@ -83,9 +87,15 @@ export const egakiStore = createStore<EgakiClientState>()(
|
|
|
83
87
|
serverGenerationStatus: null as GenerationStatus | null,
|
|
84
88
|
serverGenerationEntries: [] as GenerationProgressEntry[],
|
|
85
89
|
serverGenerationErrors: [] as GenerationError[],
|
|
90
|
+
sidebarOpen: false as boolean,
|
|
86
91
|
})),
|
|
87
92
|
)
|
|
88
93
|
|
|
94
|
+
/** Open or close the right tweakpane sidebar. */
|
|
95
|
+
export function setSidebarOpen(open: boolean) {
|
|
96
|
+
egakiStore.setState({ sidebarOpen: open })
|
|
97
|
+
}
|
|
98
|
+
|
|
89
99
|
// ---------------------------------------------------------------------------
|
|
90
100
|
// Selectors — derive values from state
|
|
91
101
|
// ---------------------------------------------------------------------------
|