feat: switch to Vite (#28)

This PR replaces webpack with Vite and restructures the project
to prepare for bundling animations in the future.

BREAKING CHANGE: change the overall structure of a project

`vite` and `@motion-canvas/vite-plugin` packages are now required to build a project:
```
npm i -D vite @motion-canvas/vite-plugin
```
The following `vite.config.ts` file needs to be created in the root of the project:
```ts
import {defineConfig} from 'vite';
import motionCanvas from '@motion-canvas/vite-plugin';

export default defineConfig({
  plugins: [motionCanvas()],
});
```

Types exposed by Motion Canvas are no longer global.
An additional `motion-canvas.d.ts` file needs to be created in the `src` directory:
```ts
/// <reference types="@motion-canvas/core/project" />
```

 Finally, the `bootstrap` function no longer exists.
 Project files should export an instance of the `Project` class instead:
 ```ts
 import {Project} from '@motion-canvas/core/lib';

 import example from './scenes/example.scene';

 export default new Project({
   name: 'project',
   scenes: [example],
   // same options as in bootstrap() are available:
   background: '#141414',
 });
 ```
Including `import '@motion-canvas/core/lib/patches';` at the top of the project file is no longer necessary.

Closes: #13
This commit is contained in:
Jacob
2022-08-10 23:18:48 +02:00
committed by GitHub
parent 355ce869ab
commit 65b91337db
73 changed files with 1759 additions and 1689 deletions

1796
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,11 +10,13 @@
"core:watch": "npm run watch -w packages/core",
"core:test": "npm run test -w packages/core",
"ui:build": "npm run build -w packages/ui",
"ui:serve": "npm run serve -w packages/ui",
"ui:dev": "npm run dev -w packages/ui",
"template:serve": "npm run serve -w packages/template",
"template:ui": "npm run ui -w packages/template",
"template:build": "npm run build -w packages/template",
"docs:start": "npm run start -w packages/docs",
"docs:build": "npm run build -w packages/docs",
"vite-plugin:build": "npm run build -w packages/vite-plugin",
"vite-plugin:watch": "npm run watch -w packages/vite-plugin",
"eslint": "eslint --fix \"**/*.ts?(x)\"",
"prettier": "prettier --write ."
},

View File

@@ -1,198 +0,0 @@
#!/usr/bin/env node
import path from 'path';
import fs from 'fs';
import {fileURLToPath} from 'url';
import webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import meow from 'meow';
import UIPlugin from './plugins/UIPlugin.mjs';
import {createRequire} from 'module';
import {metaImportTransformer} from './transformers/metaImportTransformer.mjs';
const cli = meow({
importMeta: import.meta,
flags: {
uiServer: {
type: 'boolean',
default: false,
},
uiPath: {
type: 'string',
default: '',
},
output: {
type: 'string',
alias: 'o',
default: 'output',
},
},
});
if (cli.flags.uiServer) {
cli.flags.uiPath ||= 'http://localhost:9001/main.js';
} else {
if (cli.flags.uiPath) {
cli.flags.uiPath = path.resolve(cli.flags.uiPath);
} else {
const require = createRequire(import.meta.url);
cli.flags.uiPath = path.dirname(require.resolve('@motion-canvas/ui'));
}
}
const META_VERSION = 1;
const projectFile = path.resolve(cli.input[0]);
const renderOutput = path.resolve(cli.flags.output);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageJSON = JSON.parse(
fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf8'),
);
const compiler = webpack({
entry: {project: projectFile},
mode: 'development',
devtool: 'inline-source-map',
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
options: {
getCustomTransformers: () => ({
before: [
metaImportTransformer({
project: projectFile,
version: META_VERSION,
}),
],
}),
},
},
{
test: /\.meta/i,
loader: 'meta-loader',
},
{
test: /\.(wav|mp3|ogg|mp4)$/i,
type: 'asset',
},
{
test: /\.(png|jpe?g)$/i,
oneOf: [
{
resourceQuery: /img/,
loader: 'image-loader',
},
{
resourceQuery: /anim/,
loader: 'animation-loader',
},
{
type: 'asset',
},
],
},
{
test: /\.csv$/,
loader: 'csv-loader',
options: {
dynamicTyping: true,
header: true,
skipEmptyLines: true,
},
},
{
test: /\.glsl$/i,
type: 'asset/source',
},
],
},
resolveLoader: {
modules: ['node_modules', path.resolve(__dirname, './loaders')],
},
resolve: {
extensions: ['.js', '.ts', '.tsx'],
},
output: {
filename: `[name].js`,
publicPath: '/',
path: __dirname,
},
plugins: [
new webpack.ProvidePlugin({
// Required to load additional languages for Prism
Prism: 'prismjs',
}),
new webpack.DefinePlugin({
PROJECT_FILE_NAME: `'${path.parse(projectFile).name}'`,
CORE_VERSION: `'${packageJSON.version}'`,
META_VERSION,
}),
new HtmlWebpackPlugin({title: 'Motion Canvas'}),
new UIPlugin(cli.flags),
],
});
const server = new WebpackDevServer(
{
compress: true,
port: 9000,
hot: true,
static: [
{
directory: path.join(__dirname, '../api'),
publicPath: '/api',
watch: false,
},
],
setupMiddlewares: middlewares => {
middlewares.unshift({
name: 'render',
path: '/render/:name',
middleware: (req, res) => {
const file = path.join(renderOutput, req.params.name);
const directory = path.dirname(file);
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, {recursive: true});
}
const stream = fs.createWriteStream(file, {encoding: 'base64'});
req.pipe(stream);
req.on('end', () => res.end());
},
});
middlewares.unshift({
name: 'meta',
path: '/meta/:source',
middleware: (req, res) => {
const stream = fs.createWriteStream(
path.join(compiler.context, req.params.source),
{encoding: 'utf8'},
);
req.pipe(stream);
req.on('end', () => res.end());
},
});
if (!cli.flags.uiServer) {
middlewares.unshift({
name: 'ui',
path: '/ui/:name',
middleware: (req, res) => {
fs.createReadStream(path.join(cli.flags.uiPath, req.params.name), {
encoding: 'utf8',
})
.on('error', () => res.sendStatus(404))
.pipe(res);
},
});
}
return middlewares;
},
},
compiler,
);
server.start().catch(console.error);

View File

@@ -1,30 +0,0 @@
const path = require('path');
const {readdirSync} = require('fs');
const nameRegex = /\D*(\d+)\.png$/;
function animationLoader() {
const callback = this.async();
const directoryPath = path.dirname(this.resourcePath);
const files = readdirSync(directoryPath)
.map(file => nameRegex.exec(file))
.filter(match => !!match)
.map(match => [match.input, parseInt(match[1])])
.sort(([, indexA], [, indexB]) =>
indexA < indexB ? -1 : indexA > indexB ? 1 : 0,
)
.map(([file]) => path.resolve(directoryPath, file));
loadAnimation(files, this.importModule)
.then(code => callback(null, code))
.catch(error => callback(error));
}
async function loadAnimation(files, importModule) {
const urls = await Promise.all(files.map(file => importModule(file)));
return `import {loadAnimation} from '@motion-canvas/core/lib/media';
export default loadAnimation(${JSON.stringify(urls)});`;
}
module.exports = animationLoader;

