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.
- package/CHANGELOG.md +25 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +39 -0
- package/dist/cli/dev-server.d.ts +30 -0
- package/dist/cli/dev-server.d.ts.map +1 -0
- package/dist/cli/dev-server.js +266 -0
- package/dist/cli/dev-server.test.d.ts +2 -0
- package/dist/cli/dev-server.test.d.ts.map +1 -0
- package/dist/cli/dev-server.test.js +95 -0
- package/dist/vite/angled-screen-shader.d.ts +5 -0
- package/dist/vite/angled-screen-shader.d.ts.map +1 -1
- package/dist/vite/angled-screen-shader.js +77 -34
- package/dist/vite/vite-plugin.d.ts.map +1 -1
- package/dist/vite/vite-plugin.js +108 -9
- package/package.json +2 -6
- package/src/cli/cli.ts +50 -0
- package/src/cli/dev-server.test.ts +109 -0
- package/src/cli/dev-server.ts +303 -0
- package/src/vite/angled-screen-shader.tsx +81 -34
- package/src/vite/vite-plugin.ts +117 -9
|
@@ -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 (
|
|
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
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
vec2(
|
|
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
|
|
117
|
-
vec2(
|
|
118
|
-
vec2(
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
vec2(
|
|
122
|
-
vec2(
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 <
|
|
199
|
-
col +=
|
|
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 /=
|
|
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.
|
|
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;
|
|
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"}
|
package/dist/vite/vite-plugin.js
CHANGED
|
@@ -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
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
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.
|
|
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
|
+
})
|