@lincyaw/addon-motion 0.0.0-stage → 1.0.0-rc.1
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/LICENSE +202 -0
- package/README.md +29 -2
- package/components/PmScene.tsx +18 -0
- package/package.json +61 -3
- package/src/client/SceneView.tsx +424 -0
- package/src/client/deck-scenes.ts +27 -0
- package/src/client/env.d.ts +4 -0
- package/src/client/scene.css +62 -0
- package/src/client/scene.ts +317 -0
- package/src/domain/index.ts +32 -0
- package/src/domain/messages.ts +35 -0
- package/src/node/presmium.ts +83 -0
- package/src/node/scene-starter.txt +6 -0
- package/src/node/scenes.ts +40 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import type * as AnimeModule from 'animejs'
|
|
2
|
+
import type * as D3Module from 'd3'
|
|
3
|
+
import type { gsap as Gsap } from 'gsap'
|
|
4
|
+
import type * as MotionModule from 'motion'
|
|
5
|
+
import type * as ReactModule from 'react'
|
|
6
|
+
import type * as RemotionModule from 'remotion'
|
|
7
|
+
import type * as ThreeModule from 'three'
|
|
8
|
+
|
|
9
|
+
/** What a scene gets to draw itself. */
|
|
10
|
+
export interface SceneContext {
|
|
11
|
+
/** Complete parameters and semantic state resolved for the authored Step ID. */
|
|
12
|
+
parameters: Readonly<Record<string, unknown>>
|
|
13
|
+
state?: string
|
|
14
|
+
/** The element the scene draws in; the scene owns its children. */
|
|
15
|
+
root: HTMLElement
|
|
16
|
+
/** The length of each step of the scene (ms), from `:durations:`. */
|
|
17
|
+
durations: readonly number[]
|
|
18
|
+
/** Where step `k` starts on the scene's timeline (ms). */
|
|
19
|
+
at(step: number): number
|
|
20
|
+
/** The length of the timeline (ms). */
|
|
21
|
+
total: number
|
|
22
|
+
/** The layout size of `root` in CSS pixels, before any ancestor's presentation scaling. */
|
|
23
|
+
width: number
|
|
24
|
+
height: number
|
|
25
|
+
/** A color of the design system as the page shows it now, as `rgb()` or `rgba()`: `color('accent')` is `--pm-color-accent`. */
|
|
26
|
+
color(name: string): string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A scene drawn at a place on its timeline. Its look is a function of that place only. */
|
|
30
|
+
export interface Scene {
|
|
31
|
+
/** Shows the scene at `ms` on its timeline. */
|
|
32
|
+
seek(ms: number): void
|
|
33
|
+
/** `root` changed size (the scene is shown again at its place afterwards). */
|
|
34
|
+
resize?(width: number, height: number): void
|
|
35
|
+
dispose?(): void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SceneDefinition {
|
|
39
|
+
engine: string
|
|
40
|
+
create(ctx: SceneContext): Promise<Scene>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Made = Scene | ((ms: number) => void)
|
|
44
|
+
|
|
45
|
+
function scene(made: Made): Scene {
|
|
46
|
+
return typeof made === 'function' ? { seek: made } : made
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A scene of its own code: `create` draws in `ctx.root` and returns the function of the time (or a `Scene`). */
|
|
50
|
+
export function defineScene(create: (ctx: SceneContext) => Made | Promise<Made>): SceneDefinition {
|
|
51
|
+
return { engine: 'custom', create: async ctx => scene(await create(ctx)) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type GsapAnimation = ReturnType<typeof Gsap.timeline> | ReturnType<typeof Gsap.to>
|
|
55
|
+
|
|
56
|
+
/** GSAP's plugins that draw the same at the same time; ScrambleText (random each render) and the scroll and drag ones are left out. */
|
|
57
|
+
async function gsapPlugins(gsap: typeof Gsap) {
|
|
58
|
+
// Each plugin registers itself with `window.gsap` when it loads. GSAP sets that global only when nothing holds the
|
|
59
|
+
// name, and an element with the id `gsap` (a section labelled `(gsap)=`) does, so the plugins would find the element.
|
|
60
|
+
const page = window as unknown as { gsap?: unknown }
|
|
61
|
+
if (page.gsap !== gsap) page.gsap = gsap
|
|
62
|
+
const [{ SplitText }, { TextPlugin }, { DrawSVGPlugin }, { MorphSVGPlugin }, { MotionPathPlugin }, { CustomEase }, { CustomBounce }, { CustomWiggle }, { Physics2DPlugin }, { PhysicsPropsPlugin }, { Flip }] = await Promise.all([
|
|
63
|
+
import('gsap/SplitText'),
|
|
64
|
+
import('gsap/TextPlugin'),
|
|
65
|
+
import('gsap/DrawSVGPlugin'),
|
|
66
|
+
import('gsap/MorphSVGPlugin'),
|
|
67
|
+
import('gsap/MotionPathPlugin'),
|
|
68
|
+
import('gsap/CustomEase'),
|
|
69
|
+
import('gsap/CustomBounce'),
|
|
70
|
+
import('gsap/CustomWiggle'),
|
|
71
|
+
import('gsap/Physics2DPlugin'),
|
|
72
|
+
import('gsap/PhysicsPropsPlugin'),
|
|
73
|
+
import('gsap/Flip'),
|
|
74
|
+
])
|
|
75
|
+
return { SplitText, TextPlugin, DrawSVGPlugin, MorphSVGPlugin, MotionPathPlugin, CustomEase, CustomBounce, CustomWiggle, Physics2DPlugin, PhysicsPropsPlugin, Flip }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type GsapPlugins = Awaited<ReturnType<typeof gsapPlugins>>
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A GSAP timeline (or tween), built in a `gsap.context` scoped to `ctx.root`, so selectors find the
|
|
82
|
+
* scene's elements and disposing reverts them. Times are seconds, as in GSAP: `ctx.at(k) / 1000`.
|
|
83
|
+
* `plugins` are registered before `build`: tweens can use `drawSVG`, `morphSVG`, `motionPath`, `text`,
|
|
84
|
+
* `physics2D`, `physicsProps`, and the scene can call `plugins.SplitText.create`, `plugins.CustomEase.create`.
|
|
85
|
+
*/
|
|
86
|
+
export function gsapScene(build: (ctx: SceneContext & { gsap: typeof Gsap, plugins: GsapPlugins }) => GsapAnimation): SceneDefinition {
|
|
87
|
+
return {
|
|
88
|
+
engine: 'gsap',
|
|
89
|
+
async create(ctx) {
|
|
90
|
+
const { gsap } = await import('gsap')
|
|
91
|
+
const plugins = await gsapPlugins(gsap)
|
|
92
|
+
// A tweened box on its own 3D layer is drawn a little differently from the same box at rest, so a frame would
|
|
93
|
+
// depend on how it was reached; scenes keep 2D transforms.
|
|
94
|
+
gsap.config({ force3D: false })
|
|
95
|
+
gsap.registerPlugin(...Object.values(plugins))
|
|
96
|
+
let animation: GsapAnimation | undefined
|
|
97
|
+
const context = gsap.context(() => {}, ctx.root)
|
|
98
|
+
try {
|
|
99
|
+
context.add(() => {
|
|
100
|
+
animation = build({ ...ctx, gsap, plugins })
|
|
101
|
+
})
|
|
102
|
+
animation?.pause()
|
|
103
|
+
return {
|
|
104
|
+
seek: ms => animation?.totalTime(ms / 1000, true),
|
|
105
|
+
dispose: () => context.revert(),
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
context.revert()
|
|
109
|
+
throw error
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type AnimeAnimation = ReturnType<typeof AnimeModule.createTimeline> | ReturnType<typeof AnimeModule.animate>
|
|
116
|
+
|
|
117
|
+
/** An Anime.js timeline (or animation), made in a scope rooted at `ctx.root`. Times are milliseconds. */
|
|
118
|
+
export function animeScene(build: (ctx: SceneContext & { anime: typeof AnimeModule }) => AnimeAnimation): SceneDefinition {
|
|
119
|
+
return {
|
|
120
|
+
engine: 'anime',
|
|
121
|
+
async create(ctx) {
|
|
122
|
+
const anime = await import('animejs')
|
|
123
|
+
const scope = anime.createScope({ root: ctx.root })
|
|
124
|
+
const animation = scope.execute(() => build({ ...ctx, anime }))
|
|
125
|
+
animation.pause()
|
|
126
|
+
// Until a tween has been reached once it shows the element's own value, not its from-value: going to the end and
|
|
127
|
+
// back sets them all up, so a time looks the same whatever was shown before it.
|
|
128
|
+
animation.seek(animation.duration, true)
|
|
129
|
+
animation.seek(0, true)
|
|
130
|
+
return {
|
|
131
|
+
// From the start each time: going back past a later tween that gives its own from-value does not restore
|
|
132
|
+
// the value before it, so a time is reached the same way whichever time was shown before.
|
|
133
|
+
seek(ms) {
|
|
134
|
+
animation.seek(0, true)
|
|
135
|
+
animation.seek(ms, true)
|
|
136
|
+
},
|
|
137
|
+
dispose: () => scope.revert(),
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type MotionControls = ReturnType<typeof MotionModule.animate>
|
|
144
|
+
|
|
145
|
+
/** A Motion sequence, to type the segments a scene builds. */
|
|
146
|
+
export type MotionSequence = MotionModule.AnimationSequence
|
|
147
|
+
|
|
148
|
+
/** Motion animations (`animate`, a sequence, or several); Motion counts seconds, so the scene sets `time` to `ms / 1000`. */
|
|
149
|
+
export function motionScene(build: (ctx: SceneContext & { motion: typeof MotionModule }) => MotionControls | MotionControls[]): SceneDefinition {
|
|
150
|
+
return {
|
|
151
|
+
engine: 'motion',
|
|
152
|
+
async create(ctx) {
|
|
153
|
+
const motion = await import('motion')
|
|
154
|
+
const controls = [build({ ...ctx, motion })].flat()
|
|
155
|
+
for (const control of controls) control.pause()
|
|
156
|
+
return {
|
|
157
|
+
seek(ms) {
|
|
158
|
+
for (const control of controls) control.time = ms / 1000
|
|
159
|
+
// Only what the browser animates (opacity, background color, filter, clip-path) shows at once; transforms,
|
|
160
|
+
// SVG, CSS variables and function segments wait for Motion's next frame, so it is drawn now.
|
|
161
|
+
for (const step of ['update', 'preRender', 'render'] as const) motion.frameSteps[step].process(motion.frameData)
|
|
162
|
+
},
|
|
163
|
+
dispose() {
|
|
164
|
+
for (const control of controls) control.cancel()
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface ThreeView {
|
|
172
|
+
/** Draws the scene at `ms` on the timeline (`renderer.render(scene, camera)`). */
|
|
173
|
+
render(ms: number): void
|
|
174
|
+
resize?(width: number, height: number): void
|
|
175
|
+
dispose?(): void
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The three.js addons a scene can use: HTML labels, lines wider than a pixel, and geometries the core lacks. */
|
|
179
|
+
async function threeAddons() {
|
|
180
|
+
const [{ CSS2DRenderer, CSS2DObject }, { Line2 }, { LineGeometry }, { LineMaterial }, { ParametricGeometry }, { RoundedBoxGeometry }, { ConvexGeometry }] = await Promise.all([
|
|
181
|
+
import('three/addons/renderers/CSS2DRenderer.js'),
|
|
182
|
+
import('three/addons/lines/Line2.js'),
|
|
183
|
+
import('three/addons/lines/LineGeometry.js'),
|
|
184
|
+
import('three/addons/lines/LineMaterial.js'),
|
|
185
|
+
import('three/addons/geometries/ParametricGeometry.js'),
|
|
186
|
+
import('three/addons/geometries/RoundedBoxGeometry.js'),
|
|
187
|
+
import('three/addons/geometries/ConvexGeometry.js'),
|
|
188
|
+
])
|
|
189
|
+
return { CSS2DRenderer, CSS2DObject, Line2, LineGeometry, LineMaterial, ParametricGeometry, RoundedBoxGeometry, ConvexGeometry }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export type ThreeAddons = Awaited<ReturnType<typeof threeAddons>>
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A Three.js scene: the adapter keeps logical dimensions for the scene and a drawing buffer matched
|
|
196
|
+
* to `root`'s displayed pixels, including presentation scaling; `build` returns (or resolves to) how to draw it at a time.
|
|
197
|
+
*/
|
|
198
|
+
export function threeScene(build: (ctx: SceneContext & { THREE: typeof ThreeModule, renderer: ThreeModule.WebGLRenderer, addons: ThreeAddons }) => ThreeView | ThreeView['render'] | Promise<ThreeView | ThreeView['render']>): SceneDefinition {
|
|
199
|
+
return {
|
|
200
|
+
engine: 'three',
|
|
201
|
+
async create(ctx) {
|
|
202
|
+
const [THREE, addons] = await Promise.all([import('three'), threeAddons()])
|
|
203
|
+
// The drawing buffer is kept so a screenshot of a frame (the lecture's video) shows it.
|
|
204
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true })
|
|
205
|
+
Object.assign(renderer.domElement.style, { width: '100%', height: '100%', display: 'block' })
|
|
206
|
+
ctx.root.append(renderer.domElement)
|
|
207
|
+
let width = ctx.width, height = ctx.height
|
|
208
|
+
let bufferWidth = 0, bufferHeight = 0, sizedWidth = 0, sizedHeight = 0
|
|
209
|
+
const gl = renderer.getContext()
|
|
210
|
+
const maxSize = Math.min(renderer.capabilities.maxTextureSize, Number(gl.getParameter(gl.MAX_RENDERBUFFER_SIZE)))
|
|
211
|
+
const syncBuffer = () => {
|
|
212
|
+
const box = ctx.root.getBoundingClientRect()
|
|
213
|
+
const dpr = window.devicePixelRatio || 1
|
|
214
|
+
// Hidden or newly mounted roots can have no height yet. The next seek measures them again.
|
|
215
|
+
const scale = box.width > 0 && box.height > 0 ? Math.max(box.width / width, box.height / height) : 1
|
|
216
|
+
// Scale down for a smaller presentation too; only the GPU's size limit caps magnification.
|
|
217
|
+
const ratio = Math.max(1 / Math.min(width, height), Math.min(dpr * scale, maxSize / width, maxSize / height))
|
|
218
|
+
const nextWidth = Math.floor(width * ratio), nextHeight = Math.floor(height * ratio)
|
|
219
|
+
if (nextWidth === bufferWidth && nextHeight === bufferHeight && width === sizedWidth && height === sizedHeight) return false
|
|
220
|
+
renderer.setDrawingBufferSize(width, height, ratio)
|
|
221
|
+
bufferWidth = nextWidth; bufferHeight = nextHeight
|
|
222
|
+
sizedWidth = width; sizedHeight = height
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
225
|
+
syncBuffer()
|
|
226
|
+
// An async build can load its textures (`new THREE.TextureLoader().loadAsync(url)`) before the first frame.
|
|
227
|
+
const made = await build({ ...ctx, THREE, renderer, addons })
|
|
228
|
+
const view: ThreeView = typeof made === 'function' ? { render: made } : made
|
|
229
|
+
return {
|
|
230
|
+
seek(ms) {
|
|
231
|
+
// Ancestor transforms change displayed pixels without triggering a layout ResizeObserver.
|
|
232
|
+
if (syncBuffer()) view.resize?.(width, height)
|
|
233
|
+
view.render(ms)
|
|
234
|
+
},
|
|
235
|
+
resize(w, h) {
|
|
236
|
+
if (!(w > 0 && h > 0)) return
|
|
237
|
+
width = w; height = h
|
|
238
|
+
syncBuffer()
|
|
239
|
+
view.resize?.(width, height)
|
|
240
|
+
},
|
|
241
|
+
dispose() {
|
|
242
|
+
view.dispose?.()
|
|
243
|
+
renderer.dispose()
|
|
244
|
+
renderer.forceContextLoss()
|
|
245
|
+
renderer.domElement.remove()
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A D3 scene: D3 turns the data into shapes (scales, axes, layouts, shape generators), and the scene draws them at
|
|
254
|
+
* a time with D3's interpolators and eases. `build` returns the drawing function `(ms) => void` (or a `Scene`);
|
|
255
|
+
* transitions run on D3's own timer, so they have no place here.
|
|
256
|
+
*/
|
|
257
|
+
export function d3Scene(build: (ctx: SceneContext & { d3: typeof D3Module }) => Made | Promise<Made>): SceneDefinition {
|
|
258
|
+
return {
|
|
259
|
+
engine: 'd3',
|
|
260
|
+
async create(ctx) {
|
|
261
|
+
const d3 = await import('d3')
|
|
262
|
+
return scene(await build({ ...ctx, d3 }))
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface RemotionComposition {
|
|
268
|
+
component: ReactModule.ComponentType<Record<string, unknown>>
|
|
269
|
+
fps: number
|
|
270
|
+
width: number
|
|
271
|
+
height: number
|
|
272
|
+
/** Default: the scene's timeline at `fps`. */
|
|
273
|
+
durationInFrames?: number
|
|
274
|
+
inputProps?: Record<string, unknown>
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* A Remotion composition, drawn frame by frame with the player's `Thumbnail`: the scene's place on
|
|
279
|
+
* its timeline picks the frame. `build` gets React and Remotion to write the component with
|
|
280
|
+
* `React.createElement`.
|
|
281
|
+
*/
|
|
282
|
+
export function remotionScene(build: (ctx: SceneContext & { React: typeof ReactModule, remotion: typeof RemotionModule }) => RemotionComposition | Promise<RemotionComposition>): SceneDefinition {
|
|
283
|
+
return {
|
|
284
|
+
engine: 'remotion',
|
|
285
|
+
async create(ctx) {
|
|
286
|
+
const [React, { flushSync }, { createRoot }, remotion, { Thumbnail }] = await Promise.all([
|
|
287
|
+
import('react'),
|
|
288
|
+
import('react-dom'),
|
|
289
|
+
import('react-dom/client'),
|
|
290
|
+
import('remotion'),
|
|
291
|
+
import('@remotion/player'),
|
|
292
|
+
])
|
|
293
|
+
const composition = await build({ ...ctx, React, remotion })
|
|
294
|
+
const frames = composition.durationInFrames ?? Math.max(1, Math.ceil(ctx.total * composition.fps / 1000))
|
|
295
|
+
const root = createRoot(ctx.root)
|
|
296
|
+
return {
|
|
297
|
+
seek(ms) {
|
|
298
|
+
// Half a millisecond of slack: the lecture export asks for whole milliseconds (frame 1 at 30 fps is
|
|
299
|
+
// 33 ms, not 33.3), which would otherwise show the frame before, every third frame.
|
|
300
|
+
const frame = Math.min(frames - 1, Math.max(0, Math.floor((ms + 0.5) * composition.fps / 1000)))
|
|
301
|
+
flushSync(() => root.render(React.createElement(Thumbnail, {
|
|
302
|
+
component: composition.component,
|
|
303
|
+
compositionWidth: composition.width,
|
|
304
|
+
compositionHeight: composition.height,
|
|
305
|
+
durationInFrames: frames,
|
|
306
|
+
fps: composition.fps,
|
|
307
|
+
frameToDisplay: frame,
|
|
308
|
+
inputProps: composition.inputProps ?? {},
|
|
309
|
+
noSuspense: true,
|
|
310
|
+
style: { width: '100%', height: '100%' },
|
|
311
|
+
})))
|
|
312
|
+
},
|
|
313
|
+
dispose: () => root.unmount(),
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const MOTION_ID = 'presmium.motion'
|
|
2
|
+
|
|
3
|
+
/** The directory of a deck's scenes, as `components/` holds its components. */
|
|
4
|
+
export const SCENES_DIR = 'scenes'
|
|
5
|
+
|
|
6
|
+
// Not .tsx nor .jsx: the file does not tell which runtime its JSX is for (React's, Vue's).
|
|
7
|
+
export const SCENE_EXTENSIONS = ['.ts', '.js', '.mjs'] as const
|
|
8
|
+
|
|
9
|
+
/** The scene names a deck can use: the path under `scenes/` without its extension, `/` between folders. */
|
|
10
|
+
export const SCENE_NAME = /^[\w-]+(?:\/[\w-]+)*$/
|
|
11
|
+
|
|
12
|
+
/** Where each step starts on a scene's timeline (ms), and its end. */
|
|
13
|
+
export function stepStarts(durations: readonly number[]): number[] {
|
|
14
|
+
const starts = [0]
|
|
15
|
+
for (const ms of durations) starts.push(starts.at(-1)! + ms)
|
|
16
|
+
return starts
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The scene's place on its timeline (ms) at block step `step` (from 0), `time` ms into it: step k
|
|
21
|
+
* plays the k-th part of the timeline, `durations[k]` long. Before the block's first step the scene
|
|
22
|
+
* is at its start; after the last, at its end; `time` is `Infinity` when the step is settled.
|
|
23
|
+
*/
|
|
24
|
+
export function timelinePosition(durations: readonly number[], step: number, time: number): number {
|
|
25
|
+
const starts = stepStarts(durations)
|
|
26
|
+
if (step < 0) return 0
|
|
27
|
+
if (step >= durations.length) return starts.at(-1)!
|
|
28
|
+
return starts[step]! + Math.min(Math.max(0, time), durations[step]!)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { MOTION_MESSAGES } from './messages.ts'
|
|
32
|
+
export type { MotionMessages } from './messages.ts'
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Locale } from '@lincyaw/kernel/contracts'
|
|
2
|
+
|
|
3
|
+
export interface MotionMessages {
|
|
4
|
+
steps: string
|
|
5
|
+
jump(step: number): string
|
|
6
|
+
loading: string
|
|
7
|
+
missing(scene: string): string
|
|
8
|
+
failed(scene: string, reason: string): string
|
|
9
|
+
play: string
|
|
10
|
+
pause: string
|
|
11
|
+
scrub: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const MOTION_MESSAGES: Record<Locale, MotionMessages> = {
|
|
15
|
+
'zh-CN': {
|
|
16
|
+
steps: '动画步骤',
|
|
17
|
+
jump: step => `跳到动画第 ${step} 步`,
|
|
18
|
+
loading: '正在加载动画…',
|
|
19
|
+
missing: scene => `文稿的 scenes/ 中没有动画 ${scene}`,
|
|
20
|
+
failed: (scene, reason) => `无法播放动画 ${scene}:${reason}`,
|
|
21
|
+
play: '播放动画',
|
|
22
|
+
pause: '暂停动画',
|
|
23
|
+
scrub: '动画进度',
|
|
24
|
+
},
|
|
25
|
+
en: {
|
|
26
|
+
steps: 'Animation steps',
|
|
27
|
+
jump: step => `Jump to Animation Step ${step}`,
|
|
28
|
+
loading: 'Loading the animation…',
|
|
29
|
+
missing: scene => `The document’s scenes/ has no animation ${scene}`,
|
|
30
|
+
failed: (scene, reason) => `Couldn’t play the animation ${scene}: ${reason}`,
|
|
31
|
+
play: 'Play Animation',
|
|
32
|
+
pause: 'Pause Animation',
|
|
33
|
+
scrub: 'Animation progress',
|
|
34
|
+
},
|
|
35
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
4
|
+
import { parseDuration } from '@lincyaw/kernel'
|
|
5
|
+
import type { ComponentNode, DirectiveSpec, MdastNode, PresmiumNodePlugin } from '@lincyaw/kernel'
|
|
6
|
+
import { MOTION_ID, SCENE_EXTENSIONS, SCENE_NAME, SCENES_DIR } from '../domain/index.ts'
|
|
7
|
+
import { deckScenes, sceneFile, SCENES_MODULE, scenesModule } from './scenes.ts'
|
|
8
|
+
|
|
9
|
+
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
10
|
+
|
|
11
|
+
const posix = (path: string) => path.split(sep).join('/')
|
|
12
|
+
|
|
13
|
+
const ASPECT = /^\d+(?:\.\d+)?\s*[/:]\s*\d+(?:\.\d+)?$/
|
|
14
|
+
const STARTER = readFileSync(new URL('./scene-starter.txt', import.meta.url), 'utf8')
|
|
15
|
+
|
|
16
|
+
const scene: DirectiveSpec = {
|
|
17
|
+
name: 'scene',
|
|
18
|
+
arg: true,
|
|
19
|
+
body: true,
|
|
20
|
+
options: { durations: 'list', aspect: 'string', height: 'string', still: 'string' },
|
|
21
|
+
toIR(input, ctx) {
|
|
22
|
+
const report = (message: string, severity: 'error' | 'warning' = 'error') => ctx.report({ checkId: MOTION_ID, severity, message: `{scene}: ${message}` })
|
|
23
|
+
const name = input.arg?.trim() ?? ''
|
|
24
|
+
if (!name) report(`needs the name of a scene of the deck's ${SCENES_DIR}/, e.g. {scene} merge for ${SCENES_DIR}/merge.ts`)
|
|
25
|
+
else if (!SCENE_NAME.test(name)) report(`"${name}" is not a scene name: the path under ${SCENES_DIR}/ without its extension, e.g. merge or bpe/merge`)
|
|
26
|
+
else {
|
|
27
|
+
// Every file the scene could be, so that adding it (or another extension of it) compiles the deck again.
|
|
28
|
+
ctx.watch(...SCENE_EXTENSIONS.map(ext => join(ctx.deckDir, SCENES_DIR, `${name}${ext}`)))
|
|
29
|
+
if (!sceneFile(ctx.deckDir, name)) report(`the deck has no ${SCENES_DIR}/${name}.ts (nor .js, .mjs)`)
|
|
30
|
+
}
|
|
31
|
+
const listed = Array.isArray(input.options.durations) ? input.options.durations as string[] : []
|
|
32
|
+
const durations = listed.map(parseDuration)
|
|
33
|
+
const bad = listed.filter((_, i) => durations[i] === undefined)
|
|
34
|
+
if (bad.length) report(`:durations: takes durations as {cue}\`pause\` does (1.2, 1.2s or 800ms), not ${bad.join(', ')}`)
|
|
35
|
+
const steps = durations.filter((ms): ms is number => ms !== undefined)
|
|
36
|
+
if (steps.length === 0) steps.push(0)
|
|
37
|
+
const aspect = typeof input.options.aspect === 'string' ? input.options.aspect.trim() : undefined
|
|
38
|
+
if (aspect && !ASPECT.test(aspect)) report(`:aspect: takes width/height, e.g. 16/9, not ${aspect}`)
|
|
39
|
+
const height = typeof input.options.height === 'string' ? input.options.height.trim() : undefined
|
|
40
|
+
const caption = input.body.trim()
|
|
41
|
+
const parsed = caption ? ctx.parse(caption) : undefined
|
|
42
|
+
const fallback: MdastNode = { type: 'root', children: [...(parsed?.children as MdastNode[] | undefined) ?? []] }
|
|
43
|
+
// The durations go in the node only: the renderers give them to the component as the prop durations.
|
|
44
|
+
const props: Record<string, unknown> = { scene: name }
|
|
45
|
+
if (aspect && ASPECT.test(aspect)) props.aspect = aspect.replace(':', '/').replace(/\s+/g, '')
|
|
46
|
+
if (height) props.height = height
|
|
47
|
+
if (caption) props.alt = caption.replace(/\s+/g, ' ')
|
|
48
|
+
const node: ComponentNode = { type: 'component', plugin: MOTION_ID, name: 'PmScene', props, durations: steps, fallback }
|
|
49
|
+
const definition = SCENE_NAME.test(name) ? sceneFile(ctx.deckDir, name) : undefined
|
|
50
|
+
if (definition) node.definition = { file: definition, language: definition.endsWith('.ts') ? 'typescript' : 'javascript' }
|
|
51
|
+
const still = typeof input.options.still === 'string' ? input.options.still.trim() : undefined
|
|
52
|
+
if (still) {
|
|
53
|
+
const file = resolve(dirname(ctx.file), still)
|
|
54
|
+
ctx.watch(file)
|
|
55
|
+
if (!existsSync(file)) report(`:still: file not found: ${still}`)
|
|
56
|
+
const url = posix(relative(dirname(ctx.file), file))
|
|
57
|
+
props.still = url
|
|
58
|
+
node.assets = { still: url }
|
|
59
|
+
fallback.children!.unshift({ type: 'paragraph', children: [{ type: 'image', url, alt: caption || name }] })
|
|
60
|
+
}
|
|
61
|
+
return { node, span: steps.length - 1 }
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const plugin: PresmiumNodePlugin = {
|
|
66
|
+
id: MOTION_ID,
|
|
67
|
+
version,
|
|
68
|
+
kernel: '^1.0.0',
|
|
69
|
+
directives: [scene],
|
|
70
|
+
passes: [{
|
|
71
|
+
name: 'presmium.motion.catalog',
|
|
72
|
+
after: 'execute',
|
|
73
|
+
run(doc, ctx) {
|
|
74
|
+
const scenes = deckScenes(ctx.deckDir)
|
|
75
|
+
ctx.watch(join(ctx.deckDir, SCENES_DIR), ...scenes.values())
|
|
76
|
+
const own = doc.config.presmium[MOTION_ID]
|
|
77
|
+
doc.config.presmium[MOTION_ID] = { ...(typeof own === 'object' && own !== null ? own : {}), scenes: [...scenes.keys()], starter: { language: 'typescript', code: STARTER } }
|
|
78
|
+
},
|
|
79
|
+
}],
|
|
80
|
+
documentModules: ({ deckDir }) => ({ [SCENES_MODULE]: scenesModule(deckDir) }),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default plugin
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { gsapScene } from '@lincyaw/addon-motion/scene'
|
|
2
|
+
|
|
3
|
+
export default gsapScene(({ root, gsap }) => {
|
|
4
|
+
root.innerHTML = '<div style="width:var(--pm-space-12);height:var(--pm-space-12);background:var(--pm-color-accent)"></div>'
|
|
5
|
+
return gsap.timeline().fromTo(root.firstElementChild!, { xPercent: 0 }, { xPercent: 200, duration: 1 })
|
|
6
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, relative, sep } from 'node:path'
|
|
3
|
+
import { SCENE_EXTENSIONS, SCENE_NAME, SCENES_DIR } from '../domain/index.ts'
|
|
4
|
+
|
|
5
|
+
export const SCENES_MODULE = 'virtual:presmium-motion/scenes'
|
|
6
|
+
|
|
7
|
+
/** The file of scene `name` in the deck (`scenes/<name>.ts` and the other extensions), if there is one. */
|
|
8
|
+
export function sceneFile(deckDir: string, name: string): string | undefined {
|
|
9
|
+
if (!SCENE_NAME.test(name)) return undefined
|
|
10
|
+
return SCENE_EXTENSIONS.map(ext => join(deckDir, SCENES_DIR, `${name}${ext}`)).find(file => existsSync(file))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The deck's scenes by name: the files under `scenes/`, the first extension of SCENE_EXTENSIONS winning. */
|
|
14
|
+
export function deckScenes(deckDir: string): Map<string, string> {
|
|
15
|
+
const root = join(deckDir, SCENES_DIR)
|
|
16
|
+
const scenes = new Map<string, string>()
|
|
17
|
+
const walk = (dir: string) => {
|
|
18
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
19
|
+
const path = join(dir, entry)
|
|
20
|
+
if (statSync(path).isDirectory()) {
|
|
21
|
+
walk(path)
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
const ext = SCENE_EXTENSIONS.find(e => entry.endsWith(e))
|
|
25
|
+
if (!ext) continue
|
|
26
|
+
const name = relative(root, path).slice(0, -ext.length).split(sep).join('/')
|
|
27
|
+
const known = scenes.get(name)
|
|
28
|
+
if (SCENE_NAME.test(name) && (!known || SCENE_EXTENSIONS.indexOf(ext) < SCENE_EXTENSIONS.findIndex(e => known.endsWith(e)))) scenes.set(name, path)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (existsSync(root) && statSync(root).isDirectory()) walk(root)
|
|
32
|
+
return scenes
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The source of `virtual:presmium-motion/scenes`: each scene of the deck, by name, to an import of its file. */
|
|
36
|
+
export function scenesModule(deckDir: string): string {
|
|
37
|
+
const entries = [...deckScenes(deckDir)].map(([name, file]) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(file)}),`)
|
|
38
|
+
return `export default {\n${entries.join('\n')}\n}\n`
|
|
39
|
+
}
|
|
40
|
+
|