View File

@@ -1,15 +0,0 @@
function imageLoader() {
const callback = this.async();
loadImage(this.resourcePath, this.importModule)
.then(code => callback(null, code))
.catch(error => callback(error));
}
async function loadImage(fileName, importModule) {
const url = await importModule(fileName);
return `import {loadImage} from '@motion-canvas/core/lib/media';
export default loadImage('${url}');`;
}
module.exports = imageLoader;

View File

@@ -1,39 +0,0 @@
const {SourceMapGenerator} = require('source-map');
const path = require('path');
/**
* Webpack Loader for meta files.
*
* @param {string} source
*/
function metaLoader(source) {
const relative = path.relative(this.rootContext, this.resourcePath);
const name = path.basename(relative, '.meta');
const generator = new SourceMapGenerator({file: this.resourcePath});
const lines = source.split('\n');
for (let i = 0; i < lines.length; i++) {
generator.addMapping({
source: this.resourcePath,
generated: {
line: i + 5,
column: i === 0 ? 4 : 3,
},
original: {
line: i + 1,
column: 1,
},
});
}
generator.setSourceContent(this.resourcePath, source);
const map = generator.toJSON();
const content = `import {Meta} from '@motion-canvas/core/lib';
Meta.register(
'${name}',
'${encodeURIComponent(relative)}',
\`${source}\`
);
module.hot.accept(console.error);`;
this.callback(null, content, map);
}
module.exports = metaLoader;

View File

@@ -1,23 +0,0 @@
import HtmlWebpackPlugin from 'html-webpack-plugin';
export default class UIPlugin {
constructor(config) {
this.name = 'UIPlugin';
this.config = config;
}
apply(compiler) {
compiler.hooks.compilation.tap(this.name, compilation => {
HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tapAsync(
this.name,
(data, cb) => {
data.assets.js.push(
this.config.uiServer ? this.config.uiPath : 'ui/main.js',
);
data.assets.favicon = 'data:;base64,iVBORw0KGgo=';
cb(null, data);
},
);
});
}
}

View File

@@ -1,68 +0,0 @@
import ts from 'typescript';
import path from 'path';
import fs from 'fs';
const SCENE_REGEX = /\.scene\.tsx?$/;
/**
* Create a transformer that adds meta imports to projects and scenes.
*
* Example meta import:
* ```ts
* import './name.meta';
* ```
*
* @param {{project: string, version: string}} config
* @return {(context: TransformationContext) => TransformerFactory}
*/
export function metaImportTransformer(config) {
return context => {
const visitor = node => {
if (
node.kind === ts.SyntaxKind.SourceFile &&
(config.project === path.resolve(node.fileName) ||
SCENE_REGEX.test(node.fileName))
) {
node = createMeta(context, node, config.version);
}
return ts.visitEachChild(node, visitor, context);
};
return node => ts.visitNode(node, visitor);
};
}
/**
* Add a meta import at the top of the given SourceFile.
*
* @param {TransformationContext} context
* @param {SourceFile} node
* @param {string} version
*
* @return {SourceFile}
*/
function createMeta(context, node, version) {
const {name, dir} = path.parse(node.fileName);
const metaFile = `${name}.meta`;
const metaPath = path.join(dir, metaFile);
if (!fs.existsSync(metaPath)) {
fs.writeFileSync(metaPath, JSON.stringify({version}, undefined, 2), 'utf8');
}
const importStringLiteral = context.factory.createStringLiteral(
`./${metaFile}`,
true,
);
const importDeclaration = context.factory.createImportDeclaration(
undefined,
undefined,
undefined,
importStringLiteral,
);
return context.factory.updateSourceFile(node, [
importDeclaration,
...node.statements,
]);
}

View File

@@ -1,12 +1,11 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
rootDir: 'src',
preset: 'ts-jest',
preset: 'ts-jest/presets/js-with-ts-esm',
testEnvironment: 'jsdom',
setupFiles: ['jest-canvas-mock'],
setupFiles: ['jest-canvas-mock', './setup.test.ts'],
testPathIgnorePatterns: ['setup.test.ts'],
globals: {
CORE_VERSION: '1.0.0',
PROJECT_FILE_NAME: 'tests',
META_VERSION: 1,
},
};

View File

@@ -17,33 +17,23 @@
"type": "git",
"url": "https://github.com/motion-canvas/motion-canvas.git"
},
"bin": {
"motion-canvas": "bin/index.mjs"
},
"files": [
"lib",
"bin",
"tsconfig.project.json"
"tsconfig.project.json",
"project.d.ts"
],
"peerDependencies": {
"vite": "^3.0.5"
},
"dependencies": {
"@types/prismjs": "^1.26.0",
"@types/three": "^0.141.0",
"@types/webpack-env": "^1.17.0",
"colorjs.io": "^0.3.0",
"html-webpack-plugin": "^5.5.0",
"image-size": "^1.0.1",
"konva": "^8.3.9",
"meow": "^10.1.2",
"mix-color": "^1.1.2",
"mp4box": "^0.5.2",
"prismjs": "^1.28.0",
"source-map": "^0.7.4",
"three": "^0.141.0",
"ts-loader": "^9.3.0",
"typescript": "^4.7.3",
"webpack": "^5.73.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.9.2"
"three": "^0.141.0"
},
"devDependencies": {
"@types/dom-webcodecs": "^0.1.4",
@@ -52,6 +42,8 @@
"jest": "^28.1.2",
"jest-canvas-mock": "^2.4.0",
"jest-environment-jsdom": "^28.1.2",
"ts-jest": "^28.0.5"
"ts-jest": "^28.0.5",
"typescript": "^4.7.4",
"vite": "^3.0.5"
}
}

38
packages/core/project.d.ts vendored Normal file
View File

@@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/no-namespace */
/// <reference types="vite/client" />
declare module '*?img' {
const value: Promise<HTMLImageElement>;
export = value;
}
declare module '*?anim' {
const value: Promise<HTMLImageElement[]>;
export = value;
}
declare module '*.csv' {
const value: unknown;
export = value;
}
declare module '*.glsl' {
const value: string;
export = value;
}
declare namespace JSX {
type ElementClass = import('konva/lib/Node').Node;
interface ElementChildrenAttribute {
children: unknown;
}
}
declare module 'colorjs.io' {
const noTypesYet: any;
export default noTypesYet;
}
declare type Callback = (...args: unknown[]) => void;
declare const PROJECT_FILE_NAME: string;

View File

