egaki 0.8.0 → 0.10.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 (45) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +39 -0
  4. package/dist/cli/dev-server.d.ts +30 -0
  5. package/dist/cli/dev-server.d.ts.map +1 -0
  6. package/dist/cli/dev-server.js +266 -0
  7. package/dist/cli/dev-server.test.d.ts +2 -0
  8. package/dist/cli/dev-server.test.d.ts.map +1 -0
  9. package/dist/cli/dev-server.test.js +95 -0
  10. package/dist/vite/angled-screen-shader.d.ts +50 -0
  11. package/dist/vite/angled-screen-shader.d.ts.map +1 -0
  12. package/dist/vite/angled-screen-shader.js +616 -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/mdx-video.d.ts +4 -2
  17. package/dist/vite/mdx-video.d.ts.map +1 -1
  18. package/dist/vite/mdx-video.js +4 -2
  19. package/dist/vite/mdx-video.test.js +1 -0
  20. package/dist/vite/render-client.d.ts +27 -0
  21. package/dist/vite/render-client.d.ts.map +1 -1
  22. package/dist/vite/render-client.js +47 -51
  23. package/dist/vite/sdk.d.ts.map +1 -1
  24. package/dist/vite/sdk.js +4 -3
  25. package/dist/vite/shader-renderer.d.ts +4 -0
  26. package/dist/vite/shader-renderer.d.ts.map +1 -1
  27. package/dist/vite/shader-renderer.js +3 -3
  28. package/dist/vite/tweakpane-hook.d.ts +6 -0
  29. package/dist/vite/tweakpane-hook.d.ts.map +1 -1
  30. package/dist/vite/tweakpane-hook.js +101 -4
  31. package/dist/vite/vite-plugin.d.ts.map +1 -1
  32. package/dist/vite/vite-plugin.js +108 -9
  33. package/package.json +7 -11
  34. package/src/cli/cli.ts +50 -0
  35. package/src/cli/dev-server.test.ts +109 -0
  36. package/src/cli/dev-server.ts +303 -0
  37. package/src/vite/angled-screen-shader.tsx +753 -0
  38. package/src/vite/angled-screen.tsx +13 -4
  39. package/src/vite/mdx-video.test.tsx +1 -0
  40. package/src/vite/mdx-video.tsx +4 -1
  41. package/src/vite/render-client.ts +52 -51
  42. package/src/vite/sdk.ts +4 -3
  43. package/src/vite/shader-renderer.tsx +3 -3
  44. package/src/vite/tweakpane-hook.tsx +102 -4
  45. package/src/vite/vite-plugin.ts +117 -9
@@ -1,7 +1,11 @@
1
1
  'use client'
2
2
 
