egaki 0.7.2 → 0.9.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 (63) hide show
  1. package/CHANGELOG.md +52 -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/angled-screen-shader.d.ts +45 -0
  11. package/dist/vite/angled-screen-shader.d.ts.map +1 -0
  12. package/dist/vite/angled-screen-shader.js +573 -0
  13. package/dist/vite/angled-screen.d.ts +12 -3
  14. package/dist/vite/angled-screen.d.ts.map +1 -1
  15. package/dist/vite/angled-screen.js +12 -3
  16. package/dist/vite/code-block.d.ts.map +1 -1
  17. package/dist/vite/code-block.js +26 -5
  18. package/dist/vite/layout-editor.js +1 -1
  19. package/dist/vite/mdx-video.d.ts +4 -2
  20. package/dist/vite/mdx-video.d.ts.map +1 -1
  21. package/dist/vite/mdx-video.js +4 -2
  22. package/dist/vite/mdx-video.test.js +1 -79
  23. package/dist/vite/player-page.d.ts.map +1 -1
  24. package/dist/vite/player-page.js +6 -94
  25. package/dist/vite/render-client.d.ts +27 -0
  26. package/dist/vite/render-client.d.ts.map +1 -1
  27. package/dist/vite/render-client.js +47 -51
  28. package/dist/vite/sdk.d.ts.map +1 -1
  29. package/dist/vite/sdk.js +4 -3
  30. package/dist/vite/shader-renderer.d.ts +4 -0
  31. package/dist/vite/shader-renderer.d.ts.map +1 -1
  32. package/dist/vite/shader-renderer.js +3 -3
  33. package/dist/vite/store-hooks.d.ts +2 -0
  34. package/dist/vite/store-hooks.d.ts.map +1 -1
  35. package/dist/vite/store-hooks.js +8 -0
  36. package/dist/vite/store.d.ts +5 -0
  37. package/dist/vite/store.d.ts.map +1 -1
  38. package/dist/vite/store.js +5 -0
  39. package/dist/vite/tweakpane-hook.d.ts +12 -3
  40. package/dist/vite/tweakpane-hook.d.ts.map +1 -1
  41. package/dist/vite/tweakpane-hook.js +128 -20
  42. package/dist/vite/vite-plugin.d.ts +0 -6
  43. package/dist/vite/vite-plugin.d.ts.map +1 -1
  44. package/dist/vite/vite-plugin.js +1 -92
  45. package/package.json +9 -6
  46. package/skills/egaki/SKILL.md +26 -224
  47. package/src/cli/bpm-detect.ts +165 -0
  48. package/src/cli/cli.ts +149 -0
  49. package/src/cli/loudness-detect.ts +129 -0
  50. package/src/vite/angled-screen-shader.tsx +706 -0
  51. package/src/vite/angled-screen.tsx +13 -4
  52. package/src/vite/code-block.tsx +28 -6
  53. package/src/vite/layout-editor.tsx +1 -1
  54. package/src/vite/mdx-video.test.tsx +1 -94
  55. package/src/vite/mdx-video.tsx +4 -1
  56. package/src/vite/player-page.tsx +28 -117
  57. package/src/vite/render-client.ts +52 -51
  58. package/src/vite/sdk.ts +4 -3
  59. package/src/vite/shader-renderer.tsx +3 -3
  60. package/src/vite/store-hooks.ts +11 -0
  61. package/src/vite/store.ts +10 -0
  62. package/src/vite/tweakpane-hook.tsx +173 -28
  63. package/src/vite/vite-plugin.ts +1 -91
@@ -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
  // ---------------------------------------------------------------------------
@@ -18,11 +18,21 @@
18
18
  * Tweakpane is pure DOM (no React dependency), so there are no dual-React
19
19
  * or SSR issues. During SSR (typeof window === 'undefined') the hook
20
20
  * returns the raw default values and skips all DOM work.