@@ -1,4 +1,7 @@
import {ValueDispatcher} from './events';
import {ifHot} from './utils';
const META_VERSION = 1;
/**
* Represents the contents of a meta file.
@@ -23,12 +26,9 @@ export class Meta<T extends Metadata = Metadata> {
}
private readonly data = new ValueDispatcher(<T>{version: META_VERSION});
private rawData: string;
private source: string;
private source: string | false;
private constructor(private readonly name: string) {
this.rawData = JSON.stringify(this.data.current, undefined, 2);
}
private constructor(private readonly name: string) {}
public getData() {
return this.data.current;
@@ -51,24 +51,52 @@ export class Meta<T extends Metadata = Metadata> {
...this.data.current,
...data,
};
this.rawData = JSON.stringify(this.data.current, undefined, 2);
if (this.source) {
const response = await fetch(`/meta/${this.source}`, {
method: 'POST',
body: this.rawData,
});
if (!response.ok) {
throw new Error(response.statusText);
await ifHot(async hot => {
if (this.source === false) {
return;
}
} else {
console.warn(
`The meta file for ${this.name} is missing\n`,
`Make sure the file containing your scene is called "${this.name}.ts to match the generator function name`,
);
}
if (!this.source) {
console.warn(
`The meta file for ${this.name} is missing\n`,
`Make sure the file containing your scene is called "${this.name}.ts" to match the generator function name`,
);
return;
}
if (Meta.sourceLookup[this.source]) {
console.warn(`Metadata for ${this.name} is already being updated`);
return;
}
const source = this.source;
await new Promise<void>((resolve, reject) => {
setTimeout(() => {
delete Meta.sourceLookup[source];
reject(`Connection timeout when updating metadata for ${this.name}`);
}, 1000);
Meta.sourceLookup[source] = () => {
delete Meta.sourceLookup[source];
resolve();
};
hot.send('motion-canvas:meta', {
source,
data: this.data.current,
});
});
});
}
private static metaLookup: Record<string, Meta> = {};
private static sourceLookup: Record<string, Callback> = {};
static {
ifHot(hot => {
hot.on('motion-canvas:meta-ack', ({source}) => {
this.sourceLookup[source]?.();
});
});
}
/**
* Get the {@link Meta} object for the given entity.
@@ -96,27 +124,25 @@ export class Meta<T extends Metadata = Metadata> {
* 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 path to the source file relative to the compilation
* context.
* @param source - The absolute path to the source file.
* @param rawData - New metadata as JSON.
*
* @internal
*/
public static register(name: string, source: string, rawData: string) {
public static register(
name: string,
source: string | false,
rawData: string,
) {
const meta = Meta.getMetaFor(name);
meta.source = source;
if (meta.rawData === rawData) {
return;
}
try {
const data: Metadata = JSON.parse(rawData);
data.version ??= META_VERSION;
data.version ||= META_VERSION;
meta.data.current = data;
meta.rawData = rawData;
} catch (e) {
console.error(`Error when parsing ${decodeURIComponent(source)}:`);
console.error(`Error when parsing ${source}:`);
console.error(e);
}
}

View File

@@ -1,7 +1,8 @@
import {Scene, SceneDescription} from './scenes';
import {Meta, Metadata} from './Meta';
import {ValueDispatcher} from './events';
import {EventDispatcher, ValueDispatcher} from './events';
import {Size, CanvasColorSpace} from './types';
import {AudioManager} from './media';
export const ProjectSize = {
FullHD: {width: 1920, height: 1080},
@@ -10,26 +11,40 @@ export const ProjectSize = {
export interface ProjectConfig {
name: string;
scenes: SceneDescription[];
audio?: string;
audioOffset?: number;
canvas?: HTMLCanvasElement;
size?: Size;
background?: string | false;
width?: number;
height?: number;
}
export type ProjectMetadata = Metadata;
export class Project {
/**
* Triggered after the scenes were recalculated.
*/
public get onScenesChanged() {
return this.scenes.subscribable;
}
private readonly scenes = new ValueDispatcher<Scene[]>([]);
/**
* Triggered when the current scene changes.
*/
public get onCurrentSceneChanged() {
return this.currentScene.subscribable;
}
private readonly currentScene = new ValueDispatcher<Scene>(null);
public readonly version = CORE_VERSION;
/**
* Triggered after any of the scenes were reloaded.
*/
public get onReloaded() {
return this.reloaded;
}
private readonly reloaded = new EventDispatcher<void>();
public readonly meta: Meta<ProjectMetadata>;
public frame = 0;
@@ -88,6 +103,7 @@ export class Project {
this.height = height;
}
this.updateCanvas();
this.reloadAll();
}
public getSize(): Size {
@@ -98,6 +114,7 @@ export class Project {
}
public readonly name: string;
public readonly audio = new AudioManager();
private _resolutionScale = 1;
private _colorSpace: CanvasColorSpace = 'srgb';
private _speed = 1;
@@ -111,27 +128,37 @@ export class Project {
private width: number;
private height: number;
public constructor(config: ProjectConfig) {
this.setCanvas(config.canvas ?? null);
this.setSize(
config.width ?? ProjectSize.FullHD.width,
config.height ?? ProjectSize.FullHD.height,
);
this.name = config.name;
this.background = config.background ?? false;
public constructor({
name,
scenes,
audio,
audioOffset,
canvas,
size = ProjectSize.FullHD,
background = false,
}: ProjectConfig) {
this.setCanvas(canvas);
this.setSize(size);
this.name = name;
this.background = background;
this.meta = Meta.getMetaFor(PROJECT_FILE_NAME);
for (const scene of config.scenes) {
if (audio) {
this.audio.setSource(audio);
}
if (audioOffset) {
this.audio.setOffset(audioOffset);
}
for (const scene of scenes) {
if (this.sceneLookup[scene.name]) {
console.error('Duplicated scene name: ', scene.name);
continue;
}
this.sceneLookup[scene.name] = new scene.klass(
this,
scene.name,
scene.config,
);
const instance = new scene.klass(this, scene.name, scene.config);
instance.onReloaded.subscribe(() => this.reloaded.dispatch());
this.sceneLookup[scene.name] = instance;
}
}
@@ -173,13 +200,7 @@ export class Project {
this.currentScene.current?.render(this.context, this.canvas);
}
public reload(runners: SceneDescription[]) {
for (const runner of runners) {
this.sceneLookup[runner.name]?.reload(runner.config);
}
}
public reloadAll() {
private reloadAll() {
for (const scene of Object.values(this.sceneLookup)) {
scene.reload();
}
@@ -265,6 +286,14 @@ export class Project {
return finished;
}
public syncAudio(frameOffset = 0) {
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)) {

View File

@@ -1,52 +0,0 @@
import './globals';
import type {Size} from './types';
import type {SceneDescription} from './scenes';
import {Project, ProjectSize} from './Project';
import {Player} from './player';
import {hot} from './hot';
import {AudioManager} from './media';
interface BootstrapConfig {
name: string;
scenes: SceneDescription[];
size?: Size;
background?: string | false;
audio?: string;
audioOffset?: number;
}
export function bootstrap(config: BootstrapConfig) {
const project = new Project({
name: config.name,
scenes: config.scenes,
background: config.background ?? '#141414',
...(config.size ?? ProjectSize.FullHD),
});
const audio = new AudioManager();
if (config.audio) {
audio.setSource(config.audio);
}
if (config.audioOffset) {
audio.setOffset(config.audioOffset);
}
const player = new Player(project, audio);
window.player = player;
let root: NodeModule = null;
const queue = [...module.parents];
while (queue.length > 0) {
const path = queue.shift();
const current = __webpack_require__.c[path];
if (!path.endsWith('lib/index.js')) {
root = current;
break;
}
queue.push(...current.parents);
}
if (root) {
hot(player, root);
} else {
console.warn('Root module not found. Hot reload will not work.');
}
}

View File

@@ -1,90 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/no-namespace */
declare module '*.png' {
const value: string;
export = value;
}
declare module '*.png?img' {
const value: Promise<HTMLImageElement>;
export = value;
}
declare module '*.png?anim' {
const value: Promise<HTMLImageElement[]>;
export = value;
}
declare module '*.jpg' {
const value: string;
export = value;
}
declare module '*.jpg?img' {
const value: Promise<HTMLImageElement>;
export = value;
}
declare module '*.jpg?anim' {
const value: Promise<HTMLImageElement[]>;
export = value;
}
declare module '*.jpeg' {
const value: string;
export = value;
}
declare module '*.jpeg?img' {
const value: Promise<HTMLImageElement>;
export = value;
}
declare module '*.jpeg?anim' {
const value: Promise<HTMLImageElement[]>;
export = value;
}
declare module '*.wav' {
const value: string;
export = value;
}
declare module '*.mp3' {
const value: string;
export = value;
}
declare module '*.ogg' {
const value: string;
export = value;
}
declare module '*.mp4' {
const value: string;
export = value;
}
declare module '*.csv' {
const value: unknown;
export = value;
}
declare module '*.glsl' {
const value: string;
export = value;
}
declare interface Window {
player: import('./player/Player').Player;
}
declare namespace NodeJS {
interface Module {
parents: string[];
}
}
declare namespace JSX {
type ElementClass = import('konva/lib/Node').Node;
interface ElementChildrenAttribute {
@@ -96,10 +11,6 @@ declare type Callback = (...args: unknown[]) => void;
declare const PROJECT_FILE_NAME: string;
declare const CORE_VERSION: string;
declare const META_VERSION: number;
declare module 'colorjs.io' {
const noTypesYet: any;
export default noTypesYet;

View File

@@ -1,50 +0,0 @@
import type {Player} from './player';
export function hot(player: Player, root: NodeModule) {
const updateScenes = async (modules: string[]) => {
const runners = [];
for (const module of modules) {
const runner = __webpack_require__(module).default;
if (
// FIXME Find a better way to detect runner factories.
runner.name === '__WEBPACK_DEFAULT_EXPORT__' &&
typeof runner === 'function'
) {
runners.push(await runner());
} else {
runners.push(runner);
}
}
player.project.reload(runners);
player.reload();
};
const updateAudio = async (modules: string[]) => {
let src = null;
for (const module of modules) {
const audio = __webpack_require__(module);
if (typeof audio === 'string') {
src = audio;
break;
}
}
if (src) {
player.audio.setSource(src);
}
};
const scenePaths = __webpack_require__.c[root.id].children.filter(
(name: string) => name.match(/\.scene\.[jt]sx?/),
);
const audioPaths = __webpack_require__.c[root.id].children.filter(
(name: string) => name.match(/\.wav/),
);
root.hot.accept(scenePaths, updateScenes);
module.hot.accept(scenePaths, updateScenes);
root.hot.accept(audioPaths, updateAudio);
module.hot.accept(audioPaths, updateAudio);
}

View File

@@ -1,4 +1,3 @@
export * from './bootstrap';
export * from './Meta';
export * from './Project';
export * from './symbols';

View File

@@ -1,5 +1,6 @@
import {AudioData} from './AudioData';
import {ValueDispatcher} from '../events';
import {ifHot} from '../utils';
export class AudioManager {
public get onDataChanged() {
@@ -18,6 +19,16 @@ export class AudioManager {
private error = false;
private abortController: AbortController = null;
public constructor() {
ifHot(hot => {
hot.on('motion-canvas:assets', ({urls}) => {
if (urls.includes(this.source)) {
this.setSource(this.source);
}
});
});
}
public getTime() {
return this.toAbsoluteTime(this.audioElement.currentTime);
}

View File

@@ -1,20 +1,21 @@
import {Size} from '../types';
const imageLookup: Record<string, HTMLImageElement> = {};
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
export type ImageDataSource = CanvasImageSource & Size;
export async function loadImage(source: string): Promise<HTMLImageElement> {
if (!imageLookup[source]) {
const image = new Image();
imageLookup[source] = image;
image.src = source;
await new Promise(resolve => (image.onload = resolve));
}
return imageLookup[source];
export function loadImage(source: string): Promise<HTMLImageElement> {
const image = new Image();
image.src = source;
return new Promise((resolve, reject) => {
if (image.complete) {
resolve(image);
} else {
image.onload = () => resolve(image);
image.onerror = reject;
}
});
}
export function loadAnimation(sources: string[]): Promise<HTMLImageElement[]> {

View File

@@ -1,4 +0,0 @@
import './Factory';
import './Node';
import './Shape';
import './Container';

View File

@@ -1,4 +1,3 @@
import {AudioManager} from '../media';
import type {Project} from '../Project';
import {
AsyncEventDispatcher,
@@ -79,25 +78,24 @@ export class Player {
recalculate: true,
};
public constructor(
public readonly project: Project,
public readonly audio: AudioManager,
) {
public constructor(public readonly project: Project) {
this.startTime = performance.now();
this.project.framerate = this.state.current.fps;
this.project.resolutionScale = this.state.current.scale;
this.project.colorSpace = this.state.current.colorSpace;
// TODO Recalculation should be handled by the project.
this.project.onReloaded.subscribe(() => this.reload());
this.request();
}
public loadState(state: Partial<PlayerState>) {
this.updateState(state);
this.project.speed = state.speed;
this.project.framerate = state.fps;
this.project.resolutionScale = state.scale;
this.project.colorSpace = state.colorSpace;
this.setRange(state.startFrame, state.endFrame);
this.project.speed = this.state.current.speed;
this.project.framerate = this.state.current.fps;
this.project.resolutionScale = this.state.current.scale;
this.project.colorSpace = this.state.current.colorSpace;
this.setRange(this.state.current.startFrame, this.state.current.endFrame);
}
private updateState(newState: Partial<PlayerState>) {
@@ -125,7 +123,7 @@ export class Player {
return commands;
}
public reload() {
private reload() {
this.commands.recalculate = true;
if (this.requestId === null) {
this.request();
@@ -262,11 +260,11 @@ export class Player {
state.paused ||
state.finished ||
state.render ||
!this.audio.isInRange(this.project.time);
if (await this.audio.setPaused(audioPaused)) {
this.syncAudio(-3);
!this.project.audio.isInRange(this.project.time);
if (await this.project.audio.setPaused(audioPaused)) {
this.project.syncAudio(-3);
}
this.audio.setMuted(state.muted);
this.project.audio.setMuted(state.muted);
// Rendering
if (state.render) {
@@ -295,27 +293,29 @@ export class Player {
console.time('seek time');
state.finished = await this.project.seek(clampedFrame);
console.timeEnd('seek time');
this.syncAudio(-3);
this.project.syncAudio(-3);
}
// Do nothing if paused or is ahead of the audio.
else if (
state.paused ||
(state.speed === 1 &&
this.audio.isReady() &&
this.audio.isInRange(this.project.time) &&
this.audio.getTime() < this.project.time)
this.project.audio.isReady() &&
this.project.audio.isInRange(this.project.time) &&
this.project.audio.getTime() < this.project.time)
) {
this.request();
return;
}
// Seek to synchronize animation with audio.
else if (
this.audio.isReady() &&
this.project.audio.isReady() &&
state.speed === 1 &&
this.audio.isInRange(this.project.time) &&
this.project.time < this.audio.getTime() - MAX_AUDIO_DESYNC
this.project.audio.isInRange(this.project.time) &&
this.project.time < this.project.audio.getTime() - MAX_AUDIO_DESYNC
) {
const seekFrame = this.project.secondsToFrames(this.audio.getTime());
const seekFrame = this.project.secondsToFrames(
this.project.audio.getTime(),
);
state.finished = await this.project.seek(seekFrame);
}
// Simply move forward one frame
@@ -324,7 +324,7 @@ export class Player {
// Synchronize audio.
if (state.speed !== 1) {
this.syncAudio();
this.project.syncAudio();
}
}
@@ -358,12 +358,6 @@ export class Player {
return frame >= state.startFrame && frame <= state.endFrame;
}
private syncAudio(frameOffset = 0) {
this.audio.setTime(
this.project.framesToSeconds(this.project.frame + frameOffset),
);
}
private request() {
this.requestId = requestAnimationFrame(async time => {
if (time - this.renderTime >= 990 / this.state.current.fps) {

View File

@@ -201,7 +201,6 @@ export class TimeEvents {
this.previousReference = data.timeEvents;
this.load(data.timeEvents ?? []);
this.scene.reload();
window.player.reload();
};
private load(events: SavedTimeEvent[]) {

View File

@@ -0,0 +1,2 @@
jest.mock('./utils/ifHot');
global.AudioContext = class {} as new () => AudioContext;

View File

@@ -0,0 +1,3 @@
export function ifHot() {
// do nothing
}

View File

@@ -0,0 +1,23 @@
import type {ViteHotContext} from 'vite/types/hot';
/**
* Invoke the given callback if hot module replacement is enabled.
*
* @remarks
* This helper function should be used instead of accessing `import.meta.hot`
* directly. CommonJS doesn't support `import.meta` which makes the tests fail.
* It's infinitely easier to mock this function out than to run jest on Node
* with ES modules :(
*
* @param callback - The callback to be invoked if HMR is enabled.
*
* @typeParam T - The type returned by the callback or `void` if HMR is
* disabled.
*
* @internal
*/
export function ifHot<T>(callback: (hot: ViteHotContext) => T): T | void {
if (import.meta.hot) {
return callback(import.meta.hot);
}
}

View File

@@ -6,3 +6,4 @@ export * from './useScene';
export * from './useThread';
export * from './useTime';
export * from './useContext';
export * from './ifHot';

11
packages/core/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
import 'vite/types/customEvent';
declare module 'vite/types/customEvent' {
interface CustomEventMap {
'motion-canvas:meta': {source: string; data: import('./Meta').Metadata};
'motion-canvas:meta-ack': {source: string};
'motion-canvas:assets': {urls: string[]};
}
}

View File

@@ -18,14 +18,7 @@
"paths": {
"@motion-canvas/core/lib/jsx-runtime": ["jsx-runtime.ts"]
},
"types": [
"node",
"prismjs",
"three",
"webpack-env",
"dom-webcodecs",
"jest"
]
"types": ["node", "prismjs", "three", "dom-webcodecs", "jest"]
},
"include": ["src"]
}

View File

@@ -5,6 +5,7 @@
"module": "esnext",
"target": "es2020",
"allowJs": true,
"noEmit": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,

View File

@@ -1,27 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
"tagDefinitions": [
{
"tagName": "@module",
"syntaxKind": "modifier"
},
{
"tagName": "@ignore",
"syntaxKind": "modifier"
}
],
"supportForTags": {
"@module": true,
"@param": true,
"@deprecated": true,
"@returns": true,
"@remarks": true,
"@example": true,
"@link": true,
"@internal": true,
"@eventProperty": true,
"@typeParam": true,
"@ignore": true,
"@inheritDoc": true
}
"extends": ["../../tsdoc.json"]
}

View File

@@ -1,23 +0,0 @@
{
"out": "api",
"excludeExternals": true,
"entryPoints": [
"src",
"src/animations",
"src/components",
"src/decorators",
"src/events",
"src/flow",
"src/helpers",
"src/media",
"src/player",
"src/scenes",
"src/styles",
"src/themes",
"src/threading",
"src/transitions",
"src/tweening",
"src/types",
"src/utils"
]
}

4
packages/docs/tsdoc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
"extends": ["../../tsdoc.json"]
}

View File

@@ -7,11 +7,15 @@
"license": "MIT",
"main": "src/project.ts",
"scripts": {
"serve": "motion-canvas src/project.ts",
"ui": "motion-canvas src/project.ts --ui-server"
"serve": "vite",
"build": "tsc && vite build"
},
"dependencies": {
"@motion-canvas/core": "*",
"@motion-canvas/ui": "*"
"@motion-canvas/core": "*"
},
"devDependencies": {
"@motion-canvas/ui": "*",
"@motion-canvas/vite-plugin": "*",
"vite": "^3.0.5"
}
}

View File

@@ -0,0 +1 @@
/// <reference types="@motion-canvas/core/project" />

View File

@@ -1,9 +1,9 @@
import '@motion-canvas/core/lib/patches';
import {bootstrap} from '@motion-canvas/core/lib/bootstrap';
import {Project} from '@motion-canvas/core/lib';
import example from './scenes/example.scene';
bootstrap({
name: 'base-project',
export default new Project({
name: 'project',
scenes: [example],
background: '#141414',
});

View File

@@ -2,13 +2,7 @@
"extends": "@motion-canvas/core/tsconfig.project.json",
"compilerOptions": {
"baseUrl": "src",
"types": [
"node",
"prismjs",
"three",
"webpack-env",
"dom-webcodecs",
"jest"
]
}
"types": ["node", "prismjs", "three", "dom-webcodecs"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
"extends": ["../../tsdoc.json"]
}

View File

@@ -0,0 +1,6 @@
import {defineConfig} from 'vite';
import motionCanvas from '@motion-canvas/vite-plugin';
export default defineConfig({
plugins: [motionCanvas()],
});

17
packages/ui/editor.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="icon"
type="image/png"
sizes="16x16"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAOwAAADsAEnxA+tAAABKklEQVQ4jcWTvUoDURCFv/y0A2tnZ95AW7FZwQdQSO9aRLAJbp0mPkGCTSCBmPQW8Q1SuKTU4AvkEeQO2K6MuSFmN+pCCg9cLhdmzpwzh1tK05RdUN6p+yeCftKK+kmrW4SguvGaNc3PGGRUVEE18x4Dl9dlheP76OZxMQUCIOzVa++/W2jENYaV7oqEWTMETMmhv7fi+w4i4IVhxaaeAq+9es0aL4CJqoaqOsmSrGNsxCZ16ideMeiMVPVrJyISqerC7IhIsN3CoGMeTfYceKARn6/sqOozcADkktmMcU3y5KeZrQQ4ARxwpqpts5O3kIEvGvnJ1vwB7PuquYgc5RVs4tZHeAe8WbOIlIA9r3IJU/DXcc61nXOpcy7I1hb9C5aOLTeHf/6NwCdua48fJxuYPgAAAABJRU5ErkJggg=="
/>
<title>Motion Canvas</title>
</head>
<body>
<script type="module" src="/@id/__x00__virtual:editor"></script>
</body>
</html>

21
packages/ui/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="icon"
type="image/png"
sizes="16x16"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAOwAAADsAEnxA+tAAABKklEQVQ4jcWTvUoDURCFv/y0A2tnZ95AW7FZwQdQSO9aRLAJbp0mPkGCTSCBmPQW8Q1SuKTU4AvkEeQO2K6MuSFmN+pCCg9cLhdmzpwzh1tK05RdUN6p+yeCftKK+kmrW4SguvGaNc3PGGRUVEE18x4Dl9dlheP76OZxMQUCIOzVa++/W2jENYaV7oqEWTMETMmhv7fi+w4i4IVhxaaeAq+9es0aL4CJqoaqOsmSrGNsxCZ16ideMeiMVPVrJyISqerC7IhIsN3CoGMeTfYceKARn6/sqOozcADkktmMcU3y5KeZrQQ4ARxwpqpts5O3kIEvGvnJ1vwB7PuquYgc5RVs4tZHeAe8WbOIlIA9r3IJU/DXcc61nXOpcy7I1hb9C5aOLTeHf/6NwCdua48fJxuYPgAAAABJRU5ErkJggg=="
/>
<title>Editor | Motion Canvas</title>
</head>
<body>
<script type="module">
import project from '@motion-canvas/template/dist/template.mjs';
import editor from '/src/main.tsx';
editor(project);
</script>
</body>
</html>

View File

@@ -1,15 +1,16 @@
{
"name": "@motion-canvas/ui",
"version": "9.1.2",
"description": "User Interface for Motion Canvas",
"description": "A visual editor for Motion Canvas",
"main": "dist/main.js",
"types": "types/main.d.ts",
"author": "motion-canvas",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "webpack",
"serve": "webpack serve",
"build": "webpack --mode=production",
"watch": "webpack -w"
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com/motion-canvas"
@@ -19,20 +20,18 @@
"url": "https://github.com/motion-canvas/motion-canvas.git"
},
"files": [
"dist/main.js"
"dist",
"types",
"editor.html"
],
"peerDependencies": {
"@motion-canvas/core": "*"
},
"devDependencies": {
"@motion-canvas/core": "^9.1.2",
"css-loader": "^6.7.1",
"preact": "^10.7.3",
"sass": "^1.52.2",
"sass-loader": "^13.0.0",
"strongly-typed-events": "^3.0.2",
"style-loader": "^3.3.1",
"ts-loader": "^9.3.0",
"typescript": "^4.6.3",
"webpack": "^5.73.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.9.1"
"@preact/preset-vite": "^2.3.0",
"preact": "10.7.3",
"typescript": "^4.6.4",
"vite": "^3.0.4"
}
}

View File

@@ -39,5 +39,3 @@ export function App() {
/>
);
}
export const AppNode = <App />;

View File

@@ -1,11 +1,12 @@
import styles from './Playback.module.scss';
import {IconType, IconButton, IconCheckbox} from '../controls';
import {useDocumentEvent, usePlayer, usePlayerState} from '../../hooks';
import {useDocumentEvent, usePlayerState} from '../../hooks';
import {Select, Input} from '../controls';
import {Framerate} from './Framerate';
import {useCallback} from 'preact/hooks';
import {classes} from '../../utils';
import {usePlayer} from '../../contexts';
export function PlaybackControls() {
const player = usePlayer();

View File

@@ -1,6 +1,7 @@
import {usePlayer, usePlayerState, useSubscribable} from '../../hooks';
import {usePlayerState, useSubscribable} from '../../hooks';
import {Button, Group, Input, Label, Select} from '../controls';
import {Pane} from '../tabs';
import {usePlayer} from '../../contexts';
import type {CanvasColorSpace} from '@motion-canvas/core/lib/types';
export function Rendering() {
@@ -81,8 +82,6 @@ export function Rendering() {
onChange={event => {
const value = parseInt((event.target as HTMLInputElement).value);
player.project.setSize(value, height);
player.project.reloadAll();
player.reload();
}}
/>
X
@@ -93,8 +92,6 @@ export function Rendering() {
onChange={event => {
const value = parseInt((event.target as HTMLInputElement).value);
player.project.setSize(width, value);
player.project.reloadAll();
player.reload();
}}
/>
</Group>

View File

@@ -2,18 +2,19 @@ import styles from './Timeline.module.scss';
import {useContext, useLayoutEffect, useMemo, useRef} from 'preact/hooks';
import {TimelineContext} from './TimelineContext';
import {usePlayer, useSubscribableValue} from '../../hooks';
import {useSubscribableValue} from '../../hooks';
import {useProject} from '../../contexts';
const HEIGHT = 48;
export function AudioTrack() {
const ref = useRef<HTMLCanvasElement>();
const {project, audio} = usePlayer();
const project = useProject();
const context = useMemo(() => ref.current?.getContext('2d'), [ref.current]);
const {viewLength, startFrame, endFrame, duration, density} =
useContext(TimelineContext);
const audioData = useSubscribableValue(audio.onDataChanged);
const audioData = useSubscribableValue(project.audio.onDataChanged);
useLayoutEffect(() => {
if (!context) return;
@@ -26,10 +27,10 @@ export function AudioTrack() {
context.moveTo(0, HEIGHT);
const start =
audio.toRelativeTime(project.framesToSeconds(startFrame)) *
project.audio.toRelativeTime(project.framesToSeconds(startFrame)) *
audioData.sampleRate;
const end =
audio.toRelativeTime(project.framesToSeconds(endFrame)) *
project.audio.toRelativeTime(project.framesToSeconds(endFrame)) *
audioData.sampleRate;
const flooredStart = ~~start;

View File

@@ -1,9 +1,10 @@
import styles from './Timeline.module.scss';
import type {Scene, TimeEvent} from '@motion-canvas/core/lib/scenes';
import {useDrag, usePlayer} from '../../hooks';
import {useDrag} from '../../hooks';
import {useCallback, useContext, useLayoutEffect, useState} from 'preact/hooks';
import {TimelineContext} from './TimelineContext';
import {usePlayer} from '../../contexts';
interface LabelProps {
event: TimeEvent;
@@ -27,7 +28,6 @@ export function Label({event, scene}: LabelProps) {
const newFrame = Math.max(0, eventTime);
if (event.offset !== newFrame) {
scene.timeEvents.set(event.name, newFrame, e.shiftKey);
player.reload();
}
},
[event, eventTime],

View File

@@ -1,9 +1,10 @@
import styles from './Timeline.module.scss';
import {useDrag, usePlayer, usePlayerState} from '../../hooks';
import {useDrag, usePlayerState} from '../../hooks';
import {useCallback, useContext, useEffect, useState} from 'preact/hooks';
import {Icon, IconType} from '../controls';
import {TimelineContext} from './TimelineContext';
import {usePlayer} from '../../contexts';
export function RangeTrack() {
const {fullLength} = useContext(TimelineContext);

View File

@@ -1,12 +1,8 @@
import styles from './Timeline.module.scss';
import type {Scene} from '@motion-canvas/core/lib/scenes';
import {
usePlayer,
usePlayerState,
useScenes,
useSubscribableValue,
} from '../../hooks';
import {usePlayerState, useScenes, useSubscribableValue} from '../../hooks';
import {usePlayer} from '../../contexts';
export function SceneTrack() {
const scenes = useScenes();

View File

@@ -9,7 +9,6 @@ import {
} from 'preact/hooks';
import {
useDocumentEvent,
usePlayer,
usePlayerState,
useSize,
useStateChange,
@@ -22,6 +21,7 @@ import {RangeTrack} from './RangeTrack';
import {TimelineContext, TimelineState} from './TimelineContext';
import {clamp} from '@motion-canvas/core/lib/tweening';
import {AudioTrack} from './AudioTrack';
import {usePlayer} from '../../contexts';
const ZOOM_SPEED = 0.1;
@@ -68,7 +68,9 @@ export function Timeline() {
if (prevWidth !== 0 && rect.width !== 0) {
newScale *= prevWidth / rect.width;
}
setScale(Math.max(1, newScale));
if (!isNaN(newScale)) {
setScale(Math.max(1, newScale));
}
},
[duration, rect.width],
);
@@ -119,8 +121,12 @@ export function Timeline() {
);
containerRef.current.scrollLeft = newOffset;
setScale(newScale);
setOffset(newOffset);
if (!isNaN(newScale)) {
setScale(newScale);
}
if (!isNaN(newOffset)) {
setOffset(newOffset);
}
playheadRef.current.style.left = `${
event.x - rect.x + newOffset
}px`;

View File

@@ -5,7 +5,6 @@ import {
useCurrentScene,
useDocumentEvent,
useDrag,
usePlayer,
useSize,
useStorage,
useSubscribable,
@@ -16,6 +15,7 @@ import {Grid} from './Grid';
import styles from './Viewport.module.scss';
import {ViewportContext, ViewportState} from './ViewportContext';
import {isInspectable} from '@motion-canvas/core/lib/scenes/Inspectable';
import {usePlayer} from '../../contexts';
const ZOOM_SPEED = 0.1;

View File

@@ -1,8 +1,9 @@
import {usePlayer, usePlayerState} from '../../hooks';
import {usePlayerState} from '../../hooks';
import {PlaybackControls, PlaybackProgress} from '../playback';
import {CurrentTime} from '../playback/CurrentTime';
import {View} from './View';
import styles from './Viewport.module.scss';
import {usePlayer} from '../../contexts';
export function Viewport() {
const player = usePlayer();

View File

@@ -0,0 +1,2 @@
export * from './player';
export * from './project';

View File

@@ -0,0 +1,21 @@
import {ComponentChildren, createContext} from 'preact';
import {useContext} from 'preact/hooks';
import type {Player} from '@motion-canvas/core/lib/player';
const PlayerContext = createContext<Player | null>(null);
export function usePlayer() {
return useContext(PlayerContext);
}
export function PlayerProvider({
player,
children,
}: {
player: Player;
children: ComponentChildren;
}) {
return (
<PlayerContext.Provider value={player}>{children}</PlayerContext.Provider>
);
}

View File

@@ -0,0 +1,23 @@
import {ComponentChildren, createContext} from 'preact';
import {useContext} from 'preact/hooks';
import type {Project} from '@motion-canvas/core/lib';
const ProjectContext = createContext<Project | null>(null);
export function useProject() {
return useContext(ProjectContext);
}
export function ProjectProvider({
project,
children,
}: {
project: Project;
children: ComponentChildren;
}) {
return (
<ProjectContext.Provider value={project}>
{children}
</ProjectContext.Provider>
);
}

View File

@@ -2,7 +2,6 @@ export * from './useCurrentFrame';
export * from './useDocumentEvent';
export * from './useDrag';
export * from './useMeta';
export * from './usePlayer';
export * from './usePlayerState';
export * from './usePlayerTime';
export * from './useScenes';

View File

@@ -1,5 +1,5 @@
import {useSubscribableValue} from './useSubscribable';
import {usePlayer} from './usePlayer';
import {usePlayer} from '../contexts';
export function useCurrentFrame() {
const player = usePlayer();

View File

@@ -1,7 +1,7 @@
import type {Meta, Metadata} from '@motion-canvas/core/lib';
import {useCallback} from 'preact/hooks';
import {usePlayer} from './usePlayer';
import {useSubscribableValue} from './useSubscribable';
import {usePlayer} from '../contexts';
/**
* Get a stateful value representing the contents of the given meta file and

View File

@@ -1,5 +0,0 @@
import type {Player} from '@motion-canvas/core/lib/player/Player';
export function usePlayer(): Player {
return (<{player: Player}>(<unknown>window)).player;
}

View File

@@ -1,18 +1,7 @@
import type {PlayerState} from '@motion-canvas/core/lib/player/Player';
import {usePlayer} from './usePlayer';
import {usePlayer} from '../contexts';
import {useSubscribableValue} from './useSubscribable';
const player = usePlayer();
const storageKey = `${player.project.name}-player-state`;
const savedState = localStorage.getItem(storageKey);
if (savedState) {
const state = JSON.parse(savedState) as PlayerState;
player.loadState(state);
}
player.onStateChanged.subscribe(state => {
localStorage.setItem(storageKey, JSON.stringify(state));
});
// TODO Save and restore the player state.
export function usePlayerState() {
const player = usePlayer();

View File

@@ -1,5 +1,5 @@
import {usePlayer} from './usePlayer';
import {useSubscribableValue} from './useSubscribable';
import {usePlayer} from '../contexts';
export function useScenes() {
const player = usePlayer();

View File

@@ -1,5 +1,5 @@
import {useCallback, useMemo, useState} from 'preact/hooks';
import {usePlayer} from './usePlayer';
import {usePlayer} from '../contexts';
export function useStorage<T>(
id: string,

View File

@@ -1,6 +0,0 @@
import {render} from 'preact';
import {AppNode} from './App';
const app = document.createElement('main');
document.body.appendChild(app);
render(AppNode, app);

20
packages/ui/src/main.tsx Normal file
View File

@@ -0,0 +1,20 @@
import type {Project} from '@motion-canvas/core/lib';
import {Player} from '@motion-canvas/core/lib/player';
import {render} from 'preact';
import {App} from './App';
import {PlayerProvider, ProjectProvider} from './contexts';
export default (project: Project) => {
const app = document.createElement('main');
const player = new Player(project);
document.body.appendChild(app);
document.title = `${project.name} | Motion Canvas`;
render(
<PlayerProvider player={player}>
<ProjectProvider project={project}>
<App />
</ProjectProvider>
</PlayerProvider>,
app,
);
};

1
packages/ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -1,15 +1,21 @@
{
"compilerOptions": {
"baseUrl": "src",
"outDir": "lib",
"sourceMap": true,
"noImplicitAny": true,
"module": "esnext",
"target": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": false,
"useDefineForClassFields": true,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
"jsx": "preserve",
"jsxImportSource": "preact",
"types": ["node", "preact"],
"lib": ["DOM", "DOM.Iterable", "ESNext"]
},
"include": ["src"]
}

4
packages/ui/tsdoc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
"extends": ["../../tsdoc.json"]
}

3
packages/ui/types/main.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
import type {Project} from '@motion-canvas/core/lib';
declare const _default: (project: Project) => void;
export default _default;

View File

@@ -0,0 +1,16 @@
import {defineConfig} from 'vite';
import preact from '@preact/preset-vite';
export default defineConfig({
build: {
lib: {
entry: 'src/main.tsx',
formats: ['es'],
fileName: 'main',
},
rollupOptions: {
external: ['@motion-canvas/core'],
},
},
plugins: [preact()],
});

View File

@@ -0,0 +1,29 @@
{
"name": "@motion-canvas/vite-plugin",
"version": "9.1.1",
"description": "A Vite plugin for Motion Canvas projects",
"main": "lib/main.js",
"author": "motion-canvas",
"license": "MIT",
"scripts": {
"build": "tsc",
"watch": "tsc -w"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com/motion-canvas"
},
"repository": {
"type": "git",
"url": "git+https://github.com/motion-canvas/motion-canvas.git"
},
"files": [
"lib"
],
"peerDependencies": {
"vite": "3.x"
},
"devDependencies": {
"typescript": "^4.7.4",
"vite": "^3.0.5"
}
}

View File

@@ -0,0 +1,259 @@
import type {Plugin, ResolvedConfig} from 'vite';
import path from 'path';
import fs from 'fs';
import {Readable} from 'stream';
export interface MotionCanvasPluginConfig {
/**
* The import path of the project file.
*
* @remarks
* The file must contain a default export exposing an instance of the
* {@link Project} class.
*
* @default './src/project.ts'
*/
project?: string;
/**
* Defines which assets should be buffered before being sent to the browser.
*
* @remarks
* Streaming larger assets directly from the drive my cause issues with other
* applications. For instance, if an audio file is being used in the project,
* Adobe Audition will perceive it as "being used by another application"
* and refuse to override it.
*
* Buffered assets are first loaded to the memory and then streamed from
* there. This leaves the original files open for modification with hot module
* replacement still working.
*
* @default /\.(wav|mp3|ogg)$/
*/
bufferedAssets?: RegExp;
editor?: {
/**
* The import path of the editor factory file.
*
* @remarks
* The file must contain a default export exposing a factory function.
* This function will be called with the project as its first argument.
* Its task is to create the user interface.
*
* @default '\@motion-canvas/ui'
*/
factory?: string;
/**
* The import path of the editor styles.
*
* @default '\@motion-canvas/ui/dist/style.css'
*/
styles?: string;
};
}
export default ({
project = './src/project.ts',
bufferedAssets = /\.(wav|mp3|ogg)$/,
editor: {
styles = '@motion-canvas/ui/dist/style.css',
factory = '@motion-canvas/ui',
} = {},
}: MotionCanvasPluginConfig = {}): Plugin => {
const editorPath = path.dirname(require.resolve('@motion-canvas/ui'));
const editorId = 'virtual:editor';
const resolvedEditorId = '\0' + editorId;
const timeStamps: Record<string, number> = {};
const projectName = path.parse(project).name;
let viteConfig: ResolvedConfig;
function source(...lines: string[]) {
return lines.join('\n');
}
async function createMeta(metaPath: string) {
if (!fs.existsSync(metaPath)) {
await fs.promises.writeFile(
metaPath,
JSON.stringify({version: 0}, undefined, 2),
'utf8',
);
}
}
return {
name: 'motion-canvas',
async configResolved(resolvedConfig) {
viteConfig = resolvedConfig;
},
resolveId(id) {
if (id === editorId) {
return resolvedEditorId;
}
},
load(id) {
if (id === resolvedEditorId) {
return source(
`import '${styles}';`,
`import editor from '${factory}';`,
`import project from '${project}';`,
`editor(project);`,
);
}
},
async transform(code, id) {
const [base, query] = id.split('?');
const {name, dir, ext} = path.posix.parse(base);
if (query) {
const params = new URLSearchParams(query);
if (params.has('img')) {
return source(
`import {loadImage} from '@motion-canvas/core/lib/media';`,
`import image from '/@fs/${base}';`,
`export default loadImage(image);`,
);
}
if (params.has('anim')) {
const nameRegex = /\D*(\d+)\./;
let urls: string[] = [];
for (const file of await fs.promises.readdir(dir)) {
const match = nameRegex.exec(file);
if (!match) continue;
const index = parseInt(match[1]);
urls[index] = path.posix.join(dir, file);
}
urls = urls.filter(Boolean);
return source(
`import {loadAnimation} from '@motion-canvas/core/lib/media';`,
...urls.map(
(url, index) => `import image${index} from '/@fs/${url}';`,
),
`export default loadAnimation([${urls
.map((_, index) => `image${index}`)
.join(', ')}]);`,
);
}
}
if (ext === '.meta') {
return source(
`import {Meta} from '@motion-canvas/core/lib';`,
`Meta.register(`,
` '${name}',`,
` ${viteConfig.command === 'build' ? false : `'${id}'`},`,
` \`${code}\``,
`);`,
`if (import.meta.hot) {`,
` import.meta.hot.accept();`,
`}`,
);
}
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();
const urls = [];
const modules = [];
for (const module of ctx.modules) {
if (
module.file !== null &&
timeStamps[module.file] &&
timeStamps[module.file] + 1000 > now
) {
continue;
}
urls.push(module.url);
modules.push(module);
}
if (urls.length > 0) {
ctx.server.ws.send('motion-canvas:assets', {urls});
}
return modules;
},
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url && bufferedAssets.test(req.url)) {
const file = fs.readFileSync(
path.resolve(viteConfig.root, req.url.slice(1)),
);
const stream = Readable.from(file);
stream.on('end', console.log).pipe(res);
return;
}
if (req.url === '/') {
const stream = fs.createReadStream(
path.resolve(editorPath, '../editor.html'),
);
stream.pipe(res);
return;
}
next();
});
server.ws.on('motion-canvas:meta', async ({source, data}, client) => {
timeStamps[source] = Date.now();
await fs.promises.writeFile(
source,
JSON.stringify(data, undefined, 2),
'utf8',
);
client.send('motion-canvas:meta-ack', {source});
});
},
config() {
return {
esbuild: {
jsx: 'automatic',
jsxImportSource: '@motion-canvas/core/lib',
},
define: {
PROJECT_FILE_NAME: `'${projectName}'`,
},
build: {
lib: {
entry: project,
formats: ['es'],
},
},
};
},
};
};

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"baseUrl": "src",
"outDir": "lib",
"strict": true,
"module": "commonjs",
"esModuleInterop": true,
"target": "es2021",
"moduleResolution": "node",
"declaration": true,
"allowSyntheticDefaultImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
"extends": ["../../tsdoc.json"]
}

32
tsdoc.json Normal file
View File

@@ -0,0 +1,32 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
"tagDefinitions": [
{
"tagName": "@module",
"syntaxKind": "modifier"
},
{
"tagName": "@ignore",
"syntaxKind": "modifier"
},
{
"tagName": "@default",
"syntaxKind": "modifier"
}
],
"supportForTags": {
"@module": true,
"@param": true,
"@deprecated": true,
"@returns": true,
"@remarks": true,
"@example": true,
"@link": true,
"@internal": true,
"@eventProperty": true,
"@typeParam": true,
"@ignore": true,
"@inheritDoc": true,
"@default": true
}
}