feat: make scenes independent of names (#53)

This PR changes how scenes are modified/reloaded to not relay on their names anymore.
The name of the scene generator function no longer needs to match its file name
and can be even completely omitted:
```ts
export default makeKonvaScene(function* (view) {
  // ...
});
```

This allows for bundling the animation for displaying on web.
Previously, minification would remove the name of the function breaking all time events.

BREAKING CHANGE: change the way scenes are imported

Scene files no longer need to follow the pattern: `[name].scene.tsx`.
When importing scenes in the project file, a dedicated `?scene` query param should be used:
```ts
import example from './scenes/example?scene';

export default new Project({
  name: 'project',
  scenes: [example],
});
```

Closes: #25
This commit is contained in:
Jacob
2022-08-27 01:10:46 +02:00
committed by GitHub
parent 252ed22367
commit 417617eb5f
22 changed files with 207 additions and 190 deletions

38
package-lock.json generated
View File

@@ -21514,15 +21514,15 @@
}
},
"node_modules/vite": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-3.0.5.tgz",
"integrity": "sha512-bRvrt9Tw8EGW4jj64aYFTnVg134E8hgDxyl/eEHnxiGqYk7/pTPss6CWlurqPOUzqvEoZkZ58Ws+Iu8MB87iMA==",
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-3.0.9.tgz",
"integrity": "sha512-waYABTM+G6DBTCpYAxvevpG50UOlZuynR0ckTK5PawNVt7ebX6X7wNXHaGIO6wYYFXSM7/WcuFuO2QzhBB6aMw==",
"dev": true,
"dependencies": {
"esbuild": "^0.14.47",
"postcss": "^8.4.16",
"resolve": "^1.22.1",
"rollup": "^2.75.6"
"rollup": ">=2.75.6 <2.77.0 || ~2.77.0"
},
"bin": {
"vite": "bin/vite.js"
@@ -22419,7 +22419,7 @@
},
"packages/core": {
"name": "@motion-canvas/core",
"version": "10.1.0",
"version": "10.2.0",
"license": "MIT",
"dependencies": {
"@types/prismjs": "^1.26.0",
@@ -22448,7 +22448,7 @@
},
"packages/create": {
"name": "@motion-canvas/create",
"version": "10.1.0",
"version": "10.2.0",
"license": "MIT",
"dependencies": {
"prompts": "^2.4.2"
@@ -22457,8 +22457,8 @@
"create-motion-canvas": "index.js"
},
"devDependencies": {
"@motion-canvas/core": "^10.1.0",
"@motion-canvas/ui": "^10.1.0",
"@motion-canvas/core": "^10.2.0",
"@motion-canvas/ui": "^10.2.0",
"@motion-canvas/vite-plugin": "^10.1.0"
}
},
@@ -22497,15 +22497,15 @@
"devDependencies": {
"@motion-canvas/ui": "*",
"@motion-canvas/vite-plugin": "*",
"vite": "^3.0.5"
"vite": "^3.0.9"
}
},
"packages/ui": {
"name": "@motion-canvas/ui",
"version": "10.1.0",
"version": "10.2.0",
"license": "MIT",
"devDependencies": {
"@motion-canvas/core": "^10.1.0",
"@motion-canvas/core": "^10.2.0",
"@preact/preset-vite": "^2.3.0",
"preact": "10.7.3",
"typescript": "^4.6.4",
@@ -26291,8 +26291,8 @@
"@motion-canvas/create": {
"version": "file:packages/create",
"requires": {
"@motion-canvas/core": "^10.1.0",
"@motion-canvas/ui": "^10.1.0",
"@motion-canvas/core": "^10.2.0",
"@motion-canvas/ui": "^10.2.0",
"@motion-canvas/vite-plugin": "^10.1.0",
"prompts": "^2.4.2"
}
@@ -26322,13 +26322,13 @@
"@motion-canvas/core": "*",
"@motion-canvas/ui": "*",
"@motion-canvas/vite-plugin": "*",
"vite": "^3.0.5"
"vite": "^3.0.9"
}
},
"@motion-canvas/ui": {
"version": "file:packages/ui",
"requires": {
"@motion-canvas/core": "^10.1.0",
"@motion-canvas/core": "^10.2.0",
"@preact/preset-vite": "^2.3.0",
"preact": "10.7.3",
"typescript": "^4.6.4",
@@ -38577,16 +38577,16 @@
}
},
"vite": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-3.0.5.tgz",
"integrity": "sha512-bRvrt9Tw8EGW4jj64aYFTnVg134E8hgDxyl/eEHnxiGqYk7/pTPss6CWlurqPOUzqvEoZkZ58Ws+Iu8MB87iMA==",
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-3.0.9.tgz",
"integrity": "sha512-waYABTM+G6DBTCpYAxvevpG50UOlZuynR0ckTK5PawNVt7ebX6X7wNXHaGIO6wYYFXSM7/WcuFuO2QzhBB6aMw==",
"dev": true,
"requires": {
"esbuild": "^0.14.47",
"fsevents": "~2.3.2",
"postcss": "^8.4.16",
"resolve": "^1.22.1",
"rollup": "^2.75.6"
"rollup": ">=2.75.6 <2.77.0 || ~2.77.0"
}
},
"vscode-oniguruma": {

View File

@@ -5,7 +5,4 @@ module.exports = {
testEnvironment: 'jsdom',
setupFiles: ['jest-canvas-mock', './setup.test.ts'],
testPathIgnorePatterns: ['setup.test.ts'],
globals: {
PROJECT_FILE_NAME: 'tests',
},
};

View File

@@ -25,6 +25,11 @@ declare module '*.glsl' {
export = value;
}
declare module '*?scene' {
const value: import('./lib/scenes/Scene').Scene;
export = value;
}
declare namespace JSX {
type ElementClass = import('konva/lib/Node').Node;
interface ElementChildrenAttribute {
@@ -38,5 +43,3 @@ declare module 'colorjs.io' {
}
declare type Callback = (...args: unknown[]) => void;
declare const PROJECT_FILE_NAME: string;

View File

@@ -26,9 +26,10 @@ export class Meta<T extends Metadata = Metadata> {
}
private readonly data = new ValueDispatcher(<T>{version: META_VERSION});
private source: string | false;
private constructor(private readonly name: string) {}
public constructor(
private readonly name: string,
private source: string | false = false,
) {}
public getData() {
return this.data.current;
@@ -87,7 +88,16 @@ export class Meta<T extends Metadata = Metadata> {
});
}
private static metaLookup: Record<string, Meta> = {};
/**
* Load new metadata from a file.
*
* @param data - New metadata.
*/
public async loadData(data: T) {
data.version ||= META_VERSION;
this.data.current = data;
}
private static sourceLookup: Record<string, Callback> = {};
static {
@@ -97,53 +107,4 @@ export class Meta<T extends Metadata = Metadata> {
});
});
}
/**
* Get the {@link Meta} object for the given entity.
*
* @param name - The name of the entity the metadata refers to.
*
* @typeParam T - The concrete type of the metadata. Depends on the entity.
* See {@link SceneMetadata} and {@link ProjectMetadata} for
* sample types.
*
* @internal
*/
public static getMetaFor<T extends Metadata = Metadata>(
name: string,
): Meta<T> {
this.metaLookup[name] ??= new Meta<T>(name);
return <Meta<T>>this.metaLookup[name];
}
/**
* Register a new version of metadata.
*
* @remarks
* Called directly by meta files themselves.
* Occurs during the initial load as well as during hot reloads.
*
* @param name - The Name of the entity this metadata refers to.
* @param source - The absolute path to the source file.
* @param rawData - New metadata as JSON.
*
* @internal
*/
public static register(
name: string,
source: string | false,
rawData: string,
) {
const meta = Meta.getMetaFor(name);
meta.source = source;
try {
const data: Metadata = JSON.parse(rawData);
data.version ||= META_VERSION;
meta.data.current = data;
} catch (e) {
console.error(`Error when parsing ${source}:`);
console.error(e);
}
}
}

