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.
Files changed (40) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/cli/bpm-detect.d.ts +7 -0
  3. package/dist/cli/bpm-detect.d.ts.map +1 -0
  4. package/dist/cli/bpm-detect.js +123 -0
  5. package/dist/cli/cli.d.ts.map +1 -1
  6. package/dist/cli/cli.js +115 -0
  7. package/dist/cli/loudness-detect.d.ts +13 -0
  8. package/dist/cli/loudness-detect.d.ts.map +1 -0
  9. package/dist/cli/loudness-detect.js +96 -0
  10. package/dist/vite/code-block.d.ts.map +1 -1
  11. package/dist/vite/code-block.js +26 -5
  12. package/dist/vite/layout-editor.js +1 -1
  13. package/dist/vite/mdx-video.test.js +0 -79
  14. package/dist/vite/player-page.d.ts.map +1 -1
  15. package/dist/vite/player-page.js +6 -94
  16. package/dist/vite/store-hooks.d.ts +2 -0
  17. package/dist/vite/store-hooks.d.ts.map +1 -1
  18. package/dist/vite/store-hooks.js +8 -0
  19. package/dist/vite/store.d.ts +5 -0
  20. package/dist/vite/store.d.ts.map +1 -1
  21. package/dist/vite/store.js +5 -0
  22. package/dist/vite/tweakpane-hook.d.ts +6 -3
  23. package/dist/vite/tweakpane-hook.d.ts.map +1 -1
  24. package/dist/vite/tweakpane-hook.js +27 -16
  25. package/dist/vite/vite-plugin.d.ts +0 -6
  26. package/dist/vite/vite-plugin.d.ts.map +1 -1
  27. package/dist/vite/vite-plugin.js +1 -92
  28. package/package.json +4 -1
  29. package/skills/egaki/SKILL.md +26 -224
  30. package/src/cli/bpm-detect.ts +165 -0
  31. package/src/cli/cli.ts +149 -0
  32. package/src/cli/loudness-detect.ts +129 -0
  33. package/src/vite/code-block.tsx +28 -6
  34. package/src/vite/layout-editor.tsx +1 -1
  35. package/src/vite/mdx-video.test.tsx +0 -94
  36. package/src/vite/player-page.tsx +28 -117
  37. package/src/vite/store-hooks.ts +11 -0
  38. package/src/vite/store.ts +10 -0
  39. package/src/vite/tweakpane-hook.tsx +71 -24
  40. 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
+ }
@@ -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
- useEffect(() => {
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) return
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 () => { cancelled = true }
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 ? '✦ Editing' : '✦ Edit Layout'}
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
  // ---------------------------------------------------------------------------
@@ -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 — always visible, hosts tweakpane controls */}
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
  )
@@ -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
  // ---------------------------------------------------------------------------