3
3
  /**
4
- * AngledScreen — CSS 3D perspective wrapper with fake bokeh depth-of-field.
4
+ * BasicAngledScreen — CSS 3D perspective wrapper with fake bokeh depth-of-field.
5
+ *
6
+ * The shader-based version (true depth-of-field via WebGL) lives in
7
+ * angled-screen-shader.tsx and is exported as AngledScreen. This CSS version
8
+ * is kept as a lightweight fallback that works without HTML-in-canvas support.
5
9
  *
6
10
  * Wraps any children (images, videos, divs) in a 3D-transformed plane using
7
11
  * CSS `perspective` + `transform: rotateX/Y/Z`. Simulates depth-of-field
@@ -23,7 +27,7 @@
23
27
  import { type CSSProperties, type ReactNode } from 'react'
24
28
  import { useTweakpane } from './tweakpane-hook.tsx'
25
29
 
26
- export interface AngledScreenProps {
30
+ export interface BasicAngledScreenProps {
27
31
  children: ReactNode
28
32
 
29
33
  // --- 3D transform ---
@@ -91,7 +95,12 @@ function autoDirection(rotateX: number, rotateY: number): 'top' | 'bottom' | 'le
91
95
  return rotateX > 0 ? 'bottom' : 'top'
92
96
  }
93
97
 
94
- export function AngledScreen(props: AngledScreenProps) {
98
+ /**
99
+ * @deprecated Use `AngledScreen` (angled-screen-shader.tsx) instead — the
100
+ * WebGL version with true depth-of-field. This CSS version only remains as
101
+ * the automatic fallback for browsers without HTML-in-canvas support.
102
+ */
103
+ export function BasicAngledScreen(props: BasicAngledScreenProps) {
95
104
  const {
96
105
  children,
97
106
  transformOrigin = '50% 50%',
@@ -102,7 +111,7 @@ export function AngledScreen(props: AngledScreenProps) {
102
111
  style,
103
112
  } = props
104
113
 
105
- const tp = useTweakpane('AngledScreen', {
114
+ const tp = useTweakpane('BasicAngledScreen', {
106
115
  perspective: { value: props.perspective ?? 1200, min: 100, max: 3000, step: 10 },
107
116
  rotateX: { value: props.rotateX ?? 8, min: -90, max: 90, step: 0.5 },
108
117
  rotateY: { value: props.rotateY ?? -12, min: -90, max: 90, step: 0.5 },
@@ -27,6 +27,7 @@ describe('MDX_BUILTIN_COMPONENTS', () => {
27
27
  "Audio",
28
28
  "Background",
29
29
  "BandsShader",
30
+ "BasicAngledScreen",
30
31
  "Blur",
31
32
  "BlurReveal",
32
33
  "CodeBlock",
@@ -29,7 +29,8 @@ import {
29
29
  StaggeredFadeUp,
30
30
  ShimmerSweep,
31
31
  } from './components.tsx'
32
- import { AngledScreen } from './angled-screen.tsx'
32
+ import { BasicAngledScreen } from './angled-screen.tsx'
33
+ import { AngledScreen } from './angled-screen-shader.tsx'
33
34
  import { CodeBlock, CODE_THEMES } from './code-block.tsx'
34
35
  import { BandsShader } from './bands-shader.tsx'
35
36
  import { WaveGradientShader } from './wave-gradient-shader.tsx'
@@ -924,6 +925,7 @@ export {
924
925
  StaggeredFadeUp,
925
926
  ShimmerSweep,
926
927
  AngledScreen,
928
+ BasicAngledScreen,
927
929
  CodeBlock,
928
930
  CODE_THEMES,
929
931
  BandsShader,
@@ -953,6 +955,7 @@ export const MDX_BUILTIN_COMPONENTS = {
953
955
  Background,
954
956
  LayoutTransition,
955
957
  AngledScreen,
958
+ BasicAngledScreen,
956
959
  BandsShader,
957
960
  WaveGradientShader,
958
961
  LiquidGradientShader,
@@ -9,32 +9,39 @@ import { ExportContext } from './mdx-video.tsx'
9
9
  import React from 'react'
10
10
 
11
11
  /**
12
- * Cover the web-renderer scaffold during export. The scaffold wrapper has
13
- * position:fixed + visibility:hidden + z-index:-9999, but allowHtmlInCanvas
14
- * sets visibility:visible on the inner layoutsubtree canvas, which can
15
- * make it peek through on the page.
12
+ * Wrap a composition for web-renderer entry points (export, screenshot,
13
+ * filmstrip). The scaffold wrapper is visibility:hidden and Chromium creates
14
+ * no paint records for hidden subtrees, which breaks captureElementImage()
15
+ * inside nested <HtmlInCanvas> (AngledScreen): onInit/onPaint silently never
16
+ * run and exports come out flat. visibility is overridable by descendants,
17
+ * so a visibility:visible div at the composition root restores paint records
18
+ * for the whole tree while deeper visibility:hidden ancestors (e.g.
19
+ * LayoutTransition's inactive instances) keep hiding their subtrees
20
+ * relative to this root.
16
21
  *
17
- * Do NOT modify the scaffold wrapper's own styles (translate, clip-path,
18
- * width/height, opacity). All of those break Chrome's drawElementImage
19
- * paint pipeline and produce black frames in the exported video. The
20
- * layout subtree canvas must remain at full size, in its original position,
21
- * with normal visibility for the capture to work.
22
+ * Tradeoff (accepted): the scaffold sits at z-index:-9999, painting above
23
+ * the page's root background but below all in-flow content — so the
24
+ * rendering composition can peek through wherever the page shows only the
25
+ * body background. Hiding it differently is not possible:
26
+ * - opacity:0 / filter:opacity(0) bake into captureElementImage pixels
27
+ * (verified: capture succeeds but frames come out fully transparent)
28
+ * - translate/clip-path/resize on the scaffold break the drawElementImage
29
+ * paint pipeline (black frames — learned June 2026, commit e501ca5)
30
+ * - visibility:hidden kills paint records entirely (the original bug)
31
+ * An opaque page background or full-viewport player UI fully occludes it.
22
32
  *
23
- * Instead we place an opaque overlay on top (z-index:-9998, one above the
24
- * scaffold's -9999) that visually hides the rendering without touching
25
- * the scaffold's layout or paint properties.
33
+ * TODO: re-evaluate whether this wrapper is still needed once remotion
34
+ * fixes the HtmlInCanvas delayRender scoping / hidden-scaffold capture bug:
35
+ * https://github.com/remotion-dev/remotion/issues/9367
26
36
  */
27
- function injectScaffoldCover(): () => void {
28
- const cover = document.createElement('div')
29
- cover.style.cssText = [
30
- 'position:fixed',
31
- 'inset:0',
32
- 'z-index:-9998',
33
- 'background:#050505',
34
- 'pointer-events:none',
35
- ].join(';')
36
- document.body.appendChild(cover)
37
- return () => cover.remove()
37
+ export function wrapForWebRenderer(component: React.FC): React.FC {
38
+ const Wrapped: React.FC = () =>
39
+ React.createElement(
40
+ 'div',
41
+ { style: { visibility: 'visible', position: 'relative', width: '100%', height: '100%' } },
42
+ React.createElement(component),
43
+ )
44
+ return Wrapped
38
45
  }
39
46
 
40
47
  export async function renderInBrowser(options: {
@@ -48,37 +55,31 @@ export async function renderInBrowser(options: {
48
55
  onProgress?: (progress: number) => void
49
56
  signal?: AbortSignal
50
57
  }) {
51
- const removeCover = injectScaffoldCover()
52
-
53
58
  const ExportWrapped: React.FC = () =>
54
59
  React.createElement(ExportContext.Provider, { value: true },
55
60
  React.createElement(options.component))
56
61
 
57
- try {
58
- const { getBlob } = await renderMediaOnWeb({
59
- composition: {
60
- component: ExportWrapped,
61
- durationInFrames: options.durationInFrames,
62
- fps: options.fps ?? 30,
63
- width: options.width ?? 1920,
64
- height: options.height ?? 1080,
65
- id: 'MdxVideo',
66
- calculateMetadata: null,
67
- },
68
- inputProps: {},
69
- container: 'mp4',
70
- videoCodec: 'h264',
71
- videoBitrate: 'high',
72
- allowHtmlInCanvas: true,
73
- scale: options.scale,
74
- signal: options.signal,
75
- onProgress: ({ progress }) => {
76
- options.onProgress?.(progress)
77
- },
78
- })
62
+ const { getBlob } = await renderMediaOnWeb({
63
+ composition: {
64
+ component: wrapForWebRenderer(ExportWrapped),
65
+ durationInFrames: options.durationInFrames,
66
+ fps: options.fps ?? 30,
67
+ width: options.width ?? 1920,
68
+ height: options.height ?? 1080,
69
+ id: 'MdxVideo',
70
+ calculateMetadata: null,
71
+ },
72
+ inputProps: {},
73
+ container: 'mp4',
74
+ videoCodec: 'h264',
75
+ videoBitrate: 'high',
76
+ allowHtmlInCanvas: true,
77
+ scale: options.scale,
78
+ signal: options.signal,
79
+ onProgress: ({ progress }) => {
80
+ options.onProgress?.(progress)
81
+ },
82
+ })
79
83
 
80
- return getBlob()
81
- } finally {
82
- removeCover()
83
- }
84
+ return getBlob()
84
85
  }
package/src/vite/sdk.ts CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import React from 'react'
15
15
  import { renderStillOnWeb, renderMediaOnWeb } from '@remotion/web-renderer'
16
+ import { wrapForWebRenderer } from './render-client.ts'
16
17
  import { ExportContext } from './mdx-video.tsx'
17
18
  import type {
18
19
  FrameRange,
@@ -403,7 +404,7 @@ class EgakiSDK {
403
404
 
404
405
  const still = await renderStillOnWeb({
405
406
  composition: {
406
- component: c.component,
407
+ component: wrapForWebRenderer(c.component),
407
408
  durationInFrames: c.totalDuration,
408
409
  fps: c.fps,
409
410
  width: c.width,
@@ -502,7 +503,7 @@ class EgakiSDK {
502
503
  for (let i = 0; i < framesToCapture.length; i++) {
503
504
  const still = await renderStillOnWeb({
504
505
  composition: {
505
- component: c.component,
506
+ component: wrapForWebRenderer(c.component),
506
507
  durationInFrames: c.totalDuration,
507
508
  fps: c.fps,
508
509
  width: c.width,
@@ -542,7 +543,7 @@ class EgakiSDK {
542
543
 
543
544
  const { getBlob } = await renderMediaOnWeb({
544
545
  composition: {
545
- component: this.wrapForExport(c.component),
546
+ component: wrapForWebRenderer(this.wrapForExport(c.component)),
546
547
  durationInFrames: c.totalDuration,
547
548
  fps: c.fps,
548
549
  width: c.width,
@@ -71,7 +71,7 @@ export interface ShaderConfig {
71
71
  // ---------------------------------------------------------------------------
72
72
 
73
73
  /** Parse a hex color string (#RGB, #RRGGBB, #RRGGBBAA) to [r, g, b, a] in 0-1 range. */
74
- function parseHexColor(hex: string): [number, number, number, number] {
74
+ export function parseHexColor(hex: string): [number, number, number, number] {
75
75
  let h = hex.replace('#', '')
76
76
  if (h.length === 3 || h.length === 4) {
77
77
  h = h
@@ -172,7 +172,7 @@ function generateFragmentSource(
172
172
  // WebGL helpers
173
173
  // ---------------------------------------------------------------------------
174
174
 
175
- function compileGlShader(
175
+ export function compileGlShader(
176
176
  gl: WebGL2RenderingContext,
177
177
  type: number,
178
178
  source: string,
@@ -189,7 +189,7 @@ function compileGlShader(
189
189
  return shader
190
190
  }
191
191
 
192
- function linkGlProgram(
192
+ export function linkGlProgram(
193
193
  gl: WebGL2RenderingContext,
194
194
  vs: WebGLShader,
195
195
  fs: WebGLShader,
@@ -18,6 +18,14 @@
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'
@@ -129,6 +137,10 @@ interface FolderEntry {
129
137
  params: Record<string, unknown>
130
138
  /** The original default values for diffing. */
131
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>
132
144
  }
133
145
  const activeFolders = new Map<string, FolderEntry>()
134
146
 
@@ -146,6 +158,22 @@ function cloneValue<T>(value: T): T {
146
158
  return value
147
159
  }
148
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
+
149
177
  function resolveDefault(v: ParamValue): number | boolean | string | [number, number, number, number] | { min: number; max: number } {
150
178
  if (isCubicBezierParam(v)) return [...v.value]
151
179
  if (isRangeParam(v)) return { ...v.value }
@@ -279,7 +307,10 @@ function serializeTweakpanePrompt(
279
307
  const changed: string[] = []
280
308
  for (const [key, current] of Object.entries(entry.params)) {
281
309
  const def = entry.defaults[key]
282
- 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)) {
283
314
  changed.push(` ${key}: ${formatValue(current)}`)
284
315
  }
285
316
  }
@@ -380,6 +411,12 @@ function CopyChangesButton() {
380
411
  * Returns a reactive object with current values. Updating a control in the
381
412
  * pane immediately triggers a React re-render with fresh values.
382
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
+ *
383
420
  * On unmount the folder is disposed and removed from the pane.
384
421
  */
385
422
  export function useTweakpane<T extends ParamSchema>(
@@ -408,6 +445,22 @@ export function useTweakpane<T extends ParamSchema>(
408
445
  const schemaRef = useRef(schema)
409
446
  schemaRef.current = schema
410
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
+
411
464
  // Stable ID for this hook instance
412
465
  const idRef = useRef(`${label}-${Math.random().toString(36).slice(2, 8)}`)
413
466
 
@@ -425,7 +478,10 @@ export function useTweakpane<T extends ParamSchema>(
425
478
  const def = resolveDefault(param)
426
479
  // Clone object/array values so tweakpane's in-place mutations on params
427
480
  // don't corrupt the defaults (used by "Copy changes" diffing).
428
- 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)
429
485
  defaults[key] = cloneValue(def)
430
486
  }
431
487
 
@@ -449,6 +505,7 @@ export function useTweakpane<T extends ParamSchema>(
449
505
  picker: 'inline',
450
506
  } as any)
451
507
  ;(blade as any).on('change', (ev: any) => {
508
+ if (refreshingRef.current) return
452
509
  const val = ev.value
453
510
  const tuple: [number, number, number, number] = [
454
511
  Math.round(val.x1 * 1000) / 1000,
@@ -457,6 +514,7 @@ export function useTweakpane<T extends ParamSchema>(
457
514
  Math.round(val.y2 * 1000) / 1000,
458
515
  ]
459
516
  params[key] = tuple
517
+ touchedRef.current.add(key)
460
518
  setValues((prev) => ({ ...prev, [key]: tuple }))
461
519
  })
462
520
  continue
@@ -468,7 +526,9 @@ export function useTweakpane<T extends ParamSchema>(
468
526
  max: param.max,
469
527
  step: param.step,
470
528
  }).on('change', (ev) => {
529
+ if (refreshingRef.current) return
471
530
  const val = ev.value as { min: number; max: number }
531
+ touchedRef.current.add(key)
472
532
  setValues((prev) => ({ ...prev, [key]: { min: val.min, max: val.max } }))
473
533
  })
474
534
  continue
@@ -497,17 +557,22 @@ export function useTweakpane<T extends ParamSchema>(
497
557
  }
498
558
 
499
559
  folder.addBinding(params, key, opts).on('change', (ev) => {
560
+ if (refreshingRef.current) return
561
+ touchedRef.current.add(key)
500
562
  setValues((prev) => ({ ...prev, [key]: ev.value }))
501
563
  })
502
564
  }
503
565
 
566
+ liveRef.current = { folder, params }
567
+
504
568
  // Register for copy prompt
505
- activeFolders.set(id, { label, params, defaults })
569
+ activeFolders.set(id, { label, params, defaults, touched: touchedRef.current })
506
570
  notifyFolderListeners()
507
571
  })()
508
572
 
509
573
  return () => {
510
574
  disposed = true
575
+ liveRef.current = null
511
576
  if (folder) {
512
577
  folder.dispose()
513
578
  folder = null
@@ -522,7 +587,40 @@ export function useTweakpane<T extends ParamSchema>(
522
587
  }
523
588
  }, [label, skip])
524
589
 
525
- 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>
526
624
  }
527
625
 
528
626
  // ---------------------------------------------------------------------------
@@ -20,6 +20,7 @@ import type { Plugin, PluginOption } from 'vite'
20
20
  import { spiceflowPlugin } from 'spiceflow/vite'
21
21
  import react from '@vitejs/plugin-react'
22
22
  import tailwindcss from '@tailwindcss/vite'
23
+ import pc from 'picocolors'
23
24
  import { mdxParse } from 'safe-mdx/parse'
24
25
  import { collectServerImportSources } from './server-mdx.ts'
25
26
  import { parseFrontmatter } from './mdx-parse.ts'
@@ -44,6 +45,25 @@ const RESOLVED_MODULES = '\0' + VIRTUAL_MODULES
44
45
 
45
46
  const PKG_NAME = 'egaki'
46
47
 
48
+ // Packages that must resolve to a single physical copy (same pattern as
49
+ // spiceflow:dedupe-singleton). Remotion Player + useVideoConfig share React
50
+ // context; two copies → "No video config found". Motion patches need one
51
+ // JSAnimation class. resolve.dedupe alone fails under pnpm when the package
52
+ // is only a transitive dep of egaki (not hoisted to the consumer root).
53
+ const dedupePackages = new Set([
54
+ 'remotion',
55
+ '@remotion/player',
56
+ '@remotion/media',
57
+ '@remotion/web-renderer',
58
+ 'motion',
59
+ 'motion-dom',
60
+ 'motion-utils',
61
+ ])
62
+
63
+ // Fake importer anchored inside egaki's package so this.resolve() finds the
64
+ // same remotion/motion copy egaki itself depends on.
65
+ const dedupeImporter = path.join(__pkgRoot, '_dedupe_importer_.js')
66
+
47
67
  export interface VideoPluginOptions {
48
68
  /** Path to the default MDX entry file (relative to vite root or absolute).
49
69
  * When omitted, auto-discovers: video.mdx > index.mdx > first .mdx found.
@@ -258,7 +278,7 @@ export function video(options?: VideoPluginOptions): PluginOption[] {
258
278
  const fullPath = path.join(dir, entry.name)
259
279
  if (entry.isDirectory()) {
260
280
  walkDir(fullPath)
261
- } else if (/\.(tsx?|jsx?|mdx?)$/.test(entry.name) && !/\.(test|spec|config)\./.test(entry.name)) {
281
+ } else if (/\.(tsx?|jsx?|mdx?)$/.test(entry.name) && !/\.(test|spec|config)\./.test(entry.name) && !/\.d\.ts$/.test(entry.name)) {
262
282
  // Entry MDX files are NOT skipped: they may be imported as
263
283
  // partials by other MDX files (e.g. `import Intro from './intro.mdx'`).
264
284
  // Self-import is a user error and harmless (safe-mdx handles it).
@@ -314,6 +334,52 @@ export function video(options?: VideoPluginOptions): PluginOption[] {
314
334
  }
315
335
  },
316
336
 
337
+ // Print SDK usage instructions after Vite's startup banner.
338
+ // Agents read terminal output to learn how to interact with the
339
+ // running dev server via Playwriter + window.egakiSDK.
340
+ configureServer(server) {
341
+ server.httpServer?.on('listening', () => {
342
+ const log = server.config.logger
343
+ const p = (s: string) => log.info(s)
344
+ // Small delay so our block prints after Vite's "ready in" + URL lines
345
+ setTimeout(() => {
346
+ const title = ` ${pc.cyan(pc.bold('egaki SDK'))} ${pc.dim('—')} ${pc.white('programmatic control via')} ${pc.yellow('window.egakiSDK')}`
347
+ const sep = pc.dim(' ─'.padEnd(72, '─'))
348
+
349
+ p('')
350
+ p(title)
351
+ p(sep)
352
+ p('')
353
+ p(` ${pc.green('●')} ${pc.bold('Get info')} ${pc.dim('composition metadata, sections, fps')}`)
354
+ p(` ${pc.cyan(`playwriter -s 1 -e 'console.log(await state.page.evaluate(() => window.egakiSDK.getInfo()))'`)}`)
355
+ p('')
356
+ p(` ${pc.green('●')} ${pc.bold('Screenshot')} ${pc.dim('capture a single frame as PNG')}`)
357
+ p(` ${pc.cyan("playwriter -s 1 -e \"$(cat <<'JS'")}`)
358
+ p(` ${pc.cyan('const dataUrl = await state.page.evaluate(() => window.egakiSDK.screenshot({ frame: 60 }))')}`)
359
+ p(` ${pc.cyan('const buf = Buffer.from(await (await fetch(dataUrl)).arrayBuffer())')}`)
360
+ p(` ${pc.cyan('require("node:fs").writeFileSync("/tmp/frame-60.png", buf)')}`)
361
+ p(` ${pc.cyan('JS')}`)
362
+ p(` ${pc.cyan(')"')}`)
363
+ p('')
364
+ p(` ${pc.green('●')} ${pc.bold('Filmstrip')} ${pc.dim('grid of frames across scenes')}`)
365
+ p(` ${pc.cyan("playwriter -s 1 -e \"$(cat <<'JS'")}`)
366
+ p(` ${pc.cyan('const dataUrl = await state.page.evaluate(() =>')}`)
367
+ p(` ${pc.cyan(' window.egakiSDK.filmstrip({ scenes: [0, 1, 2], framesPerScene: 3 }))')}`)
368
+ p(` ${pc.cyan('const buf = Buffer.from(await (await fetch(dataUrl)).arrayBuffer())')}`)
369
+ p(` ${pc.cyan('require("node:fs").writeFileSync("/tmp/filmstrip.png", buf)')}`)
370
+ p(` ${pc.cyan('JS')}`)
371
+ p(` ${pc.cyan(')"')}`)
372
+ p('')
373
+ p(` ${pc.green('●')} ${pc.bold('Export video')} ${pc.dim('render MP4 via WebCodecs')}`)
374
+ p(` ${pc.cyan(`playwriter -s 1 -e 'await state.page.evaluate(() => window.egakiSDK.export({ path: "output.mp4" }))'`)}`)
375
+ p('')
376
+ p(` ${pc.green('●')} ${pc.bold('Seek')} ${pc.dim('jump to a specific frame')}`)
377
+ p(` ${pc.cyan(`playwriter -s 1 -e 'await state.page.evaluate(() => window.egakiSDK.seekTo(120))'`)}`)
378
+ p('')
379
+ }, 50)
380
+ })
381
+ },
382
+
317
383
  // HMR for file changes in the project.
318
384
  //
319
385
  // Entry MDX: the source string flows server → client through the RSC
@@ -440,16 +506,13 @@ export function video(options?: VideoPluginOptions): PluginOption[] {
440
506
  arr.push(/^tweakpane/)
441
507
  config.resolve.noExternal = arr
442
508
 
443
- // Deduplicate motion packages so egaki's motion-timing virtual module
444
- // patches the same JSAnimation class that motion/react uses. Without
445
- // this, Vite can resolve motion-dom to different instances and
446
- // prototype patches won't affect the user's animations.
447
- // Always applied (not gated by hasMotion) because configEnvironment
448
- // runs before configResolved where hasMotion is set. Harmless when
449
- // motion isn't installed — dedupe on missing packages is a no-op.
509
+ // Baseline hint for Vite's built-in resolver (same list as the
510
+ // resolveId singleton plugin below). Real enforcement is the
511
+ // egaki:dedupe-singleton plugin — resolve.dedupe alone is not enough
512
+ // under pnpm when remotion is only a transitive dep of egaki.
450
513
  config.resolve.dedupe = mergeUnique(
451
514
  config.resolve.dedupe as string[] | undefined,
452
- ['motion', 'motion-dom', 'motion-utils'],
515
+ [...dedupePackages],
453
516
  )
454
517
 
455
518
  if (name === 'client') {
@@ -458,6 +521,14 @@ export function video(options?: VideoPluginOptions): PluginOption[] {
458
521
  config.optimizeDeps.exclude,
459
522
  [PKG_NAME],
460
523
  )
524
+ // Prebundle safe-mdx as a single entry so its pure-CJS leaves
525
+ // (format via fault/micromark) stay internalized with CJS interop.
526
+ // Do NOT list format/fault as separate optimizeDeps entries — that
527
+ // splits them into named-only ESM chunks and the browser crashes with
528
+ // "does not provide an export named 'default'" (same bug Holocron hit
529
+ // when safe-mdx leaked to the client; see holocron vite CHANGELOG).
530
+ // Holocron's clean fix is keep safe-mdx server-only; egaki needs it on
531
+ // the client for MDX-in-browser, so correct prebundling is the fix.
461
532
  config.optimizeDeps.include = mergeUnique(
462
533
  config.optimizeDeps.include,
463
534
  [
@@ -465,6 +536,8 @@ export function video(options?: VideoPluginOptions): PluginOption[] {
465
536
  `${PKG_NAME} > remotion`,
466
537
  `${PKG_NAME} > @remotion/player`,
467
538
  `${PKG_NAME} > safe-mdx`,
539
+ 'safe-mdx',
540
+ 'safe-mdx/parse',
468
541
  ],
469
542
  )
470
543
  }
@@ -479,9 +552,44 @@ export function video(options?: VideoPluginOptions): PluginOption[] {
479
552
  },
480
553
  }
481
554
 
555
+ // Force remotion / motion to a single copy across all importers, even when
556
+ // egaki is a transitive or file:-linked dep under pnpm. Mirrors
557
+ // spiceflow:dedupe-singleton (spiceflow/src/vite.tsx).
558
+ //
559
+ // Vite's resolve.dedupe sets basedir=root then walks node_modules/, but
560
+ // pnpm only symlinks direct deps at the root. Transitive deps stay buried
561
+ // in .pnpm/, so dedupe silently falls back to importer-based resolution
562
+ // and Player vs useVideoConfig load different remotion instances.
563
+ const dedupeSingletonPlugin: Plugin = {
564
+ name: 'egaki:dedupe-singleton',
565
+ enforce: 'pre',
566
+ async resolveId(id, _importer, options) {
567
+ if (
568
+ id.startsWith('.') ||
569
+ id.startsWith('/') ||
570
+ id.startsWith('\0') ||
571
+ id.includes('?')
572
+ ) {
573
+ return null
574
+ }
575
+
576
+ const pkgName = id.startsWith('@')
577
+ ? id.split('/').slice(0, 2).join('/')
578
+ : (id.split('/')[0] ?? id)
579
+
580
+ if (!dedupePackages.has(pkgName)) return null
581
+
582
+ return this.resolve(id, dedupeImporter, {
583
+ ...options,
584
+ skipSelf: true,
585
+ })
586
+ },
587
+ }
588
+
482
589
  return [
483
590
  videoPlugin,
484
591
  rscPackagePlugin,
592
+ dedupeSingletonPlugin,
485
593
  tailwindcss(),
486
594
  spiceflowPlugin({ entry: VIRTUAL_APP }),
487
595
  react(),