egaki 0.7.1 → 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 +32 -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 +9 -9
  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
@@ -23,6 +23,8 @@
23
23
  import { useCallback, useEffect, useRef, useState, useSyncExternalStore, createContext, useContext } from 'react'
24
24
  import type { PlayerRef } from '@remotion/player'
25
25
  import { LayoutContainerContext, useIsPremounting } from './mdx-video.tsx'
26
+ import { setSidebarOpen } from './store.ts'
27
+ import { useSidebarOpen } from './store-hooks.ts'
26
28
 
27
29
  // ---------------------------------------------------------------------------
28
30
  // Types
@@ -530,10 +532,32 @@ export function useTweakpane<T extends ParamSchema>(
530
532
  /** Sidebar width constant shared with player-page layout. */
531
533
  export const SIDEBAR_WIDTH = 280
532
534
 
535
+ /** Lucide-style panel-right icon used for both opening and closing the sidebar. */
536
+ function PanelRightIcon() {
537
+ return (
538
+ <svg
539
+ width='16'
540
+ height='16'
541
+ viewBox='0 0 24 24'
542
+ fill='none'
543
+ stroke='currentColor'
544
+ strokeWidth='1.5'
545
+ strokeLinecap='round'
546
+ strokeLinejoin='round'
547
+ >
548
+ <rect x='3' y='3' width='18' height='18' rx='2' />
549
+ <line x1='15' y1='3' x2='15' y2='21' />
550
+ </svg>
551
+ )
552
+ }
553
+
533
554
  /**
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.
555
+ * Right sidebar that hosts the tweakpane Pane. Collapsed by default so the
556
+ * video takes the full width; a fixed top-right button opens it. The sidebar
557
+ * container stays mounted while collapsed (hidden via display: none) because
558
+ * useTweakpane registers controls into it — unmounting would lose all
559
+ * registrations until components remount. Also provides player metadata
560
+ * (frame, fps, sections) to the copy prompt button.
537
561
  */
538
562
  export function TweakpaneRoot({
539
563
  playerRef,
@@ -566,28 +590,51 @@ export function TweakpaneRoot({
566
590
  }
567
591
  }, [playerRef, fps, sections, entryPath])
568
592
 
593
+ const sidebarOpen = useSidebarOpen()
594
+
569
595
  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 />
596
+ <>
597
+ {!sidebarOpen && (
598
+ <button
599
+ onClick={() => setSidebarOpen(true)}
600
+ title='Open controls sidebar'
601
+ 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'
602
+ >
603
+ <PanelRightIcon />
604
+ </button>
605
+ )}
586
606
  <div
587
- ref={containerRefCallback}
588
- id='egaki-tweakpane'
589
- style={{ width: '100%' }}
590
- />
591
- </div>
607
+ className='egaki-sidebar'
608
+ style={{
609
+ width: SIDEBAR_WIDTH,
610
+ minWidth: SIDEBAR_WIDTH,
611
+ height: '100vh',
612
+ position: 'sticky',
613
+ top: 0,
614
+ overflowY: 'auto',
615
+ overflowX: 'hidden',
616
+ background: '#111',
617
+ borderLeft: '1px solid rgba(255,255,255,0.08)',
618
+ flexShrink: 0,
619
+ display: sidebarOpen ? undefined : 'none',
620
+ }}
621
+ >
622
+ <div className='flex items-center justify-end px-2 pt-2'>
623
+ <button
624
+ onClick={() => setSidebarOpen(false)}
625
+ title='Close controls sidebar'
626
+ 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'
627
+ >
628
+ <PanelRightIcon />
629
+ </button>
630
+ </div>
631
+ <CopyChangesButton />
632
+ <div
633
+ ref={containerRefCallback}
634
+ id='egaki-tweakpane'
635
+ style={{ width: '100%' }}
636
+ />
637
+ </div>
638
+ </>
592
639
  )
593
640
  }
@@ -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
  }