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.
- package/CHANGELOG.md +49 -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 +50 -0
- package/dist/vite/angled-screen-shader.d.ts.map +1 -0
- package/dist/vite/angled-screen-shader.js +616 -0
- package/dist/vite/angled-screen.d.ts +12 -3
- package/dist/vite/angled-screen.d.ts.map +1 -1
- package/dist/vite/angled-screen.js +12 -3
- package/dist/vite/mdx-video.d.ts +4 -2
- package/dist/vite/mdx-video.d.ts.map +1 -1
- package/dist/vite/mdx-video.js +4 -2
- package/dist/vite/mdx-video.test.js +1 -0
- package/dist/vite/render-client.d.ts +27 -0
- package/dist/vite/render-client.d.ts.map +1 -1
- package/dist/vite/render-client.js +47 -51
- package/dist/vite/sdk.d.ts.map +1 -1
- package/dist/vite/sdk.js +4 -3
- package/dist/vite/shader-renderer.d.ts +4 -0
- package/dist/vite/shader-renderer.d.ts.map +1 -1
- package/dist/vite/shader-renderer.js +3 -3
- package/dist/vite/tweakpane-hook.d.ts +6 -0
- package/dist/vite/tweakpane-hook.d.ts.map +1 -1
- package/dist/vite/tweakpane-hook.js +101 -4
- package/dist/vite/vite-plugin.d.ts.map +1 -1
- package/dist/vite/vite-plugin.js +108 -9
- package/package.json +7 -11
- 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 +753 -0
- package/src/vite/angled-screen.tsx +13 -4
- package/src/vite/mdx-video.test.tsx +1 -0
- package/src/vite/mdx-video.tsx +4 -1
- package/src/vite/render-client.ts +52 -51
- package/src/vite/sdk.ts +4 -3
- package/src/vite/shader-renderer.tsx +3 -3
- package/src/vite/tweakpane-hook.tsx +102 -4
- package/src/vite/vite-plugin.ts +117 -9
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
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
|
|
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
|
-
|
|
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('
|
|
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 },
|
package/src/vite/mdx-video.tsx
CHANGED
|
@@ -29,7 +29,8 @@ import {
|
|
|
29
29
|
StaggeredFadeUp,
|
|
30
30
|
ShimmerSweep,
|
|
31
31
|
} from './components.tsx'
|
|
32
|
-
import {
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
onProgress
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/vite/vite-plugin.ts
CHANGED
|
@@ -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
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
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
|
-
[
|
|
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(),
|