mirror of
https://github.com/motion-canvas/motion-canvas.git
synced 2026-01-12 07:18:01 -05:00
feat: add meta files (#28)
Meta files are additional JSON files containing information about scenes and projects. They can be edited both programmatically and via IDE making them perfect for storing data edited through the user interface. Each meta file has the same name as the file it relates to but with extension changed to .meta. It's generated and imported automatically and can be safely added to version control. BREAKING CHANGE: change time events API Fixes: #7
This commit is contained in:
@@ -9,6 +9,7 @@ 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,
|
||||
@@ -33,16 +34,19 @@ if (cli.flags.uiServer) {
|
||||
cli.flags.uiPath ||= 'http://localhost:9001/main.js';
|
||||
} else {
|
||||
if (cli.flags.uiPath) {
|
||||
cli.flags.uiPath = path.resolve(process.cwd(), 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 projectFile = path.resolve(process.cwd(), cli.input[0]);
|
||||
const renderOutput = path.resolve(process.cwd(), cli.flags.output);
|
||||
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},
|
||||
@@ -53,6 +57,16 @@ const compiler = webpack({
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
getCustomTransformers: () => ({
|
||||
before: [
|
||||
metaImportTransformer({
|
||||
project: projectFile,
|
||||
version: packageJSON.version,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.glsl$/i,
|
||||
@@ -62,6 +76,10 @@ const compiler = webpack({
|
||||
test: /\.mp4/i,
|
||||
type: 'asset',
|
||||
},
|
||||
{
|
||||
test: /\.meta/i,
|
||||
loader: 'meta-loader',
|
||||
},
|
||||
{
|
||||
test: /\.wav$/i,
|
||||
type: 'asset',
|
||||
@@ -108,6 +126,10 @@ const compiler = webpack({
|
||||
// Required to load additional languages for Prism
|
||||
Prism: 'prismjs',
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
PROJECT_FILE_NAME: `'${path.parse(projectFile).name}'`,
|
||||
CORE_VERSION: `'${packageJSON.version}'`,
|
||||
}),
|
||||
new HtmlWebpackPlugin({title: 'Motion Canvas'}),
|
||||
new UIPlugin(cli.flags),
|
||||
],
|
||||
@@ -141,6 +163,19 @@ const server = new WebpackDevServer(
|
||||
},
|
||||
});
|
||||
|
||||
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',
|
||||
|
||||
39
bin/loaders/meta-loader.js
Normal file
39
bin/loaders/meta-loader.js
Normal file
@@ -0,0 +1,39 @@
|
||||
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: 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}',
|
||||
'${encodeURI(relative)}',
|
||||
${source}
|
||||
);
|
||||
module.hot.accept(console.error);`;
|
||||
this.callback(null, content, map);
|
||||
}
|
||||
|
||||
module.exports = metaLoader;
|
||||
68
bin/transformers/metaImportTransformer.mjs
Normal file
68
bin/transformers/metaImportTransformer.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
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,
|
||||
]);
|
||||
}
|
||||
66
package-lock.json
generated
66
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@motion-canvas/core",
|
||||
"version": "1.1.0",
|
||||
"version": "2.2.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@motion-canvas/core",
|
||||
"version": "1.1.0",
|
||||
"version": "2.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.0",
|
||||
@@ -20,6 +20,7 @@
|
||||
"mix-color": "^1.1.2",
|
||||
"mp4box": "^0.5.2",
|
||||
"prismjs": "^1.28.0",
|
||||
"source-map": "^0.7.4",
|
||||
"strongly-typed-events": "^3.0.2",
|
||||
"three": "^0.141.0",
|
||||
"ts-loader": "^9.3.0",
|
||||
@@ -2174,6 +2175,14 @@
|
||||
"node": ">= 10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clean-css/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clean-stack": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||
@@ -4392,6 +4401,15 @@
|
||||
"uglify-js": "^3.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/handlebars/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hard-rejection": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz",
|
||||
@@ -9728,11 +9746,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
||||
"integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
@@ -9744,6 +9762,14 @@
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/spawn-error-forwarder": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/spawn-error-forwarder/-/spawn-error-forwarder-1.0.0.tgz",
|
||||
@@ -12857,6 +12883,13 @@
|
||||
"integrity": "sha512-YYuuxv4H/iNb1Z/5IbMRoxgrzjWGhOEFfd+groZ5dMCVkpENiMZmwspdrzBo9286JjM1gZJPAyL7ZIdzuvu2AQ==",
|
||||
"requires": {
|
||||
"source-map": "~0.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"clean-stack": {
|
||||
@@ -14540,6 +14573,14 @@
|
||||
"source-map": "^0.6.1",
|
||||
"uglify-js": "^3.1.4",
|
||||
"wordwrap": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"hard-rejection": {
|
||||
@@ -18335,9 +18376,9 @@
|
||||
}
|
||||
},
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
||||
"integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="
|
||||
},
|
||||
"source-map-support": {
|
||||
"version": "0.5.21",
|
||||
@@ -18346,6 +18387,13 @@
|
||||
"requires": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"spawn-error-forwarder": {
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"mix-color": "^1.1.2",
|
||||
"mp4box": "^0.5.2",
|
||||
"prismjs": "^1.28.0",
|
||||
"source-map": "^0.7.4",
|
||||
"strongly-typed-events": "^3.0.2",
|
||||
"three": "^0.141.0",
|
||||
"ts-loader": "^9.3.0",
|
||||
|
||||
109
src/Meta.ts
Normal file
109
src/Meta.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {SimpleEventDispatcher} from 'strongly-typed-events';
|
||||
|
||||
/**
|
||||
* Represents the contents of a meta file.
|
||||
*/
|
||||
export interface Metadata {
|
||||
version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the meta file of a given entity.
|
||||
*
|
||||
* @template T Type of the data stored in the meta file.
|
||||
*/
|
||||
export class Meta<T extends Metadata = Metadata> {
|
||||
/**
|
||||
* Triggered when metadata changes.
|
||||
*
|
||||
* @event T
|
||||
*/
|
||||
public get Changed() {
|
||||
return this.changed.asEvent();
|
||||
}
|
||||
|
||||
private data: T;
|
||||
private source: string;
|
||||
private ignoreReload = false;
|
||||
private changed = new SimpleEventDispatcher<T>();
|
||||
|
||||
private constructor() {
|
||||
this.data = <T>{version: Meta.currentVersion};
|
||||
}
|
||||
|
||||
public getData(): T {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data without waiting for confirmation.
|
||||
*
|
||||
* Any possible errors will be logged to the console.
|
||||
*
|
||||
* @param data
|
||||
*/
|
||||
public setDataSync(data: Partial<T>) {
|
||||
this.setData(data).catch(console.error);
|
||||
}
|
||||
|
||||
public async setData(data: Partial<T>) {
|
||||
this.data = {
|
||||
...this.data,
|
||||
...data,
|
||||
};
|
||||
this.changed.dispatch(this.data);
|
||||
this.ignoreReload = true;
|
||||
const response = await fetch(`/meta/${this.source}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.data, undefined, 2),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
private static currentVersion = 1;
|
||||
private static metaLookup: Record<string, Meta> = {};
|
||||
|
||||
/**
|
||||
* Get the {@link Meta} object for the given entity.
|
||||
*
|
||||
* @param name Name of the entity the metadata refers to.
|
||||
* @template T 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>();
|
||||
return <Meta<T>>this.metaLookup[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new version of metadata.
|
||||
*
|
||||
* Called directly by meta files themselves.
|
||||
* Occurs during the initial load as well as during hot reloads.
|
||||
*
|
||||
* @param name Name of the entity this metadata refers to.
|
||||
* @param source Path to the source file relative to the compilation context.
|
||||
* @param data New metadata.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public static register(name: string, source: string, data: Metadata) {
|
||||
const meta = Meta.getMetaFor(name);
|
||||
if (meta.ignoreReload) {
|
||||
meta.ignoreReload = false;
|
||||
return;
|
||||
}
|
||||
|
||||
data.version ??= Meta.currentVersion;
|
||||
meta.source = source;
|
||||
meta.data = data;
|
||||
meta.changed.dispatch(data);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {Thread, ThreadsCallback} from './threading';
|
||||
import {Scene, SceneRunner} from './Scene';
|
||||
import {SimpleEventDispatcher} from 'strongly-typed-events';
|
||||
import {KonvaNode} from './decorators';
|
||||
import {Meta, Metadata} from './Meta';
|
||||
|
||||
Konva.autoDrawEnabled = false;
|
||||
|
||||
@@ -16,17 +17,21 @@ export const ProjectSize = {
|
||||
FullHD: {width: 1920, height: 1080},
|
||||
};
|
||||
|
||||
interface ProjectConfig extends Partial<StageConfig> {
|
||||
export interface ProjectConfig extends Partial<StageConfig> {
|
||||
scenes: SceneRunner[];
|
||||
background: string | false;
|
||||
}
|
||||
|
||||
export type ProjectMetadata = Metadata;
|
||||
|
||||
@KonvaNode()
|
||||
export class Project extends Stage {
|
||||
public get ScenesChanged() {
|
||||
return this.scenesChanged.asEvent();
|
||||
}
|
||||
|
||||
public readonly version = CORE_VERSION;
|
||||
public readonly meta: Meta<ProjectMetadata>;
|
||||
public readonly background: Rect;
|
||||
public readonly master: Layer;
|
||||
public readonly center: Vector2d;
|
||||
@@ -74,6 +79,7 @@ export class Project extends Stage {
|
||||
...rest,
|
||||
});
|
||||
|
||||
this.meta = Meta.getMetaFor(PROJECT_FILE_NAME);
|
||||
this.offset({
|
||||
x: this.width() / -2,
|
||||
y: this.height() / -2,
|
||||
|
||||
115
src/Scene.ts
115
src/Scene.ts
@@ -10,8 +10,9 @@ import {Group} from 'konva/lib/Group';
|
||||
import {Shape} from 'konva/lib/Shape';
|
||||
import {SceneTransition} from './transitions';
|
||||
import {decorate, KonvaNode, threadable} from './decorators';
|
||||
import {SimpleEventDispatcher} from 'strongly-typed-events';
|
||||
import {setScene} from './utils';
|
||||
import {Meta, Metadata} from './Meta';
|
||||
import {SavedTimeEvent, TimeEvents} from './TimeEvents';
|
||||
|
||||
export interface SceneRunner {
|
||||
(layer: Scene, project: Project): ThreadGenerator;
|
||||
@@ -24,11 +25,8 @@ export enum SceneState {
|
||||
Finished,
|
||||
}
|
||||
|
||||
export interface TimeEvent {
|
||||
name: string;
|
||||
initialTime: number;
|
||||
targetTime: number;
|
||||
offset: number;
|
||||
export interface SceneMetadata extends Metadata {
|
||||
timeEvents: SavedTimeEvent[];
|
||||
}
|
||||
|
||||
@KonvaNode()
|
||||
@@ -37,6 +35,8 @@ export class Scene extends Group {
|
||||
public firstFrame = 0;
|
||||
public transitionDuration = 0;
|
||||
public duration = 0;
|
||||
public readonly meta: Meta<SceneMetadata>;
|
||||
public readonly timeEvents: TimeEvents;
|
||||
|
||||
public get lastFrame() {
|
||||
return this.firstFrame + this.duration;
|
||||
@@ -45,23 +45,10 @@ export class Scene extends Group {
|
||||
this.duration = value - this.firstFrame;
|
||||
}
|
||||
|
||||
public get timeEvents(): TimeEvent[] {
|
||||
return Object.values(this.timeEventLookup);
|
||||
}
|
||||
|
||||
public get TimeEventsChanged() {
|
||||
return this.timeEventsChanged.asEvent();
|
||||
}
|
||||
|
||||
private readonly storageKey: string;
|
||||
private readonly timeEventsChanged = new SimpleEventDispatcher<TimeEvent[]>();
|
||||
private timeEventLookup: Record<string, TimeEvent> = {};
|
||||
private storedEventLookup: Record<string, TimeEvent> = {};
|
||||
private previousScene: Scene = null;
|
||||
private runner: ThreadGenerator;
|
||||
private state: SceneState = SceneState.Initial;
|
||||
private cached = false;
|
||||
private preserveEvents = false;
|
||||
private counters: Record<string, number> = {};
|
||||
|
||||
public constructor(
|
||||
@@ -77,22 +64,17 @@ export class Scene extends Group {
|
||||
});
|
||||
decorate(runnerFactory, threadable());
|
||||
|
||||
this.storageKey = `scene-${this.project.name()}-${this.name()}`;
|
||||
const storedEvents = localStorage.getItem(this.storageKey);
|
||||
if (storedEvents) {
|
||||
for (const event of Object.values<TimeEvent>(JSON.parse(storedEvents))) {
|
||||
this.storedEventLookup[event.name] = event;
|
||||
}
|
||||
}
|
||||
this.meta = Meta.getMetaFor(`${this.name()}.scene`);
|
||||
this.timeEvents = new TimeEvents(this);
|
||||
}
|
||||
|
||||
public invalidate() {
|
||||
this.cached = false;
|
||||
}
|
||||
|
||||
public markAsCached() {
|
||||
this.cached = true;
|
||||
this.preserveEvents = false;
|
||||
localStorage.setItem(
|
||||
this.storageKey,
|
||||
JSON.stringify(this.storedEventLookup),
|
||||
);
|
||||
this.timeEvents.preserveTiming = true;
|
||||
}
|
||||
|
||||
public isMarkedAsCached() {
|
||||
@@ -104,12 +86,6 @@ export class Scene extends Group {
|
||||
this.runnerFactory = runnerFactory;
|
||||
}
|
||||
this.cached = false;
|
||||
this.storedEventLookup = {
|
||||
...this.storedEventLookup,
|
||||
...this.timeEventLookup,
|
||||
};
|
||||
this.timeEventLookup = {};
|
||||
this.timeEventsChanged.dispatch([]);
|
||||
}
|
||||
|
||||
public async reset(previousScene: Scene = null) {
|
||||
@@ -220,69 +196,4 @@ export class Scene extends Group {
|
||||
|
||||
return `${this.name()}.${type}.${id}`;
|
||||
}
|
||||
|
||||
public getFrameEvent(name: string): number {
|
||||
const initialTime = this.project.framesToSeconds(
|
||||
this.project.frame - this.firstFrame,
|
||||
);
|
||||
if (this.timeEventLookup[name] === undefined) {
|
||||
const event: TimeEvent = {
|
||||
name,
|
||||
initialTime,
|
||||
targetTime: this.storedEventLookup[name]?.targetTime ?? initialTime,
|
||||
offset: this.storedEventLookup[name]?.offset ?? 0,
|
||||
};
|
||||
|
||||
if (this.storedEventLookup[name]) {
|
||||
if (this.preserveEvents) {
|
||||
event.targetTime = event.initialTime + event.offset;
|
||||
} else {
|
||||
event.offset = Math.max(0, event.targetTime - event.initialTime);
|
||||
}
|
||||
}
|
||||
|
||||
this.timeEventLookup[name] = event;
|
||||
this.timeEventsChanged.dispatch(this.timeEvents);
|
||||
} else if (this.timeEventLookup[name].initialTime !== initialTime) {
|
||||
const event: TimeEvent = {
|
||||
...this.timeEventLookup[name],
|
||||
initialTime,
|
||||
};
|
||||
|
||||
if (this.preserveEvents) {
|
||||
event.targetTime = event.initialTime + event.offset;
|
||||
} else {
|
||||
event.offset = Math.max(0, event.targetTime - event.initialTime);
|
||||
}
|
||||
|
||||
this.timeEventLookup[name] = event;
|
||||
this.storedEventLookup[name] = event;
|
||||
this.timeEventsChanged.dispatch(this.timeEvents);
|
||||
}
|
||||
|
||||
return (
|
||||
this.firstFrame +
|
||||
this.project.secondsToFrames(
|
||||
this.timeEventLookup[name].initialTime +
|
||||
this.timeEventLookup[name].offset,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public setFrameEvent(name: string, offset: number, preserve = true) {
|
||||
if (
|
||||
!this.timeEventLookup[name] ||
|
||||
this.timeEventLookup[name].offset === offset
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.cached = false;
|
||||
this.preserveEvents = preserve;
|
||||
this.storedEventLookup[name] = this.timeEventLookup[name] = {
|
||||
...this.timeEventLookup[name],
|
||||
targetTime: this.timeEventLookup[name].initialTime + offset,
|
||||
offset,
|
||||
};
|
||||
this.timeEventsChanged.dispatch(this.timeEvents);
|
||||
}
|
||||
}
|
||||
|
||||
201
src/TimeEvents.ts
Normal file
201
src/TimeEvents.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import type {Scene} from './Scene';
|
||||
import {SimpleEventDispatcher} from 'strongly-typed-events';
|
||||
|
||||
/**
|
||||
* Represents a time event at runtime.
|
||||
*/
|
||||
export interface TimeEvent {
|
||||
/**
|
||||
* Name of the event.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Time in seconds, relative to the beginning of the scene, at which the event
|
||||
* was registered.
|
||||
*
|
||||
* In other words, the moment at which {@link waitUntil} for this event was
|
||||
* invoked.
|
||||
*/
|
||||
initialTime: number;
|
||||
/**
|
||||
* Time in seconds, relative to the beginning of the scene, at which the event
|
||||
* should end.
|
||||
*/
|
||||
targetTime: number;
|
||||
/**
|
||||
* Duration of the event in seconds.
|
||||
*/
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a time event stored in a meta file.
|
||||
*/
|
||||
export interface SavedTimeEvent {
|
||||
name: string;
|
||||
targetTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages time events for a given scene.
|
||||
*/
|
||||
export class TimeEvents {
|
||||
/**
|
||||
* Triggered when time events change.
|
||||
*
|
||||
* @event TimeEvent[]
|
||||
*/
|
||||
public get Changed() {
|
||||
return this.changed.asEvent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the timing of events should be preserved.
|
||||
*
|
||||
* When set to `true` the offsets of events will be adjusted to keep them in
|
||||
* place.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public preserveTiming = true;
|
||||
|
||||
private readonly changed = new SimpleEventDispatcher<TimeEvent[]>();
|
||||
private lookup: Record<string, TimeEvent> = {};
|
||||
private previousReference: SavedTimeEvent[];
|
||||
|
||||
public constructor(private readonly scene: Scene) {
|
||||
const storageKey = `scene-${scene.project.name()}-${scene.name()}`;
|
||||
const storedEvents = localStorage.getItem(storageKey);
|
||||
if (storedEvents) {
|
||||
console.info('Migrating localStorage to meta files');
|
||||
localStorage.setItem(`${storageKey}-backup`, storedEvents);
|
||||
localStorage.removeItem(storageKey);
|
||||
this.load(Object.values<TimeEvent>(JSON.parse(storedEvents)));
|
||||
this.save();
|
||||
} else {
|
||||
this.load(scene.meta.getData().timeEvents ?? []);
|
||||
}
|
||||
|
||||
scene.meta.Changed.subscribe(event => {
|
||||
// Ignore the event if `timeEvents` hasn't changed.
|
||||
// This may happen when another part of metadata has changed triggering
|
||||
// this event.
|
||||
if (event.timeEvents === this.previousReference) return;
|
||||
this.previousReference = event.timeEvents;
|
||||
|
||||
this.load(event.timeEvents ?? []);
|
||||
scene.reload();
|
||||
window.player.reload();
|
||||
});
|
||||
}
|
||||
|
||||
public toArray(): TimeEvent[] {
|
||||
return Object.values(this.lookup);
|
||||
}
|
||||
|
||||
public get(name: string) {
|
||||
return this.lookup[name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the time offset of the given event.
|
||||
*
|
||||
* @param name Name of the event.
|
||||
* @param offset Time offset in seconds.
|
||||
* @param preserve Whether the timing of the consecutive events should be
|
||||
* preserved. See {@link TimeEvents.preserveTiming}.
|
||||
*/
|
||||
public set(name: string, offset: number, preserve = true) {
|
||||
if (!this.lookup[name] || this.lookup[name].offset === offset) {
|
||||
return;
|
||||
}
|
||||
this.scene.invalidate();
|
||||
this.preserveTiming = preserve;
|
||||
this.lookup[name] = {
|
||||
...this.lookup[name],
|
||||
targetTime: this.lookup[name].initialTime + offset,
|
||||
offset,
|
||||
};
|
||||
this.changed.dispatch(this.toArray());
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a time event.
|
||||
*
|
||||
* @param name Name of the event.
|
||||
*
|
||||
* @return The absolute frame at which the event should occur.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public register(name: string): number {
|
||||
const initialTime = this.scene.project.framesToSeconds(
|
||||
this.scene.project.frame - this.scene.firstFrame,
|
||||
);
|
||||
if (!this.lookup[name]) {
|
||||
this.lookup[name] = {
|
||||
name,
|
||||
initialTime,
|
||||
targetTime: initialTime,
|
||||
offset: 0,
|
||||
};
|
||||
this.changed.dispatch(this.toArray());
|
||||
} else {
|
||||
let changed = false;
|
||||
const event = {...this.lookup[name]};
|
||||
if (event.initialTime !== initialTime) {
|
||||
event.initialTime = initialTime;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const offset = Math.max(0, event.targetTime - event.initialTime);
|
||||
if (!this.preserveTiming && event.offset !== offset) {
|
||||
event.offset = offset;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const target = event.initialTime + event.offset;
|
||||
if (this.preserveTiming && event.targetTime !== target) {
|
||||
event.targetTime = target;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this.lookup[name] = event;
|
||||
this.changed.dispatch(this.toArray());
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
this.scene.firstFrame +
|
||||
this.scene.project.secondsToFrames(this.lookup[name].targetTime)
|
||||
);
|
||||
}
|
||||
|
||||
private save() {
|
||||
this.scene.meta.setDataSync({
|
||||
timeEvents: Object.values(this.lookup).map(event => ({
|
||||
name: event.name,
|
||||
targetTime: event.targetTime,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
private load(events: SavedTimeEvent[]) {
|
||||
const previousEvents = this.lookup;
|
||||
this.lookup = {};
|
||||
for (const event of events) {
|
||||
const previous = previousEvents[event.name] ?? {
|
||||
name: event.name,
|
||||
initialTime: 0,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
this.lookup[event.name] = {
|
||||
...previous,
|
||||
targetTime: event.targetTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function* waitUntil(
|
||||
const scene = useScene();
|
||||
const frames =
|
||||
typeof targetTime === 'string'
|
||||
? scene.getFrameEvent(targetTime)
|
||||
? scene.timeEvents.register(targetTime)
|
||||
: scene.project.secondsToFrames(targetTime);
|
||||
|
||||
while (scene.project.frame < frames) {
|
||||
|
||||
@@ -53,3 +53,7 @@ declare namespace JSX {
|
||||
}
|
||||
|
||||
declare type Callback = (...args: unknown[]) => void;
|
||||
|
||||
declare const PROJECT_FILE_NAME: string;
|
||||
|
||||
declare const CORE_VERSION: string;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './bootstrap';
|
||||
export * from './Meta';
|
||||
export * from './Project';
|
||||
export * from './Scene';
|
||||
export * from './symbols';
|
||||
export * from './TimeEvents';
|
||||
|
||||
Reference in New Issue
Block a user