View File

@@ -1,4 +1,4 @@
import {Scene, SceneDescription} from './scenes';
import {Scene} from './scenes';
import {Meta, Metadata} from './Meta';
import {EventDispatcher, ValueDispatcher} from './events';
import {Size, CanvasColorSpace, CanvasOutputMimeType} from './types';
@@ -11,7 +11,7 @@ export const ProjectSize = {
export interface ProjectConfig {
name: string;
scenes: SceneDescription[];
scenes: Scene[];
audio?: string;
audioOffset?: number;
canvas?: HTMLCanvasElement;
@@ -131,7 +131,6 @@ export class Project {
private _quality = 1;
private _speed = 1;
private framesPerSeconds = 30;
private readonly sceneLookup: Record<string, Scene> = {};
private previousScene: Scene = null;
private background: string | false;
private canvas: HTMLCanvasElement;
@@ -153,7 +152,6 @@ export class Project {
this.setSize(size);
this.name = name;
this.background = background;
this.meta = Meta.getMetaFor(PROJECT_FILE_NAME);
if (audio) {
this.audio.setSource(audio);
@@ -163,15 +161,10 @@ export class Project {
}
for (const scene of scenes) {
if (this.sceneLookup[scene.name]) {
console.error('Duplicated scene name: ', scene.name);
continue;
}
const instance = new scene.klass(this, scene.name, scene.config);
instance.onReloaded.subscribe(() => this.reloaded.dispatch());
this.sceneLookup[scene.name] = instance;
scene.project = this;
scene.onReloaded.subscribe(() => this.reloaded.dispatch());
}
this.scenes.current = [...scenes];
ifHot(hot => {
hot.on('motion-canvas:export-ack', ({frame}) => {
@@ -219,7 +212,7 @@ export class Project {
}
private reloadAll() {
for (const scene of Object.values(this.sceneLookup)) {
for (const scene of this.scenes.current) {
scene.reload();
}
}
@@ -260,7 +253,7 @@ export class Project {
const speed = this._speed;
this._speed = 1;
this.frame = 0;
const scenes = Object.values(this.sceneLookup);
const scenes = [...this.scenes.current];
for (const scene of scenes) {
await scene.recalculate();
}
@@ -336,13 +329,9 @@ export class Project {
this.audio.setTime(this.framesToSeconds(this.frame + frameOffset));
}
public updateScene(description: SceneDescription) {
this.sceneLookup[description.name]?.reload(description.config);
}
private findBestScene(frame: number): Scene {
let lastScene = null;
for (const scene of Object.values(this.sceneLookup)) {
for (const scene of this.scenes.current) {
if (!scene.isCached()) {
console.warn(
'Attempting to seek a project with an invalidated scene:',
@@ -360,7 +349,7 @@ export class Project {
}
private getNextScene(scene?: Scene): Scene {
const scenes = Object.values(this.sceneLookup);
const scenes = this.scenes.current;
if (!scene) {
return scenes[0];
}

View File

@@ -24,7 +24,7 @@ export abstract class GeneratorScene<T>
implements Scene<ThreadGeneratorFactory<T>>, Threadable
{
public readonly timeEvents: TimeEvents;
public readonly meta: Meta<SceneMetadata>;
public project: Project;
public get firstFrame() {
return this.cache.current.firstFrame;
@@ -84,13 +84,11 @@ export abstract class GeneratorScene<T>
private counters: Record<string, number> = {};
public constructor(
public readonly project: Project,
public readonly name: string,
public readonly meta: Meta<SceneMetadata>,
private runnerFactory: ThreadGeneratorFactory<T>,
) {
decorate(this.runnerFactory, threadable(name));
this.meta = Meta.getMetaFor(`${name}.scene`);
this.timeEvents = new TimeEvents(this);
}

View File

@@ -47,7 +47,6 @@ export function makeKonvaScene(
factory: ThreadGeneratorFactory<KonvaView>,
): SceneDescription {
return {
name: factory.name,
config: factory,
klass: KonvaScene,
};

View File

@@ -21,7 +21,7 @@ export interface SceneMetadata extends Metadata {
* {@link SceneDescription.config}.
*/
export interface SceneConstructor<T> {
new (project: Project, name: string, config: T): Scene;
new (name: string, meta: Meta, config: T): Scene;
}
/**
@@ -34,13 +34,6 @@ export interface SceneDescription<T = unknown> {
* The class used to instantiate the scene.
*/
klass: SceneConstructor<T>;
/**
* The name of the scene.
*
* @remarks
* Should match the first portion of the file name (`[name].scene.ts`).
*/
name: string;
/**
* Configuration object.
*/
@@ -98,11 +91,8 @@ export interface Scene<T = unknown> {
readonly name: string;
/**
* Reference to the project.
*
* @remarks
* Will be passed as the first argument to the constructor.
*/
readonly project: Project;
project: Project;
readonly timeEvents: TimeEvents;
readonly meta: Meta<SceneMetadata>;

View File

@@ -58,17 +58,8 @@ export class TimeEvents {
private preserveTiming = true;
public constructor(private readonly scene: Scene) {
const storageKey = `scene-${scene.project.name}-${scene.name}`;
const storedEvents = localStorage.getItem(storageKey);
if (storedEvents) {
console.warn('Migrating localStorage to meta files');
localStorage.setItem(`${storageKey}-backup`, storedEvents);
localStorage.removeItem(storageKey);
this.load(Object.values<TimeEvent>(JSON.parse(storedEvents)));
} else {
this.previousReference = scene.meta.getData().timeEvents ?? [];
this.load(this.previousReference);
}
this.previousReference = scene.meta.getData().timeEvents ?? [];
this.load(this.previousReference);
scene.onReloaded.subscribe(this.handleReload);
scene.onRecalculated.subscribe(this.handleRecalculated);

View File

@@ -1,6 +1,6 @@
import {Project} from '@motion-canvas/core/lib';
import example from './scenes/example.scene';
import example from './scenes/example?scene';
export default new Project({
name: 'project',

View File

@@ -1,7 +1,7 @@
import {makeKonvaScene} from '@motion-canvas/core/lib/scenes';
import {waitFor} from '@motion-canvas/core/lib/flow';
export default makeKonvaScene(function* example(view) {
export default makeKonvaScene(function* (view) {
// Create your animations here
yield* waitFor(5);

View File

@@ -1,6 +1,6 @@
import {Project} from '@motion-canvas/core/lib';
import example from './scenes/example.scene';
import example from './scenes/example?scene';
export default new Project({
name: 'project',

View File

@@ -1,7 +1,7 @@
import {makeKonvaScene} from '@motion-canvas/core/lib/scenes';
import {waitFor} from '@motion-canvas/core/lib/flow';
export default makeKonvaScene(function* example(view) {
export default makeKonvaScene(function* (view) {
// Create your animations here
yield* waitFor(5);

View File

@@ -51,16 +51,16 @@ must add an element to our scene.
### Programming an animation
The scaffolding command will create several files for you, but for now we're
going to focus on `src/scenes/example.scene.tsx`, which is where we can add our
animations. Open `example.scene.tsx` in a text editor, and replace all code in
going to focus on `src/scenes/example.tsx`, which is where we can add our
animations. Open `example.tsx` in a text editor, and replace all code in
the file with the following snippet.
```tsx
```tsx title="src/scenes/example.tsx"
import {makeKonvaScene} from '@motion-canvas/core/lib/scenes';
import {Circle} from 'konva/lib/shapes/Circle';
import {useRef} from '@motion-canvas/core/lib/utils';
export default makeKonvaScene(function* example(view) {
export default makeKonvaScene(function* (view) {
const myCircle = useRef();
view.add(
@@ -99,7 +99,7 @@ function.
import {makeKonvaScene} from '@motion-canvas/core/lib/scenes';
// highlight-next-line
export default makeKonvaScene(function* example(view) {
export default makeKonvaScene(function* (view) {
// animation code
});
```
@@ -122,7 +122,7 @@ import {makeKonvaScene} from '@motion-canvas/core/lib/scenes';
// highlight-next-line
import {Circle} from 'konva/lib/shapes/Circle';
export default makeKonvaScene(function* example(view) {
export default makeKonvaScene(function* (view) {
// highlight-start
view.add(
<Circle
@@ -148,7 +148,7 @@ import {Circle} from 'konva/lib/shapes/Circle';
// highlight-next-line
import {useRef} from '@motion-canvas/core/lib/utils';
export default makeKonvaScene(function* example(view) {
export default makeKonvaScene(function* (view) {
// highlight-next-line
const myCircle = useRef();
@@ -214,7 +214,7 @@ import {Circle} from 'konva/lib/shapes/Circle';
import {useRef} from '@motion-canvas/core/lib/utils';
// make a new konva scene and pass it the scene function
export default makeKonvaScene(function* example(view) {
export default makeKonvaScene(function* (view) {
// create a reference to store the circle
const myCircle = useRef();

View File

@@ -19,10 +19,10 @@ npm init @motion-canvas
Upgrade the versions of all motion-canvas packages in your `package.json` file:
```diff
- "@motion-canvas/core": "9.0.0",
- "@motion-canvas/ui": "9.0.0",
+ "@motion-canvas/core": "10.0.0",
+ "@motion-canvas/ui": "10.0.0",
- "@motion-canvas/core": "^9.0.0",
- "@motion-canvas/ui": "^9.0.0",
+ "@motion-canvas/core": "^10.0.0",
+ "@motion-canvas/ui": "^10.0.0",
```
To apply the changes, run:

View File

@@ -0,0 +1,71 @@
---
title: v11.0.0
---
# Migrating to version 11.0.0
:::tip
If you're starting a new project, you can quickly scaffold it using:
```bash
npm init @motion-canvas
```
:::
## Install the new version
Upgrade the versions of all motion-canvas packages in your `package.json` file:
```diff
- "@motion-canvas/core": "^10.0.0",
- "@motion-canvas/ui": "^10.0.0",
- "@motion-canvas/plugin-vite": "^10.0.0",
+ "@motion-canvas/core": "^11.0.0",
+ "@motion-canvas/ui": "^11.0.0",
+ "@motion-canvas/plugin-vite": "^11.0.0",
```
To apply the changes, run:
```bash
npm install
```
## Update project file
Since version 11, scene file names no longer need to follow the pattern: `[name].scene.tsx`.
Instead, a dedicated `?scene` query flag is used when importing a scene in the project file:
```diff title="src/project.tsx"
- import example from './scenes/example.scene';
+ import example from './scenes/example?scene';
export default new Project({
name: 'project',
scenes: [example],
});
```
:::note
In the above example, we also changed the name of the scene file from `example.scene.tsx` to
`example.tsx`. This way we avoid the redundant `scene` when importing it.
Of course, if you want, you can keep the old file name and import it as: `example.scene?scene`.
:::
This instructs our Vite plugin to turn the imported module into a scene.
It will instantiate an adequate class, load its metadata, and set up hot module replacement.
Thanks to this change, the name of your scene generator function no longer
needs to match its file name. In fact, it can be completely omitted:
```diff title="src/scenes/example.tsx"
- export default makeKonvaScene(function* example(view) {
+ export default makeKonvaScene(function* (view) {
// ...
});
```

View File

@@ -16,6 +16,6 @@
"devDependencies": {
"@motion-canvas/ui": "*",
"@motion-canvas/vite-plugin": "*",
"vite": "^3.0.5"
"vite": "^3.0.9"
}
}

View File

@@ -1,6 +1,6 @@
import {Project} from '@motion-canvas/core/lib';
import example from './scenes/example.scene';
import example from './scenes/example?scene';
export default new Project({
name: 'project',

View File

@@ -0,0 +1,4 @@
{
"version": 1,
"timeEvents": []
}

View File

@@ -1,3 +0,0 @@
{
"version": 1
}

View File

@@ -1,7 +1,7 @@
import {makeKonvaScene} from '@motion-canvas/core/lib/scenes';
import {waitFor} from '@motion-canvas/core/lib/flow';
export default makeKonvaScene(function* example(view) {
export default makeKonvaScene(function* (view) {
// Create your animations here
yield* waitFor(5);

View File

@@ -71,7 +71,6 @@ export default ({
const editorId = 'virtual:editor';
const resolvedEditorId = '\0' + editorId;
const timeStamps: Record<string, number> = {};
const projectName = path.parse(project).name;
const outputPath = path.resolve(output);
let viteConfig: ResolvedConfig;
@@ -100,15 +99,63 @@ export default ({
return resolvedEditorId;
}
},
load(id) {
async load(id) {
if (id === resolvedEditorId) {
return source(
`import '${styles}';`,
`import editor from '${factory}';`,
`import project from '${project}';`,
`import project from '${project}?project';`,
`editor(project);`,
);
}
const [base, query] = id.split('?');
const {name, dir} = path.posix.parse(base);
if (query) {
const params = new URLSearchParams(query);
if (params.has('scene')) {
const metaFile = `${name}.meta`;
await createMeta(path.join(dir, metaFile));
const sceneFile = `${name}`;
return source(
`import meta from './${metaFile}';`,
`import description from './${sceneFile}';`,
`let scene;`,
`if (import.meta.hot) {`,
` scene = import.meta.hot.data.scene;`,
`}`,
`scene ??= new description.klass('${name}', meta, description.config);`,
`if (import.meta.hot) {`,
` import.meta.hot.accept();`,
` if (import.meta.hot.data.scene) {`,
` scene.reload(description.config);`,
` } else {`,
` import.meta.hot.data.scene = scene;`,
` }`,
`}`,
`export default scene;`,
);
}
if (params.has('project')) {
const metaFile = `${name}.meta`;
await createMeta(path.join(dir, metaFile));
const projectFile = `${name}`;
return source(
`import '@motion-canvas/core/lib/patches/Factory';`,
`import '@motion-canvas/core/lib/patches/Node';`,
`import '@motion-canvas/core/lib/patches/Shape';`,
`import '@motion-canvas/core/lib/patches/Container';`,
`import meta from './${metaFile}';`,
`import project from './${projectFile}';`,
`project.meta = meta`,
`export default project;`,
);
}
}
},
async transform(code, id) {
const [base, query] = id.split('?');
@@ -148,49 +195,22 @@ export default ({
}
if (ext === '.meta') {
const sourceFile = viteConfig.command === 'build' ? false : `'${id}'`;
return source(
`import {Meta} from '@motion-canvas/core/lib';`,
`Meta.register(`,
` '${name}',`,
` ${viteConfig.command === 'build' ? false : `'${id}'`},`,
` \`${code}\``,
`);`,
`let meta;`,
`if (import.meta.hot) {`,
` meta = import.meta.hot.data.meta;`,
`}`,
`meta ??= new Meta('${name}', ${sourceFile}, ${code});`,
`if (import.meta.hot) {`,
` import.meta.hot.accept();`,
` import.meta.hot.data.meta = meta;`,
`}`,
`meta.loadData(${code});`,
`export default meta;`,
);
}
if (name === projectName && (await this.resolve(project))?.id === id) {
const metaFile = `${name}.meta`;
await createMeta(path.join(dir, metaFile));
const imports =
`import '@motion-canvas/core/lib/patches/Factory';` +
`import '@motion-canvas/core/lib/patches/Node';` +
`import '@motion-canvas/core/lib/patches/Shape';` +
`import '@motion-canvas/core/lib/patches/Container';` +
`import './${metaFile}';`;
return imports + code;
}
if (name.endsWith('.scene')) {
const metaFile = `${name}.meta`;
await createMeta(path.join(dir, metaFile));
const imports =
`import './${metaFile}';` +
`import {useProject as __useProject} from '@motion-canvas/core/lib/utils';`;
const hmr = source(
`if (import.meta.hot) {`,
` import.meta.hot.accept(module => {`,
` __useProject()?.updateScene(module.default);`,
` });`,
`}`,
);
return imports + code + '\n' + hmr;
}
},
handleHotUpdate(ctx) {
const now = Date.now();
@@ -272,12 +292,9 @@ export default ({
jsx: 'automatic',
jsxImportSource: '@motion-canvas/core/lib',
},
define: {
PROJECT_FILE_NAME: `'${projectName}'`,
},
build: {
lib: {
entry: project,
entry: `${project}?project`,
formats: ['es'],
},
},