21
+ *
22
+ * Live props vs user overrides: each param follows the LIVE schema value
23
+ * passed on every render (so animated props like translateX={interpolate(...)}
24
+ * work) until the user touches that control in the pane. Once touched, the
25
+ * user's value wins for that key until the component remounts. Untouched
26
+ * controls are refreshed in the pane every render so animated values are
27
+ * visible while playing. Programmatic refreshes are guarded so tweakpane's
28
+ * change events (which fire on refresh() too) don't mark keys as touched.
21
29
  */
22
30
 
23
31
  import { useCallback, useEffect, useRef, useState, useSyncExternalStore, createContext, useContext } from 'react'
24
32
  import type { PlayerRef } from '@remotion/player'
25
33
  import { LayoutContainerContext, useIsPremounting } from './mdx-video.tsx'
34
+ import { setSidebarOpen } from './store.ts'
35
+ import { useSidebarOpen } from './store-hooks.ts'
26
36
 
27
37
  // ---------------------------------------------------------------------------
28
38
  // Types
@@ -127,6 +137,10 @@ interface FolderEntry {
127
137
  params: Record<string, unknown>
128
138
  /** The original default values for diffing. */
129
139
  defaults: Record<string, unknown>
140
+ /** Keys the user changed via the pane UI. Only these count as "changed"
141
+ * in the copy prompt — untouched keys track live (possibly animated)
142
+ * props, so diffing them against mount-time defaults would be noise. */
143
+ touched: Set<string>
130
144
  }
131
145
  const activeFolders = new Map<string, FolderEntry>()
132
146
 
@@ -144,6 +158,22 @@ function cloneValue<T>(value: T): T {
144
158
  return value
145
159
  }
146
160
 
