egaki 0.9.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.
@@ -11,7 +11,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  * intersection → plane-local UV → sample DOM texture
12
12
  *
13
13
  * The intersection distance gives true per-pixel depth, which drives:
14
- * - bokeh blur (poisson-disc, radius grows with distance from focus)
14
+ * - bokeh blur (sparse 6-blade hex iris, radius grows with distance from
15
+ * focus — fewer taps so the aperture shape reads as hard disks instead
16
+ * of smoothing into a gaussian)
17
+ * - chromatic aberration (radial R/B split, stronger in the bokeh)
15
18
  * - fog toward backgroundColor (like the original Three.js plugin's
16
19
  * scene fog, unframer-private/plugin-angled-screen BokehPass setup)
17
20
  * - film grain (from the plugin's filmGrainShader, u_time-seeded)
@@ -94,6 +97,7 @@ uniform float u_focusDist; // focus distance in px along the ray
94
97
  uniform float u_aspect; // width / height, for circular bokeh
95
98
  uniform float u_fog;
96
99
  uniform float u_grain;
100
+ uniform float u_ca; // chromatic aberration strength
97
101
  uniform vec4 u_background;
98
102
  uniform float u_debug;
99
103
  uniform float u_time;
@@ -105,21 +109,26 @@ float rand(vec2 co) {
105
109
  return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
106
110
  }
107
111
 
108
- // Three.js BokehShader ring kernel (Martins Upitis lens blur):
109
- // 16 taps at radius 1.0, 8 at 0.9, 8 at 0.7, 8 at 0.4, plus center = 41.
110
- const vec2 RING1[16] = vec2[](
111
- vec2(0.0, 0.4), vec2(0.15, 0.37), vec2(0.29, 0.29), vec2(-0.37, 0.15),
112
- vec2(0.40, 0.0), vec2(0.37, -0.15), vec2(0.29, -0.29), vec2(-0.15, -0.37),
113
- vec2(0.0, -0.4), vec2(-0.15, 0.37), vec2(-0.29, 0.29), vec2(0.37, 0.15),
114
- vec2(-0.4, 0.0), vec2(-0.37, -0.15), vec2(-0.29, -0.29), vec2(0.15, -0.37)
112
+ // Sparse 6-blade hexagonal iris. Far fewer taps than the 41-sample Martins
113
+ // Upitis kernel so the aperture SHAPE reads as discrete hex disks instead of
114
+ // smoothing into a gaussian. Outer ring is weighted heavier so bright points
115
+ // form hard-edged bokeh blobs (the look of real lens DOF on the refs).
116
+ // Unit circle coords at radius 1.0 / 0.55.
117
+ const vec2 HEX_OUTER[6] = vec2[](
118
+ vec2( 1.0000, 0.0000),
119
+ vec2( 0.5000, 0.8660),
120
+ vec2(-0.5000, 0.8660),
121
+ vec2(-1.0000, 0.0000),
122
+ vec2(-0.5000, -0.8660),
123
+ vec2( 0.5000, -0.8660)
115
124
  );
116
- const vec2 RING9[8] = vec2[](
117
- vec2(0.15, 0.37), vec2(-0.37, 0.15), vec2(0.37, -0.15), vec2(-0.15, -0.37),
118
- vec2(-0.15, 0.37), vec2(0.37, 0.15), vec2(-0.37, -0.15), vec2(0.15, -0.37)
119
- );
120
- const vec2 RING7[8] = vec2[](
121
- vec2(0.29, 0.29), vec2(0.40, 0.0), vec2(0.29, -0.29), vec2(0.0, -0.4),
122
- vec2(-0.29, 0.29), vec2(-0.4, 0.0), vec2(-0.29, -0.29), vec2(0.0, 0.4)
125
+ const vec2 HEX_MID[6] = vec2[](
126
+ vec2( 0.5500, 0.0000),
127
+ vec2( 0.2750, 0.4763),
128
+ vec2(-0.2750, 0.4763),
129
+ vec2(-0.5500, 0.0000),
130
+ vec2(-0.2750, -0.4763),
131
+ vec2( 0.2750, -0.4763)
123
132
  );
124
133
 
125
134
  // Intersect the camera ray through screen uv suv with the tilted plane.
@@ -154,6 +163,17 @@ vec4 sampleScene(vec2 suv) {
154
163
  return mix(u_background, c, coverage);
155
164
  }
156
165
 
166
+ // Per-channel sample with chromatic aberration: R and B pull apart along
167
+ // the radial axis from frame center (classic lens CA purple/cyan fringing).
168
+ vec4 sampleSceneCA(vec2 suv, vec2 caOffset) {
169
+ float r = sampleScene(suv + caOffset).r;
170
+ float g = sampleScene(suv).g;
171
+ float b = sampleScene(suv - caOffset).b;
172
+ // Coverage/alpha from the green (unshifted) sample keeps silhouette stable.
173
+ vec4 mid = sampleScene(suv);
174
+ return vec4(r, g, b, mid.a);
175
+ }
176
+
157
177
  void main() {
158
178
  vec4 bg = u_background;
159
179
 
@@ -178,27 +198,41 @@ void main() {
178
198
  // stays tack sharp. Background pixels (no hit) count as far away, so the
179
199
  // plane silhouette melts softly where it is out of focus.
180
200
  float factor = hit ? max(t - u_focusDist, 0.0) / u_perspective : 1.0;
181
- vec2 dofblur = vec2(clamp(factor * u_aperture, 0.0, u_maxblur)) * u_bokehEnabled;
201
+ float blurAmt = clamp(factor * u_aperture, 0.0, u_maxblur) * u_bokehEnabled;
202
+ vec2 dofblur = vec2(blurAmt);
182
203
 
183
204
  vec2 aspectcorrect = vec2(1.0, u_aspect);
184
- vec2 dofblur9 = dofblur * 0.9;
185
- vec2 dofblur7 = dofblur * 0.7;
186
- vec2 dofblur4 = dofblur * 0.4;
187
205
 
188
- vec4 col = sampleScene(v_uv);
189
- for (int i = 0; i < 16; i++) {
190
- col += sampleScene(v_uv + RING1[i] * aspectcorrect * dofblur);
191
- }
192
- for (int i = 0; i < 8; i++) {
193
- col += sampleScene(v_uv + RING9[i] * aspectcorrect * dofblur9);
194
- }
195
- for (int i = 0; i < 8; i++) {
196
- col += sampleScene(v_uv + RING7[i] * aspectcorrect * dofblur7);
206
+ // Radial CA direction from frame center. Strength grows with local blur so
207
+ // sharp regions stay clean and only the bokeh fringes with purple/cyan.
208
+ vec2 fromCenter = v_uv - 0.5;
209
+ float radial = length(fromCenter);
210
+ vec2 caDir = radial > 1e-5 ? fromCenter / radial : vec2(1.0, 0.0);
211
+ // Base fringe + blur-scaled fringe. u_ca ~0.45 reads like a real lens on
212
+ // the reference photos without turning the whole frame into a prism.
213
+ float caAmt = u_ca * (0.0015 + blurAmt * 0.55);
214
+ vec2 caOffset = caDir * caAmt * aspectcorrect;
215
+
216
+ // Sparse hex gather. Outer ring weight 2.0 so the 6-blade iris is visible
217
+ // as hard bokeh disks; mid ring 1.0 fills the disk; center 1.0 anchors.
218
+ // Total weight = 6*2 + 6*1 + 1 = 19.
219
+ const float W_OUTER = 2.0;
220
+ const float W_MID = 1.0;
221
+ const float W_CENTER = 1.0;
222
+ vec4 col = sampleSceneCA(v_uv, caOffset) * W_CENTER;
223
+ for (int i = 0; i < 6; i++) {
224
+ col += sampleSceneCA(v_uv + HEX_OUTER[i] * aspectcorrect * dofblur, caOffset) * W_OUTER;
197
225
  }
198
- for (int i = 0; i < 8; i++) {
199
- col += sampleScene(v_uv + RING7[i] * aspectcorrect * dofblur4);
226
+ for (int i = 0; i < 6; i++) {
227
+ col += sampleSceneCA(v_uv + HEX_MID[i] * aspectcorrect * dofblur, caOffset) * W_MID;
200
228
  }
201
- col /= 41.0;
229
+ col /= (6.0 * W_OUTER + 6.0 * W_MID + W_CENTER);
230
+
231
+ // Mild highlight bias: bright samples punch through more (classic optical
232
+ // bokeh where speculars form bright disks instead of averaging down).
233
+ // Applied as a soft lift so it doesn't blow whites on already-bright UI.
234
+ float luma = dot(col.rgb, vec3(0.299, 0.587, 0.114));
235
+ col.rgb += col.rgb * blurAmt * 1.8 * smoothstep(0.55, 0.95, luma);
202
236
 
203
237
  // Fog toward background with depth (plugin's THREE.Fog equivalent)
204
238
  col.rgb = mix(col.rgb, bg.rgb, u_fog * depth01 * (hit ? 1.0 : 0.0));
@@ -226,6 +260,7 @@ const UNIFORM_NAMES = [
226
260
  'u_aspect',
227
261
  'u_fog',
228
262
  'u_grain',
263
+ 'u_ca',
229
264
  'u_background',
230
265
  'u_debug',
231
266
  'u_time',
@@ -309,12 +344,18 @@ export function AngledScreen(props) {
309
344
  step: 0.01,
310
345
  },
311
346
  fog: { value: props.fog ?? 0.35, min: 0, max: 1, step: 0.01 },
312
- grainIntensity: { value: props.grainIntensity ?? 0.02, min: 0, max: 0.2, step: 0.005 },
347
+ grainIntensity: { value: props.grainIntensity ?? 0.02, min: 0, max: 0.3, step: 0.005 },
348
+ chromaticAberration: {
349
+ value: props.chromaticAberration ?? 0.45,
350
+ min: 0,
351
+ max: 2,
352
+ step: 0.01,
353
+ },
313
354
  backgroundColor: props.backgroundColor ?? '#000000',
314
355
  debug: props.debug ?? false,
315
356
  });
316
357
  const { perspective, rotateX, rotateY, rotateZ, translateX, translateZ } = tp;
317
- const { aperture, maxBlur, focus, fog, grainIntensity } = tp;
358
+ const { aperture, maxBlur, focus, fog, grainIntensity, chromaticAberration } = tp;
318
359
  const { bokeh, debug, backgroundColor } = tp;
319
360
  const time = frame / fps;
320
361
  const onInit = useCallback(({ canvas }) => {
@@ -473,6 +514,8 @@ export function AngledScreen(props) {
473
514
  gl.uniform1f(locs.u_fog, fog);
474
515
  if (locs.u_grain)
475
516
  gl.uniform1f(locs.u_grain, grainIntensity);
517
+ if (locs.u_ca)
518
+ gl.uniform1f(locs.u_ca, chromaticAberration);
476
519
  if (locs.u_background)
477
520
  gl.uniform4fv(locs.u_background, parseHexColor(backgroundColor));
478
521
  if (locs.u_debug)
@@ -507,7 +550,7 @@ export function AngledScreen(props) {
507
550
  }
508
551
  }, [
509
552
  compW, compH, perspective, rotateX, rotateY, rotateZ, translateX, translateZ,
510
- bokeh, aperture, maxBlur, focus, fog, grainIntensity,
553
+ bokeh, aperture, maxBlur, focus, fog, grainIntensity, chromaticAberration,
511
554
  backgroundColor, debug, time,
512
555
  ]);
513
556
  const supported = typeof document !== 'undefined' && HtmlInCanvas.isSupported();
@@ -1 +1 @@
1
- {"version":3,"file":"vite-plugin.d.ts","sourceRoot":"","sources":["../../src/vite/vite-plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,OAAO,KAAK,EAAU,YAAY,EAAE,MAAM,MAAM,CAAA;AA4BhD,MAAM,WAAW,kBAAkB;IACjC;;wEAEoE;IACpE,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAiDD,wBAAgB,KAAK,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,YAAY,EAAE,CAoYlE"}
1
+ {"version":3,"file":"vite-plugin.d.ts","sourceRoot":"","sources":["../../src/vite/vite-plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,OAAO,KAAK,EAAU,YAAY,EAAE,MAAM,MAAM,CAAA;AAgDhD,MAAM,WAAW,kBAAkB;IACjC;;wEAEoE;IACpE,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAiDD,wBAAgB,KAAK,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,YAAY,EAAE,CA4dlE"}
@@ -18,6 +18,7 @@ import { fileURLToPath } from 'node:url';
18
18
  import { spiceflowPlugin } from 'spiceflow/vite';
19
19
  import react from '@vitejs/plugin-react';
20
20
  import tailwindcss from '@tailwindcss/vite';
21
+ import pc from 'picocolors';
21
22
  import { mdxParse } from 'safe-mdx/parse';
22
23
  import { collectServerImportSources } from "./server-mdx.js";
23
24
  import { parseFrontmatter } from "./mdx-parse.js";
@@ -34,6 +35,23 @@ const RESOLVED_MDX = '\0' + VIRTUAL_MDX;
34
35
  const VIRTUAL_MODULES = 'virtual:egaki-modules';
35
36
  const RESOLVED_MODULES = '\0' + VIRTUAL_MODULES;
36
37
  const PKG_NAME = 'egaki';
38
+ // Packages that must resolve to a single physical copy (same pattern as
39
+ // spiceflow:dedupe-singleton). Remotion Player + useVideoConfig share React
40
+ // context; two copies → "No video config found". Motion patches need one
41
+ // JSAnimation class. resolve.dedupe alone fails under pnpm when the package
42
+ // is only a transitive dep of egaki (not hoisted to the consumer root).
43
+ const dedupePackages = new Set([
44
+ 'remotion',
45
+ '@remotion/player',
46
+ '@remotion/media',
47
+ '@remotion/web-renderer',
48
+ 'motion',
49
+ 'motion-dom',
50
+ 'motion-utils',
51
+ ]);
52
+ // Fake importer anchored inside egaki's package so this.resolve() finds the
53
+ // same remotion/motion copy egaki itself depends on.
54
+ const dedupeImporter = path.join(__pkgRoot, '_dedupe_importer_.js');
37
55
  /** Resolve a relative MDX import source against the project root,
38
56
  * probing common extensions. Returns the absolute path or undefined. */
39
57
  function resolveSourceToFile(root, source) {
@@ -237,7 +255,7 @@ export function video(options) {
237
255
  if (entry.isDirectory()) {
238
256
  walkDir(fullPath);
239
257
  }
240
- else if (/\.(tsx?|jsx?|mdx?)$/.test(entry.name) && !/\.(test|spec|config)\./.test(entry.name)) {
258
+ else if (/\.(tsx?|jsx?|mdx?)$/.test(entry.name) && !/\.(test|spec|config)\./.test(entry.name) && !/\.d\.ts$/.test(entry.name)) {
241
259
  // Entry MDX files are NOT skipped: they may be imported as
242
260
  // partials by other MDX files (e.g. `import Intro from './intro.mdx'`).
243
261
  // Self-import is a user error and harmless (safe-mdx handles it).
@@ -291,6 +309,50 @@ export function video(options) {
291
309
  ].join('\n');
292
310
  }
293
311
  },
312
+ // Print SDK usage instructions after Vite's startup banner.
313
+ // Agents read terminal output to learn how to interact with the
314
+ // running dev server via Playwriter + window.egakiSDK.
315
+ configureServer(server) {
316
+ server.httpServer?.on('listening', () => {
317
+ const log = server.config.logger;
318
+ const p = (s) => log.info(s);
319
+ // Small delay so our block prints after Vite's "ready in" + URL lines
320
+ setTimeout(() => {
321
+ const title = ` ${pc.cyan(pc.bold('egaki SDK'))} ${pc.dim('—')} ${pc.white('programmatic control via')} ${pc.yellow('window.egakiSDK')}`;
322
+ const sep = pc.dim(' ─'.padEnd(72, '─'));
323
+ p('');
324
+ p(title);
325
+ p(sep);
326
+ p('');
327
+ p(` ${pc.green('●')} ${pc.bold('Get info')} ${pc.dim('composition metadata, sections, fps')}`);
328
+ p(` ${pc.cyan(`playwriter -s 1 -e 'console.log(await state.page.evaluate(() => window.egakiSDK.getInfo()))'`)}`);
329
+ p('');
330
+ p(` ${pc.green('●')} ${pc.bold('Screenshot')} ${pc.dim('capture a single frame as PNG')}`);
331
+ p(` ${pc.cyan("playwriter -s 1 -e \"$(cat <<'JS'")}`);
332
+ p(` ${pc.cyan('const dataUrl = await state.page.evaluate(() => window.egakiSDK.screenshot({ frame: 60 }))')}`);
333
+ p(` ${pc.cyan('const buf = Buffer.from(await (await fetch(dataUrl)).arrayBuffer())')}`);
334
+ p(` ${pc.cyan('require("node:fs").writeFileSync("/tmp/frame-60.png", buf)')}`);
335
+ p(` ${pc.cyan('JS')}`);
336
+ p(` ${pc.cyan(')"')}`);
337
+ p('');
338
+ p(` ${pc.green('●')} ${pc.bold('Filmstrip')} ${pc.dim('grid of frames across scenes')}`);
339
+ p(` ${pc.cyan("playwriter -s 1 -e \"$(cat <<'JS'")}`);
340
+ p(` ${pc.cyan('const dataUrl = await state.page.evaluate(() =>')}`);
341
+ p(` ${pc.cyan(' window.egakiSDK.filmstrip({ scenes: [0, 1, 2], framesPerScene: 3 }))')}`);
342
+ p(` ${pc.cyan('const buf = Buffer.from(await (await fetch(dataUrl)).arrayBuffer())')}`);
343
+ p(` ${pc.cyan('require("node:fs").writeFileSync("/tmp/filmstrip.png", buf)')}`);
344
+ p(` ${pc.cyan('JS')}`);
345
+ p(` ${pc.cyan(')"')}`);
346
+ p('');
347
+ p(` ${pc.green('●')} ${pc.bold('Export video')} ${pc.dim('render MP4 via WebCodecs')}`);
348
+ p(` ${pc.cyan(`playwriter -s 1 -e 'await state.page.evaluate(() => window.egakiSDK.export({ path: "output.mp4" }))'`)}`);
349
+ p('');
350
+ p(` ${pc.green('●')} ${pc.bold('Seek')} ${pc.dim('jump to a specific frame')}`);
351
+ p(` ${pc.cyan(`playwriter -s 1 -e 'await state.page.evaluate(() => window.egakiSDK.seekTo(120))'`)}`);
352
+ p('');
353
+ }, 50);
354
+ });
355
+ },
294
356
  // HMR for file changes in the project.
295
357
  //
296
358
  // Entry MDX: the source string flows server → client through the RSC
@@ -408,22 +470,29 @@ export function video(options) {
408
470
  arr.push(new RegExp(`^${PKG_NAME}`));
409
471
  arr.push(/^tweakpane/);
410
472
  config.resolve.noExternal = arr;
411
- // Deduplicate motion packages so egaki's motion-timing virtual module
412
- // patches the same JSAnimation class that motion/react uses. Without
413
- // this, Vite can resolve motion-dom to different instances and
414
- // prototype patches won't affect the user's animations.
415
- // Always applied (not gated by hasMotion) because configEnvironment
416
- // runs before configResolved where hasMotion is set. Harmless when
417
- // motion isn't installed — dedupe on missing packages is a no-op.
418
- config.resolve.dedupe = mergeUnique(config.resolve.dedupe, ['motion', 'motion-dom', 'motion-utils']);
473
+ // Baseline hint for Vite's built-in resolver (same list as the
474
+ // resolveId singleton plugin below). Real enforcement is the
475
+ // egaki:dedupe-singleton plugin — resolve.dedupe alone is not enough
476
+ // under pnpm when remotion is only a transitive dep of egaki.
477
+ config.resolve.dedupe = mergeUnique(config.resolve.dedupe, [...dedupePackages]);
419
478
  if (name === 'client') {
420
479
  config.optimizeDeps ??= {};
421
480
  config.optimizeDeps.exclude = mergeUnique(config.optimizeDeps.exclude, [PKG_NAME]);
481
+ // Prebundle safe-mdx as a single entry so its pure-CJS leaves
482
+ // (format via fault/micromark) stay internalized with CJS interop.
483
+ // Do NOT list format/fault as separate optimizeDeps entries — that
484
+ // splits them into named-only ESM chunks and the browser crashes with
485
+ // "does not provide an export named 'default'" (same bug Holocron hit
486
+ // when safe-mdx leaked to the client; see holocron vite CHANGELOG).
487
+ // Holocron's clean fix is keep safe-mdx server-only; egaki needs it on
488
+ // the client for MDX-in-browser, so correct prebundling is the fix.
422
489
  config.optimizeDeps.include = mergeUnique(config.optimizeDeps.include, [
423
490
  `${PKG_NAME} > spiceflow > @vitejs/plugin-rsc/vendor/react-server-dom/client.browser`,
424
491
  `${PKG_NAME} > remotion`,
425
492
  `${PKG_NAME} > @remotion/player`,
426
493
  `${PKG_NAME} > safe-mdx`,
494
+ 'safe-mdx',
495
+ 'safe-mdx/parse',
427
496
  ]);
428
497
  }
429
498
  if (name === 'rsc' || name === 'ssr') {
@@ -432,9 +501,39 @@ export function video(options) {
432
501
  }
433
502
  },
434
503
  };
504
+ // Force remotion / motion to a single copy across all importers, even when
505
+ // egaki is a transitive or file:-linked dep under pnpm. Mirrors
506
+ // spiceflow:dedupe-singleton (spiceflow/src/vite.tsx).
507
+ //
508
+ // Vite's resolve.dedupe sets basedir=root then walks node_modules/, but
509
+ // pnpm only symlinks direct deps at the root. Transitive deps stay buried
510
+ // in .pnpm/, so dedupe silently falls back to importer-based resolution
511
+ // and Player vs useVideoConfig load different remotion instances.
512
+ const dedupeSingletonPlugin = {
513
+ name: 'egaki:dedupe-singleton',
514
+ enforce: 'pre',
515
+ async resolveId(id, _importer, options) {
516
+ if (id.startsWith('.') ||
517
+ id.startsWith('/') ||
518
+ id.startsWith('\0') ||
519
+ id.includes('?')) {
520
+ return null;
521
+ }
522
+ const pkgName = id.startsWith('@')
523
+ ? id.split('/').slice(0, 2).join('/')
524
+ : (id.split('/')[0] ?? id);
525
+ if (!dedupePackages.has(pkgName))
526
+ return null;
527
+ return this.resolve(id, dedupeImporter, {
528
+ ...options,
529
+ skipSelf: true,
530
+ });
531
+ },
532
+ };
435
533
  return [
436
534
  videoPlugin,
437
535
  rscPackagePlugin,
536
+ dedupeSingletonPlugin,
438
537
  tailwindcss(),
439
538
  spiceflowPlugin({ entry: VIRTUAL_APP }),
440
539
  react(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "egaki",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "AI image, video, and speech generation CLI with MDX-to-video framework",
6
6
  "main": "./dist/cli/main.js",
@@ -113,18 +113,15 @@
113
113
  "zod": "^4.3.6",
114
114
  "@vitejs/plugin-react": "^6.0.2",
115
115
  "spiceflow": "1.26.0-rsc.7",
116
+ "vite": "^8.0.10",
116
117
  "zustand": "^5.0.14"
117
118
  },
118
119
  "peerDependencies": {
119
- "vite": ">=6",
120
120
  "motion": ">=12",
121
121
  "motion-dom": ">=12",
122
122
  "motion-utils": ">=12"
123
123
  },
124
124
  "peerDependenciesMeta": {
125
- "vite": {
126
- "optional": true
127
- },
128
125
  "motion": {
129
126
  "optional": true
130
127
  },
@@ -141,7 +138,6 @@
141
138
  "devDependencies": {
142
139
  "@types/js-yaml": "^4.0.9",
143
140
  "@types/react": "^19.2.17",
144
- "vite": "^8.0.10",
145
141
  "vitest": "^4.1.8"
146
142
  },
147
143
  "scripts": {
package/src/cli/cli.ts CHANGED
@@ -244,6 +244,56 @@ cli
244
244
  await showUsage()
245
245
  })
246
246
 
247
+ // ─── dev command ─────────────────────────────────────────────────────────────
248
+
249
+ cli
250
+ .command(
251
+ 'dev [entry]',
252
+ dedent`
253
+ Start the MDX video dev server with zero setup.
254
+ Pass an .mdx file (or a directory containing one) — no package.json,
255
+ vite.config.ts, or npm install needed. All dependencies (react,
256
+ remotion, vite) come from the egaki CLI's own installation.
257
+ Uses a random free port unless --port is given.
258
+ `,
259
+ )
260
+ .option(
261
+ '-p, --port [port]',
262
+ z.coerce.number().int().describe('Fixed port (default: random free port)'),
263
+ )
264
+ .option(
265
+ '--host [host]',
266
+ z.string().describe('Host to bind (default: localhost)'),
267
+ )
268
+ .option('--open', 'Open the player in the default browser')
269
+ .example('# Serve a single MDX file, no project setup')
270
+ .example('egaki dev video.mdx')
271
+ .example('# Serve the current directory (auto-discovers video.mdx)')
272
+ .example('egaki dev')
273
+ .example('# Fixed port + open browser')
274
+ .example('egaki dev intro.mdx --port 5199 --open')
275
+ .action(async (entry, options) => {
276
+ const { startDevServer } = await import('./dev-server.js')
277
+ const result = await startDevServer({
278
+ entry,
279
+ port: options.port,
280
+ host: options.host,
281
+ })
282
+ if (result instanceof Error) {
283
+ console.error(pc.red(result.message))
284
+ process.exit(1)
285
+ }
286
+ console.log()
287
+ console.log(` ${pc.green('➜')} egaki dev server running`)
288
+ console.log(` ${pc.dim('root:')} ${result.root}`)
289
+ console.log(` ${pc.dim('url:')} ${pc.cyan(result.url)}`)
290
+ console.log()
291
+ if (options.open) {
292
+ const { openUrlInBrowser } = await import('./open-browser.js')
293
+ await openUrlInBrowser(result.url)
294
+ }
295
+ })
296
+
247
297
  // ─── image command ───────────────────────────────────────────────────────────
248
298
 
249
299
  cli
@@ -0,0 +1,109 @@
1
+ // Tests for the zero-config dev server (egaki dev).
2
+ //
3
+ // Creates a temp directory containing ONLY a video.mdx file — no
4
+ // package.json, no vite.config.ts, no node_modules — and verifies the
5
+ // dev server boots, serves HTML at /, and resolves all framework deps
6
+ // from the CLI's own installation.
7
+
8
+ import { describe, test, expect, afterAll } from 'vitest'
9
+ import fs from 'node:fs'
10
+ import os from 'node:os'
11
+ import path from 'node:path'
12
+ import dedent from 'string-dedent'
13
+ import { startDevServer, findFreePort, type RunningDevServer } from './dev-server.ts'
14
+
15
+ const cleanups: Array<() => Promise<void>> = []
16
+
17
+ afterAll(async () => {
18
+ for (const cleanup of cleanups) {
19
+ await cleanup()
20
+ }
21
+ })
22
+
23
+ function makeScratchProject(): string {
24
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'egaki-dev-test-'))
25
+ const MDX = dedent`
26
+ ---
27
+ width: 1920
28
+ height: 1080
29
+ fps: 30
30
+ ---
31
+
32
+ # Hello duration=2
33
+
34
+ Zero-config dev server test.
35
+ `
36
+ fs.writeFileSync(path.join(dir, 'video.mdx'), MDX)
37
+ cleanups.push(async () => {
38
+ fs.rmSync(dir, { recursive: true, force: true })
39
+ })
40
+ return dir
41
+ }
42
+
43
+ describe('findFreePort', () => {
44
+ test('returns a valid port number', async () => {
45
+ const port = await findFreePort()
46
+ expect(port).toBeGreaterThan(0)
47
+ expect(port).toBeLessThan(65536)
48
+ })
49
+ })
50
+
51
+ describe('startDevServer', () => {
52
+ test('returns Error for missing entry', async () => {
53
+ const result = await startDevServer({ entry: '/nonexistent/path/video.mdx' })
54
+ expect(result).toBeInstanceOf(Error)
55
+ if (result instanceof Error) {
56
+ expect(result.message).toContain('entry not found')
57
+ }
58
+ })
59
+
60
+ test('returns Error for directory without mdx files', async () => {
61
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'egaki-dev-empty-'))
62
+ cleanups.push(async () => {
63
+ fs.rmSync(dir, { recursive: true, force: true })
64
+ })
65
+ const result = await startDevServer({ entry: dir })
66
+ expect(result).toBeInstanceOf(Error)
67
+ if (result instanceof Error) {
68
+ expect(result.message).toContain('no .mdx files found')
69
+ }
70
+ })
71
+
72
+ test('returns Error for non-mdx file entry', async () => {
73
+ const dir = makeScratchProject()
74
+ const txtPath = path.join(dir, 'notes.txt')
75
+ fs.writeFileSync(txtPath, 'hi')
76
+ const result = await startDevServer({ entry: txtPath })
77
+ expect(result).toBeInstanceOf(Error)
78
+ if (result instanceof Error) {
79
+ expect(result.message).toContain('must be an .mdx file')
80
+ }
81
+ })
82
+
83
+ test('serves a scratch mdx file with no package.json or node_modules', async () => {
84
+ const dir = makeScratchProject()
85
+ const result = await startDevServer({
86
+ entry: path.join(dir, 'video.mdx'),
87
+ })
88
+ expect(result).not.toBeInstanceOf(Error)
89
+ const running = result as RunningDevServer
90
+ cleanups.push(() => running.close())
91
+
92
+ expect(running.root).toBe(dir)
93
+ expect(running.url).toMatch(/^http:\/\/localhost:\d+\/$/)
94
+
95
+ const response = await fetch(running.url)
96
+ expect(response.status).toBe(200)
97
+ expect(response.headers.get('content-type')).toContain('text/html')
98
+ const html = await response.text()
99
+ // RSC streaming: the doctype shell arrives in a separate flush, so
100
+ // assert on stable player UI markup instead of '<html'.
101
+ expect(html).toContain('Export MP4')
102
+
103
+ // The dependency shim was created and is CLI-owned
104
+ expect(fs.existsSync(path.join(dir, 'node_modules', '.egaki-shim'))).toBe(true)
105
+ expect(
106
+ fs.lstatSync(path.join(dir, 'node_modules', 'egaki')).isSymbolicLink(),
107
+ ).toBe(true)
108
+ }, 120_000)
109
+ })