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
@@ -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.8.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",
@@ -88,9 +88,9 @@
88
88
  "@ai-sdk/xai": "^3.0.92",
89
89
  "@clack/prompts": "^1.0.1",
90
90
  "@fal-ai/client": "^1.10.1",
91
- "@remotion/media": "4.0.475",
92
- "@remotion/player": "4.0.475",
93
- "@remotion/web-renderer": "4.0.475",
91
+ "@remotion/media": "4.0.494",
92
+ "@remotion/player": "4.0.494",
93
+ "@remotion/web-renderer": "4.0.494",
94
94
  "@tailwindcss/vite": "^4.2.2",
95
95
  "@tweakpane/core": "^2.0.5",
96
96
  "@tweakpane/plugin-essentials": "^0.2.1",
@@ -98,12 +98,12 @@
98
98
  "eventsource-parser": "^3.0.6",
99
99
  "goke": "^6.12.1",
100
100
  "js-yaml": "^4.1.1",
101
- "mediabunny": "^1.46.0",
101
+ "mediabunny": "^1.50.8",
102
102
  "moveable": "^0.53.0",
103
103
  "picocolors": "^1.1.1",
104
104
  "react": "19.2.7",
105
105
  "react-dom": "19.2.7",
106
- "remotion": "4.0.475",
106
+ "remotion": "4.0.494",
107
107
  "safe-mdx": "^1.11.3",
108
108
  "shiki": "^4.2.0",
109
109
  "string-dedent": "^3.0.2",
@@ -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
+ })