161
+ /** Deep-equal for param values: primitives, small arrays (bezier tuples),
162
+ * and flat objects (range {min, max}). */
163
+ function valuesEqual(a: unknown, b: unknown): boolean {
164
+ if (a === b) return true
165
+ if (Array.isArray(a) && Array.isArray(b)) {
166
+ return a.length === b.length && a.every((x, i) => x === b[i])
167
+ }
168
+ if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
169
+ const ra = a as Record<string, unknown>
170
+ const rb = b as Record<string, unknown>
171
+ const ka = Object.keys(ra)
172
+ return ka.length === Object.keys(rb).length && ka.every((k) => ra[k] === rb[k])
173
+ }
174
+ return false
175
+ }
176
+
147
177
  function resolveDefault(v: ParamValue): number | boolean | string | [number, number, number, number] | { min: number; max: number } {
148
178
  if (isCubicBezierParam(v)) return [...v.value]
149
179
  if (isRangeParam(v)) return { ...v.value }
@@ -277,7 +307,10 @@ function serializeTweakpanePrompt(
277
307
  const changed: string[] = []
278
308
  for (const [key, current] of Object.entries(entry.params)) {
279
309
  const def = entry.defaults[key]
280
- if (current !== def) {
310
+ // Only user-touched keys count as changed. Untouched keys mirror live
311
+ // (possibly animated) props, so comparing them to mount-time defaults
312
+ // would produce false positives on every playing frame.
313
+ if (entry.touched.has(key) && !valuesEqual(current, def)) {
281
314
  changed.push(` ${key}: ${formatValue(current)}`)
282
315
  }
283
316
  }
@@ -378,6 +411,12 @@ function CopyChangesButton() {
378
411
  * Returns a reactive object with current values. Updating a control in the
379
412
  * pane immediately triggers a React re-render with fresh values.
380
413
  *
414
+ * Values follow live props: each key returns the schema value from the
415
+ * CURRENT render until the user touches that control in the pane, so
416
+ * animated props (e.g. `translateX={interpolate(frame, ...)}`) flow through
417
+ * every frame. Once touched, the user's pane value wins for that key until
418
+ * the component remounts.
419
+ *
381
420
  * On unmount the folder is disposed and removed from the pane.
382
421
  */
383
422
  export function useTweakpane<T extends ParamSchema>(
@@ -406,6 +445,22 @@ export function useTweakpane<T extends ParamSchema>(
406
445
  const schemaRef = useRef(schema)
407
446
  schemaRef.current = schema
408
447
 
448
+ // Keys the user changed via the pane UI. Untouched keys follow the live
449
+ // schema value on every render (so animated props work); touched keys keep
450
+ // the user's pane value until remount.
451
+ const touchedRef = useRef<Set<string>>(new Set())
452
+ // Latest user override values, mirrored from state so the mount effect can
453
+ // seed rebuilt folders without re-running on every pane change.
454
+ const valuesRef = useRef(values)
455
+ valuesRef.current = values
456
+ // Live handles to the mounted folder + bound params, used by the per-render
457
+ // sync effect below to push animated prop values into the pane.
458
+ const liveRef = useRef<{ folder: import('tweakpane').FolderApi; params: Record<string, unknown> } | null>(null)
459
+ // True while we call folder.refresh() programmatically. tweakpane emits
460
+ // change events on refresh() too, so handlers must ignore those or every
461
+ // animated frame would mark keys as user-touched.
462
+ const refreshingRef = useRef(false)
463
+
409
464
  // Stable ID for this hook instance
410
465
  const idRef = useRef(`${label}-${Math.random().toString(36).slice(2, 8)}`)
411
466
 
@@ -423,7 +478,10 @@ export function useTweakpane<T extends ParamSchema>(
423
478
  const def = resolveDefault(param)
424
479
  // Clone object/array values so tweakpane's in-place mutations on params
425
480
  // don't corrupt the defaults (used by "Copy changes" diffing).
426
- params[key] = cloneValue(def)
481
+ // Touched keys keep the user's override when the folder is rebuilt
482
+ // (e.g. premount → active transition re-runs this effect).
483
+ const touched = touchedRef.current.has(key)
484
+ params[key] = cloneValue(touched ? valuesRef.current[key] : def)
427
485
  defaults[key] = cloneValue(def)
428
486
  }
429
487
 
@@ -447,6 +505,7 @@ export function useTweakpane<T extends ParamSchema>(
447
505
  picker: 'inline',
448
506
  } as any)
449
507
  ;(blade as any).on('change', (ev: any) => {
508
+ if (refreshingRef.current) return
450
509
  const val = ev.value
451
510
  const tuple: [number, number, number, number] = [
452
511
  Math.round(val.x1 * 1000) / 1000,
@@ -455,6 +514,7 @@ export function useTweakpane<T extends ParamSchema>(
455
514
  Math.round(val.y2 * 1000) / 1000,
456
515
  ]
457
516
  params[key] = tuple
517
+ touchedRef.current.add(key)
458
518
  setValues((prev) => ({ ...prev, [key]: tuple }))
459
519
  })
460
520
  continue
@@ -466,7 +526,9 @@ export function useTweakpane<T extends ParamSchema>(
466
526
  max: param.max,
467
527
  step: param.step,
468
528
  }).on('change', (ev) => {
529
+ if (refreshingRef.current) return
469
530
  const val = ev.value as { min: number; max: number }
531
+ touchedRef.current.add(key)
470
532
  setValues((prev) => ({ ...prev, [key]: { min: val.min, max: val.max } }))
471
533
  })
472
534
  continue
@@ -495,17 +557,22 @@ export function useTweakpane<T extends ParamSchema>(
495
557
  }
496
558
 
497
559
  folder.addBinding(params, key, opts).on('change', (ev) => {
560
+ if (refreshingRef.current) return
561
+ touchedRef.current.add(key)
498
562
  setValues((prev) => ({ ...prev, [key]: ev.value }))
499
563
  })
500
564
  }
501
565
 
566
+ liveRef.current = { folder, params }
567
+
502
568
  // Register for copy prompt
503
- activeFolders.set(id, { label, params, defaults })
569
+ activeFolders.set(id, { label, params, defaults, touched: touchedRef.current })
504
570
  notifyFolderListeners()
505
571
  })()
506
572
 
507
573
  return () => {
508
574
  disposed = true
575
+ liveRef.current = null
509
576
  if (folder) {
510
577
  folder.dispose()
511
578
  folder = null
@@ -520,7 +587,40 @@ export function useTweakpane<T extends ParamSchema>(
520
587
  }
521
588
  }, [label, skip])
522
589
 
523
- return values as ResolvedValues<T>
590
+ // Push live (possibly animated) prop values into the pane for untouched
591
+ // keys. Runs after every render — during playback the component re-renders
592
+ // each frame, so animated values are visible moving in the pane UI.
593
+ // folder.refresh() emits change events; refreshingRef makes handlers
594
+ // ignore them so refreshes never mark keys as touched.
595
+ useEffect(() => {
596
+ const live = liveRef.current
597
+ if (!live) return
598
+ let dirty = false
599
+ for (const [key, param] of Object.entries(schemaRef.current)) {
600
+ if (touchedRef.current.has(key)) continue
601
+ // Bezier blades are not bindings — refresh() won't read params for
602
+ // them. The returned value still follows live props below.
603
+ if (isCubicBezierParam(param)) continue
604
+ const liveVal = resolveDefault(param)
605
+ if (!valuesEqual(live.params[key], liveVal)) {
606
+ live.params[key] = cloneValue(liveVal)
607
+ dirty = true
608
+ }
609
+ }
610
+ if (dirty) {
611
+ refreshingRef.current = true
612
+ live.folder.refresh()
613
+ refreshingRef.current = false
614
+ }
615
+ })
616
+
617
+ // Merge: user-touched keys return the pane value, untouched keys follow
618
+ // the live schema value so animated props work without any extra wiring.
619
+ const merged: Record<string, unknown> = {}
620
+ for (const [key, param] of Object.entries(schema)) {
621
+ merged[key] = touchedRef.current.has(key) ? values[key] : resolveDefault(param)
622
+ }
623
+ return merged as ResolvedValues<T>
524
624
  }
525
625
 
526
626
  // ---------------------------------------------------------------------------
@@ -530,10 +630,32 @@ export function useTweakpane<T extends ParamSchema>(
530
630
  /** Sidebar width constant shared with player-page layout. */
531
631
  export const SIDEBAR_WIDTH = 280
532
632
 
633
+ /** Lucide-style panel-right icon used for both opening and closing the sidebar. */
634
+ function PanelRightIcon() {
635
+ return (
636
+ <svg
637
+ width='16'
638
+ height='16'
639
+ viewBox='0 0 24 24'
640
+ fill='none'
641
+ stroke='currentColor'
642
+ strokeWidth='1.5'
643
+ strokeLinecap='round'
644
+ strokeLinejoin='round'
645
+ >
646
+ <rect x='3' y='3' width='18' height='18' rx='2' />
647
+ <line x1='15' y1='3' x2='15' y2='21' />
648
+ </svg>
649
+ )
650
+ }
651
+
533
652
  /**
534
- * Right sidebar that hosts the tweakpane Pane. Always visible in the page
535
- * layout; the Pane mounts into the scrollable inner div. Also provides
536
- * player metadata (frame, fps, sections) to the copy prompt button.
653
+ * Right sidebar that hosts the tweakpane Pane. Collapsed by default so the
654
+ * video takes the full width; a fixed top-right button opens it. The sidebar
655
+ * container stays mounted while collapsed (hidden via display: none) because
656
+ * useTweakpane registers controls into it — unmounting would lose all
657
+ * registrations until components remount. Also provides player metadata
658
+ * (frame, fps, sections) to the copy prompt button.
537
659
  */
538
660
  export function TweakpaneRoot({
539
661
  playerRef,
@@ -566,28 +688,51 @@ export function TweakpaneRoot({
566
688
  }
567
689
  }, [playerRef, fps, sections, entryPath])
568
690
 
691
+ const sidebarOpen = useSidebarOpen()
692
+
569
693
  return (
570
- <div
571
- className='egaki-sidebar'
572
- style={{
573
- width: SIDEBAR_WIDTH,
574
- minWidth: SIDEBAR_WIDTH,
575
- height: '100vh',
576
- position: 'sticky',
577
- top: 0,
578
- overflowY: 'auto',
579
- overflowX: 'hidden',
580
- background: '#111',
581
- borderLeft: '1px solid rgba(255,255,255,0.08)',
582
- flexShrink: 0,
583
- }}
584
- >
585
- <CopyChangesButton />
694
+ <>
695
+ {!sidebarOpen && (
696
+ <button
697
+ onClick={() => setSidebarOpen(true)}
698
+ title='Open controls sidebar'
699
+ className='fixed top-4 right-4 z-10 flex items-center justify-center rounded-lg w-8 h-8 text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors cursor-pointer'
700
+ >
701
+ <PanelRightIcon />
702
+ </button>
703
+ )}
586
704
  <div
587
- ref={containerRefCallback}
588
- id='egaki-tweakpane'
589
- style={{ width: '100%' }}
590
- />
591
- </div>
705
+ className='egaki-sidebar'
706
+ style={{
707
+ width: SIDEBAR_WIDTH,
708
+ minWidth: SIDEBAR_WIDTH,
709
+ height: '100vh',
710
+ position: 'sticky',
711
+ top: 0,
712
+ overflowY: 'auto',
713
+ overflowX: 'hidden',
714
+ background: '#111',
715
+ borderLeft: '1px solid rgba(255,255,255,0.08)',
716
+ flexShrink: 0,
717
+ display: sidebarOpen ? undefined : 'none',
718
+ }}
719
+ >
720
+ <div className='flex items-center justify-end px-2 pt-2'>
721
+ <button
722
+ onClick={() => setSidebarOpen(false)}
723
+ title='Close controls sidebar'
724
+ className='flex items-center justify-center rounded-lg w-8 h-8 text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors cursor-pointer'
725
+ >
726
+ <PanelRightIcon />
727
+ </button>
728
+ </div>
729
+ <CopyChangesButton />
730
+ <div
731
+ ref={containerRefCallback}
732
+ id='egaki-tweakpane'
733
+ style={{ width: '100%' }}
734
+ />
735
+ </div>
736
+ </>
592
737
  )
593
738
  }
@@ -64,60 +64,6 @@ function resolveSourceToFile(root: string, source: string): string | undefined {
64
64
  return undefined
65
65
  }
66
66
 
67
- /**
68
- * Extract raw source text ranges per section directly from the mdast.
69
- * Works with heading nodes' positions to capture the FULL raw heading
70
- * text including props like `duration=3s` and `transition=20` that
71
- * `splitIntoSections` strips during parsing. Each section spans from
72
- * its heading's start offset to the next heading's start (or EOF).
73
- */
74
- function sectionRawTexts(source: string, mdast: any): string[] {
75
- // Collect the start offsets of each heading node
76
- const headingStarts: number[] = []
77
- for (const node of mdast.children) {
78
- if (node.type === 'heading' && node.position?.start?.offset != null) {
79
- headingStarts.push(node.position.start.offset)
80
- }
81
- }
82
- // Slice source between consecutive headings, trimming trailing whitespace
83
- // so boundary changes (adding/removing a following section) don't cause
84
- // false positives on the preceding section.
85
- return headingStarts.map((start, i) => {
86
- const end = i + 1 < headingStarts.length ? headingStarts[i + 1]! : source.length
87
- return source.slice(start, end).trimEnd()
88
- })
89
- }
90
-
91
- /**
92
- * Compare old and new MDX sources section-by-section. Returns the index
93
- * of the first added or edited section, or null if nothing changed (or
94
- * only deletions occurred, which we skip per design).
95
- */
96
- export function findChangedSectionIndex(oldSource: string, newSource: string): number | null {
97
- try {
98
- const oldTexts = sectionRawTexts(oldSource, mdxParse(oldSource))
99
- const newTexts = sectionRawTexts(newSource, mdxParse(newSource))
100
-
101
- const maxLen = Math.max(oldTexts.length, newTexts.length)
102
- for (let i = 0; i < maxLen; i++) {
103
- if (i >= oldTexts.length) return i // new section added
104
- if (i >= newTexts.length) return null // section deleted, skip
105
- if (oldTexts[i] !== newTexts[i]) {
106
- // Distinguish edit from deletion: if the next old section matches
107
- // the current new section, a section was deleted (shifted down).
108
- // Look ahead to detect deletions at any position, not just the tail.
109
- if (newTexts.length < oldTexts.length && oldTexts[i + 1] === newTexts[i]) {
110
- return null // deletion, skip
111
- }
112
- return i // content changed
113
- }
114
- }
115
- return null // no scene-level change (frontmatter/preamble only)
116
- } catch {
117
- return null // parse error, don't seek
118
- }
119
- }
120
-
121
67
  /** Discover .mdx files in the project root directory (non-recursive).
122
68
  * Only root-level files become entries to avoid import resolution issues
123
69
  * with nested paths. Returns a map of routePath → absolutePath. */
@@ -160,8 +106,6 @@ export function video(options?: VideoPluginOptions): PluginOption[] {
160
106
  let defaultRoute: string = ''
161
107
  /** Whether the user's project has `motion` (framer-motion) installed. */
162
108
  let hasMotion = false
163
- /** Cached previous MDX sources for section-level diff detection, keyed by abs path. */
164
- const previousMdxSources: Map<string, string> = new Map()
165
109
  /** Set of absolute paths of all entry MDX files for quick lookup. */
166
110
  let entryPathSet: Set<string> = new Set()
167
111
 
@@ -217,13 +161,6 @@ export function video(options?: VideoPluginOptions): PluginOption[] {
217
161
  defaultRoute = resolveDefaultRoute(mdxEntries, options?.entry, root)
218
162
  entryPathSet = new Set(mdxEntries.values())
219
163
 
220
- // Seed previous sources for section-level diff detection.
221
- for (const [, absPath] of mdxEntries) {
222
- try {
223
- previousMdxSources.set(absPath, fs.readFileSync(absPath, 'utf-8'))
224
- } catch { /* ignore read errors */ }
225
- }
226
-
227
164
  // Auto-generate egaki-env.d.ts so MDX LSP knows about built-in
228
165
  // components via the global MDXProvidedComponents type. Same
229
166
  // pattern Vite uses for vite-env.d.ts.
@@ -432,19 +369,7 @@ export function video(options?: VideoPluginOptions): PluginOption[] {
432
369
  mdxEntries.set(relPath.replace(/\.mdx$/, ''), absEntry)
433
370
  }
434
371
  defaultRoute = resolveDefaultRoute(mdxEntries, options?.entry, root)
435
- const nextEntryPathSet = new Set(mdxEntries.values())
436
-
437
- // Sync previousMdxSources: seed new entries, prune removed ones.
438
- for (const file of previousMdxSources.keys()) {
439
- if (!nextEntryPathSet.has(file)) previousMdxSources.delete(file)
440
- }
441
- for (const file of nextEntryPathSet) {
442
- if (!previousMdxSources.has(file)) {
443
- try { previousMdxSources.set(file, fs.readFileSync(file, 'utf-8')) }
444
- catch { /* file may be mid-write */ }
445
- }
446
- }
447
- entryPathSet = nextEntryPathSet
372
+ entryPathSet = new Set(mdxEntries.values())
448
373
 
449
374
  invalidateVirtual([RESOLVED_APP, RESOLVED_MDX, RESOLVED_MODULES])
450
375
  if (this.environment.name === 'client') {
@@ -457,26 +382,11 @@ export function video(options?: VideoPluginOptions): PluginOption[] {
457
382
  invalidateVirtual([RESOLVED_APP, RESOLVED_MDX])
458
383
 
459
384
  if (this.environment.name === 'client') {
460
- // Section-level diff detection for the changed entry.
461
- const newMdxSource = fs.readFileSync(normalizedFile, 'utf-8')
462
- const prevSource = previousMdxSources.get(normalizedFile)
463
- const changedSection = prevSource != null
464
- ? findChangedSectionIndex(prevSource, newMdxSource)
465
- : null
466
- previousMdxSources.set(normalizedFile, newMdxSource)
467
-
468
385
  ctx.server.environments.client?.hot.send({
469
386
  type: 'custom',
470
387
  event: 'rsc:update',
471
388
  data: { file: ctx.file },
472
389
  })
473
- if (changedSection != null) {
474
- ctx.server.environments.client?.hot.send({
475
- type: 'custom',
476
- event: 'egaki:scene-changed',
477
- data: { sectionIndex: changedSection, file: normalizedFile },
478
- })
479
- }
480
390
  }
481
391
  return []
482
392
  }