mirror of
https://github.com/motion-canvas/motion-canvas.git
synced 2026-01-11 14:57:56 -05:00
feat: framerate-independent timing (#64)
All time-related functions now correctly handle durations that are not a multiple of one frame.
For example, something like this:
```ts
yield* waitFor(0.01);
yield* tween(1, value => {
// animate
});
```
will cause the tween to be interpolated correctly.
I.e. starting at 0.01 and ending at 1.01.
Before the change, the `waitFor` duration would be
rounded up to the nearest frame (0.016(6) in case of 60 fps).
Analogically, it's now possible to create sequences with a short delay:
```
yield* sequence(
0.005,
...rects.map(rect => rect.x(100, 1))
);
```
The current time can be access via `useTime()`:
```ts
// current time: 0s
yield* waitFor(0.02);
// current time: 0.016(6)s
// real time: 0.02s
const realTime = useTime();
```
BREAKING CHANGE: Konva patches are not imported by default
Projects using `KonvaScene`s should import the patches manually at the very top of the file project:
```ts
import '@motion-canvas/core/lib/patches'
// ...
bootstrap(...);
```
`getset` import path has changed:
```ts
import {getset} from '@motion-canvas/core/lib/decorators/getset';
```
Closes: #57
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
rootDir: 'src',
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'jsdom',
|
||||
setupFiles: ['jest-canvas-mock'],
|
||||
globals: {
|
||||
CORE_VERSION: '1.0.0',
|
||||
PROJECT_FILE_NAME: 'tests',
|
||||
META_VERSION: 1,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
"author": "motion-canvas",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc -w",
|
||||
"test": "jest --passWithNoTests",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"watch": "tsc -p tsconfig.build.json -w",
|
||||
"test": "jest",
|
||||
"lint": "eslint \"src/**/*.ts?(x)\"",
|
||||
"lint:fix": "eslint --fix \"src/**/*.ts?(x)\"",
|
||||
"prettier": "prettier --check \"src/**/*\"",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import './patches';
|
||||
|
||||
import {Scene, SceneDescription} from './scenes';
|
||||
import {Meta, Metadata} from './Meta';
|
||||
import {ValueDispatcher} from './events';
|
||||
@@ -40,7 +38,7 @@ export class Project {
|
||||
}
|
||||
|
||||
public get framerate(): number {
|
||||
return this.framesPerSeconds;
|
||||
return this.framesPerSeconds / this._speed;
|
||||
}
|
||||
|
||||
public set framerate(value: number) {
|
||||
@@ -53,6 +51,11 @@ export class Project {
|
||||
this.updateCanvas();
|
||||
}
|
||||
|
||||
public set speed(value: number) {
|
||||
this._speed = value;
|
||||
this.reloadAll();
|
||||
}
|
||||
|
||||
private updateCanvas() {
|
||||
if (this.canvas) {
|
||||
this.canvas.width = this.width * this._resolutionScale;
|
||||
@@ -89,6 +92,7 @@ export class Project {
|
||||
|
||||
public readonly name: string;
|
||||
private _resolutionScale = 1;
|
||||
private _speed = 1;
|
||||
private framesPerSeconds = 30;
|
||||
private readonly sceneLookup: Record<string, Scene> = {};
|
||||
private previousScene: Scene = null;
|
||||
@@ -177,7 +181,7 @@ export class Project {
|
||||
}
|
||||
}
|
||||
|
||||
public async next(speed = 1): Promise<boolean> {
|
||||
public async next(): Promise<boolean> {
|
||||
if (this.previousScene) {
|
||||
await this.previousScene.next();
|
||||
if (
|
||||
@@ -188,7 +192,7 @@ export class Project {
|
||||
}
|
||||
}
|
||||
|
||||
this.frame += speed;
|
||||
this.frame += this._speed;
|
||||
|
||||
if (this.currentScene.current) {
|
||||
await this.currentScene.current.next();
|
||||
@@ -207,15 +211,18 @@ export class Project {
|
||||
public async recalculate() {
|
||||
this.previousScene = null;
|
||||
|
||||
const speed = this._speed;
|
||||
this._speed = 1;
|
||||
this.frame = 0;
|
||||
const scenes = Object.values(this.sceneLookup);
|
||||
for (const scene of scenes) {
|
||||
await scene.recalculate();
|
||||
}
|
||||
this._speed = speed;
|
||||
this.scenes.current = scenes;
|
||||
}
|
||||
|
||||
public async seek(frame: number, speed = 1): Promise<boolean> {
|
||||
public async seek(frame: number): Promise<boolean> {
|
||||
if (this.currentScene.current && !this.currentScene.current.isCached()) {
|
||||
console.warn(
|
||||
'Attempting to seek a project with an invalidated scene:',
|
||||
@@ -245,7 +252,7 @@ export class Project {
|
||||
|
||||
let finished = false;
|
||||
while (this.frame < frame && !finished) {
|
||||
finished = await this.next(speed);
|
||||
finished = await this.next();
|
||||
}
|
||||
|
||||
return finished;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {getset, KonvaNode} from '../decorators';
|
||||
import {KonvaNode} from '../decorators';
|
||||
import {getset} from '../decorators/getset';
|
||||
import {Sprite} from './Sprite';
|
||||
import {GetSet} from 'konva/lib/types';
|
||||
import {LinearLayout} from './LinearLayout';
|
||||
|
||||
@@ -3,7 +3,8 @@ import {Shape, ShapeConfig} from 'konva/lib/Shape';
|
||||
import {Context} from 'konva/lib/Context';
|
||||
import {GetSet, Vector2d} from 'konva/lib/types';
|
||||
import {clamp} from 'three/src/math/MathUtils';
|
||||
import {getset, KonvaNode} from '../decorators';
|
||||
import {KonvaNode} from '../decorators';
|
||||
import {getset} from '../decorators/getset';
|
||||
|
||||
export interface ArrowConfig extends ShapeConfig {
|
||||
radius?: number;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {LinearLayout, LinearLayoutConfig} from './LinearLayout';
|
||||
import {Rect} from 'konva/lib/shapes/Rect';
|
||||
import {Range, RangeConfig} from './Range';
|
||||
import {getset, KonvaNode} from '../decorators';
|
||||
import {KonvaNode} from '../decorators';
|
||||
import {getset} from '../decorators/getset';
|
||||
import {parseColor} from 'mix-color';
|
||||
import {Surface} from './Surface';
|
||||
import {Origin} from '../types';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Context} from 'konva/lib/Context';
|
||||
import {GetSet} from 'konva/lib/types';
|
||||
import {KonvaNode, getset} from '../decorators';
|
||||
import {KonvaNode} from '../decorators';
|
||||
import {getset} from '../decorators/getset';
|
||||
import {CanvasHelper} from '../helpers';
|
||||
import {Shape, ShapeConfig} from 'konva/lib/Shape';
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import {GetSet, IRect, Vector2d} from 'konva/lib/types';
|
||||
import {ShapeGetClientRectConfig} from 'konva/lib/Shape';
|
||||
import {Origin, Size, Spacing, getOriginOffset} from '../types';
|
||||
import {Animator, tween, textTween, InterpolationFunction} from '../tweening';
|
||||
import {getset, threadable} from '../decorators';
|
||||
import {threadable} from '../decorators';
|
||||
import {getset} from '../decorators/getset';
|
||||
|
||||
export interface LayoutTextConfig extends TextConfig {
|
||||
minWidth?: number;
|
||||
|
||||
@@ -3,7 +3,8 @@ import {GetSet} from 'konva/lib/types';
|
||||
import {Shape} from 'konva/lib/Shape';
|
||||
import {Center, getOriginOffset, Origin, Size} from '../types';
|
||||
import {ContainerConfig} from 'konva/lib/Container';
|
||||
import {getset, KonvaNode} from '../decorators';
|
||||
import {KonvaNode} from '../decorators';
|
||||
import {getset} from '../decorators/getset';
|
||||
import {Node} from 'konva/lib/Node';
|
||||
|
||||
export interface LinearLayoutConfig extends ContainerConfig {
|
||||
|
||||
@@ -2,7 +2,8 @@ import {Group} from 'konva/lib/Group';
|
||||
import {Container, ContainerConfig} from 'konva/lib/Container';
|
||||
import {Center, flipOrigin, getOriginDelta, Origin} from '../types';
|
||||
import {GetSet, IRect} from 'konva/lib/types';
|
||||
import {getset, KonvaNode} from '../decorators';
|
||||
import {KonvaNode} from '../decorators';
|
||||
import {getset} from '../decorators/getset';
|
||||
import {Node} from 'konva/lib/Node';
|
||||
import {useKonvaView} from '../scenes';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Context} from 'konva/lib/Context';
|
||||
import {getset, KonvaNode} from '../decorators';
|
||||
import {KonvaNode} from '../decorators';
|
||||
import {getset} from '../decorators/getset';
|
||||
import {CanvasHelper} from '../helpers';
|
||||
import {GetSet} from 'konva/lib/types';
|
||||
import {getFontColor, getStyle, Style} from '../styles';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {Context} from 'konva/lib/Context';
|
||||
import {GetSet, Vector2d} from 'konva/lib/types';
|
||||
import {waitFor} from '../flow';
|
||||
import {cached, getset, KonvaNode, threadable} from '../decorators';
|
||||
import {cached, KonvaNode, threadable} from '../decorators';
|
||||
import {getset} from '../decorators/getset';
|
||||
import {GeneratorHelper} from '../helpers';
|
||||
import {InterpolationFunction, map, tween} from '../tweening';
|
||||
import {cancel, ThreadGenerator} from '../threading';
|
||||
@@ -223,7 +224,7 @@ export class Sprite extends Shape {
|
||||
if (this.task) {
|
||||
const previousTask = this.task;
|
||||
this.task = (function* (): ThreadGenerator {
|
||||
yield* cancel(previousTask);
|
||||
cancel(previousTask);
|
||||
yield* runTask;
|
||||
})();
|
||||
GeneratorHelper.makeThreadable(this.task, runTask);
|
||||
@@ -263,7 +264,7 @@ export class Sprite extends Shape {
|
||||
@threadable()
|
||||
public *stop() {
|
||||
if (this.task) {
|
||||
yield* cancel(this.task);
|
||||
cancel(this.task);
|
||||
this.task = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import {getOriginDelta, Origin, Size} from '../types';
|
||||
import {CanvasHelper} from '../helpers';
|
||||
import {easeOutExpo, linear, tween} from '../tweening';
|
||||
import {GetSet} from 'konva/lib/types';
|
||||
import {getset, KonvaNode, threadable} from '../decorators';
|
||||
import {KonvaNode, threadable} from '../decorators';
|
||||
import {getset} from '../decorators/getset';
|
||||
import {Node} from 'konva/lib/Node';
|
||||
import {Reference} from '../utils';
|
||||
import {Group} from 'konva/lib/Group';
|
||||
|
||||
@@ -4,7 +4,8 @@ import {Context} from 'konva/lib/Context';
|
||||
import * as THREE from 'three';
|
||||
import {CanvasHelper} from '../helpers';
|
||||
import {GetSet} from 'konva/lib/types';
|
||||
import {getset, KonvaNode} from '../decorators';
|
||||
import {KonvaNode} from '../decorators';
|
||||
import {getset} from '../decorators/getset';
|
||||
import {Shape, ShapeConfig} from 'konva/lib/Shape';
|
||||
|
||||
export interface ThreeViewConfig extends ShapeConfig {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Shape, ShapeConfig} from 'konva/lib/Shape';
|
||||
import {getset, threadable} from '../decorators';
|
||||
import {threadable} from '../decorators';
|
||||
import {getset} from '../decorators/getset';
|
||||
import {Context} from 'konva/lib/Context';
|
||||
import {GetSet} from 'konva/lib/types';
|
||||
import {cancel, ThreadGenerator} from '../threading';
|
||||
@@ -57,7 +58,7 @@ export class Video extends Shape {
|
||||
if (this.task) {
|
||||
const previousTask = this.task;
|
||||
this.task = (function* (): ThreadGenerator {
|
||||
yield* cancel(previousTask);
|
||||
cancel(previousTask);
|
||||
yield* runTask;
|
||||
})();
|
||||
GeneratorHelper.makeThreadable(this.task, runTask);
|
||||
@@ -71,7 +72,7 @@ export class Video extends Shape {
|
||||
@threadable()
|
||||
public *stop() {
|
||||
if (this.task) {
|
||||
yield* cancel(this.task);
|
||||
cancel(this.task);
|
||||
this.task = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {cached, getset, KonvaNode, threadable} from '../../decorators';
|
||||
import {cached, KonvaNode, threadable} from '../../decorators';
|
||||
import {getset} from '../../decorators/getset';
|
||||
import {GetSet} from 'konva/lib/types';
|
||||
import PrismJS from 'prismjs';
|
||||
import {Context} from 'konva/lib/Context';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from './cached';
|
||||
export * from './decorate';
|
||||
export * from './getset';
|
||||
export * from './KonvaNode';
|
||||
export * from './threadable';
|
||||
|
||||
@@ -10,5 +10,6 @@ export * from './delay';
|
||||
export * from './every';
|
||||
export * from './loop';
|
||||
export * from './noop';
|
||||
export * from './run';
|
||||
export * from './scheduling';
|
||||
export * from './sequence';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {ThreadGenerator} from '../threading';
|
||||
import {decorate, threadable} from '../decorators';
|
||||
|
||||
decorate(noop, threadable());
|
||||
/**
|
||||
* Do nothing.
|
||||
*/
|
||||
|
||||
48
src/flow/run.ts
Normal file
48
src/flow/run.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {ThreadGenerator} from '../threading';
|
||||
import {GeneratorHelper} from '../helpers';
|
||||
|
||||
/**
|
||||
* Turn the given generator function into a threadable generator.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* yield run(function* () {
|
||||
* // do things
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @param runner A generator function or a factory that creates the generator.
|
||||
*/
|
||||
export function run(runner: () => ThreadGenerator): ThreadGenerator;
|
||||
/**
|
||||
* Turn the given generator function into a threadable generator.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* yield run(function* () {
|
||||
* // do things
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @param runner A generator function or a factory that creates the generator.
|
||||
* @param name An optional name used when displaying this generator in the UI.
|
||||
*/
|
||||
export function run(
|
||||
name: string,
|
||||
runner: () => ThreadGenerator,
|
||||
): ThreadGenerator;
|
||||
export function run(
|
||||
firstArg: (() => ThreadGenerator) | string,
|
||||
runner?: () => ThreadGenerator,
|
||||
): ThreadGenerator {
|
||||
let task;
|
||||
if (typeof firstArg === 'string') {
|
||||
task = runner();
|
||||
GeneratorHelper.makeThreadable(task, firstArg);
|
||||
} else {
|
||||
task = firstArg();
|
||||
GeneratorHelper.makeThreadable(task, task);
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
64
src/flow/scheduling.test.ts
Normal file
64
src/flow/scheduling.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import {Project} from '../Project';
|
||||
import {setProject, useProject, useThread, useTime} from '../utils';
|
||||
import {threads} from '../threading';
|
||||
import {waitFor} from './scheduling';
|
||||
|
||||
describe('waitFor()', () => {
|
||||
beforeEach(() => {
|
||||
setProject(new Project({name: 'tests', scenes: []}));
|
||||
});
|
||||
|
||||
test('Framerate-independent wait duration', () => {
|
||||
const project = useProject();
|
||||
|
||||
let time60;
|
||||
const task60 = threads(function* () {
|
||||
yield* waitFor(3.1415);
|
||||
time60 = useTime();
|
||||
});
|
||||
|
||||
let time24;
|
||||
const task24 = threads(function* () {
|
||||
yield* waitFor(3.1415);
|
||||
time24 = useTime();
|
||||
});
|
||||
|
||||
project.framerate = 60;
|
||||
project.frame = 0;
|
||||
for (const _ of task60) {
|
||||
project.frame++;
|
||||
}
|
||||
|
||||
project.framerate = 24;
|
||||
project.frame = 0;
|
||||
for (const _ of task24) {
|
||||
project.frame++;
|
||||
}
|
||||
|
||||
expect(time60).toBeCloseTo(3.1415);
|
||||
expect(time24).toBeCloseTo(3.1415);
|
||||
});
|
||||
|
||||
test('Accumulated time offset', () => {
|
||||
const project = useProject();
|
||||
|
||||
let time: number;
|
||||
const task = threads(function* () {
|
||||
yield* waitFor(0.15);
|
||||
yield* waitFor(0.15);
|
||||
yield* waitFor(0.15);
|
||||
time = useTime();
|
||||
});
|
||||
|
||||
project.framerate = 10;
|
||||
project.frame = 0;
|
||||
for (const _ of task) {
|
||||
project.frame++;
|
||||
}
|
||||
|
||||
expect(project.frame).toBe(4);
|
||||
expect(time).toBeCloseTo(0.45);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import {decorate, threadable} from '../decorators';
|
||||
import {ThreadGenerator} from '../threading';
|
||||
import {useProject, useScene} from '../utils';
|
||||
import {useProject, useScene, useThread} from '../utils';
|
||||
|
||||
decorate(waitUntil, threadable());
|
||||
/**
|
||||
@@ -54,11 +54,16 @@ export function* waitFor(
|
||||
after?: ThreadGenerator,
|
||||
): ThreadGenerator {
|
||||
const project = useProject();
|
||||
const frames = project.secondsToFrames(seconds);
|
||||
const startFrame = project.frame;
|
||||
while (project.frame - startFrame < frames) {
|
||||
const thread = useThread();
|
||||
const step = project.framesToSeconds(1);
|
||||
|
||||
const targetTime = thread.time + seconds;
|
||||
// subtracting the step is not necessary, but it keeps the thread time ahead
|
||||
// of the project time.
|
||||
while (targetTime - step > project.time) {
|
||||
yield;
|
||||
}
|
||||
thread.time = targetTime;
|
||||
|
||||
if (after) {
|
||||
yield* after;
|
||||
|
||||
@@ -4,19 +4,31 @@ import {join, ThreadGenerator} from '../threading';
|
||||
|
||||
decorate(sequence, threadable());
|
||||
/**
|
||||
* Run
|
||||
* Start all tasks one after another with a constant delay between.
|
||||
*
|
||||
* The function doesn't wait until the previous task in the sequence has
|
||||
* finished. Once the delay has passed, the next task will start event if
|
||||
* the previous is still running.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* yield* sequence(
|
||||
* 0.1,
|
||||
* ...rects.map(rect => rect.x(100, 1))
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @param delay
|
||||
* @param sequences
|
||||
* @param tasks
|
||||
*/
|
||||
export function* sequence(
|
||||
delay: number,
|
||||
...sequences: ThreadGenerator[]
|
||||
...tasks: ThreadGenerator[]
|
||||
): ThreadGenerator {
|
||||
for (const sequence1 of sequences) {
|
||||
yield sequence1;
|
||||
for (const task of tasks) {
|
||||
yield task;
|
||||
yield* waitFor(delay);
|
||||
}
|
||||
|
||||
yield* join(...sequences);
|
||||
yield* join(...tasks);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ export class Player {
|
||||
|
||||
public loadState(state: Partial<PlayerState>) {
|
||||
this.updateState(state);
|
||||
this.project.speed = state.speed;
|
||||
this.project.framerate = state.fps;
|
||||
this.project.resolutionScale = state.scale;
|
||||
this.setRange(state.startFrame, state.endFrame);
|
||||
@@ -157,7 +158,9 @@ export class Player {
|
||||
}
|
||||
|
||||
public setSpeed(value: number) {
|
||||
this.project.speed = value;
|
||||
this.updateState({speed: value});
|
||||
this.reload();
|
||||
}
|
||||
|
||||
public setFramerate(fps: number) {
|
||||
@@ -180,11 +183,11 @@ export class Player {
|
||||
}
|
||||
|
||||
public requestPreviousFrame(): void {
|
||||
this.commands.seek = this.frame.current - 1;
|
||||
this.commands.seek = this.frame.current - this.state.current.speed;
|
||||
}
|
||||
|
||||
public requestNextFrame(): void {
|
||||
this.commands.seek = this.frame.current + 1;
|
||||
this.commands.seek = this.frame.current + this.state.current.speed;
|
||||
}
|
||||
|
||||
public requestReset(): void {
|
||||
@@ -282,7 +285,7 @@ export class Player {
|
||||
const seekFrame = commands.seek < 0 ? this.project.frame : commands.seek;
|
||||
const clampedFrame = this.clampRange(seekFrame, state);
|
||||
console.time('seek time');
|
||||
state.finished = await this.project.seek(clampedFrame, state.speed);
|
||||
state.finished = await this.project.seek(clampedFrame);
|
||||
console.timeEnd('seek time');
|
||||
this.syncAudio(-3);
|
||||
}
|
||||
@@ -305,11 +308,11 @@ export class Player {
|
||||
this.project.time < this.audio.getTime() - MAX_AUDIO_DESYNC
|
||||
) {
|
||||
const seekFrame = this.project.secondsToFrames(this.audio.getTime());
|
||||
state.finished = await this.project.seek(seekFrame, state.speed);
|
||||
state.finished = await this.project.seek(seekFrame);
|
||||
}
|
||||
// Simply move forward one frame
|
||||
else if (this.project.frame < state.endFrame) {
|
||||
state.finished = await this.project.next(state.speed);
|
||||
state.finished = await this.project.next();
|
||||
|
||||
// Synchronize audio.
|
||||
if (state.speed !== 1) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {TimeEvents} from './TimeEvents';
|
||||
import {EventDispatcher, ValueDispatcher} from '../events';
|
||||
import {Project} from '../Project';
|
||||
import {decorate, threadable} from '../decorators';
|
||||
import {setScene} from '../utils';
|
||||
import {setProject, setScene} from '../utils';
|
||||
import {CachedSceneData, Scene, SceneMetadata} from './Scene';
|
||||
import {
|
||||
isTransitionable,
|
||||
@@ -157,6 +157,7 @@ export abstract class GeneratorScene<T>
|
||||
|
||||
public async next() {
|
||||
setScene(this);
|
||||
setProject(this.project);
|
||||
let result = this.runner.next();
|
||||
this.update();
|
||||
while (result.value) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {GeneratorHelper} from '../helpers';
|
||||
import {ThreadGenerator} from './ThreadGenerator';
|
||||
import {setThread, useProject} from '../utils';
|
||||
|
||||
/**
|
||||
* A class representing an individual thread.
|
||||
@@ -16,36 +17,59 @@ export class Thread {
|
||||
*/
|
||||
public value: unknown;
|
||||
|
||||
/**
|
||||
* The current time of this thread.
|
||||
*
|
||||
* Used by {@link waitFor} and other time-based functions to properly support
|
||||
* durations shorter than one frame.
|
||||
*/
|
||||
public time = 0;
|
||||
|
||||
/**
|
||||
* Check if this thread or any of its ancestors has been canceled.
|
||||
*/
|
||||
public get canceled(): boolean {
|
||||
return this._canceled || (this.parent?.canceled ?? false);
|
||||
return this.isCanceled || (this.parent?.canceled ?? false);
|
||||
}
|
||||
|
||||
private parent: Thread = null;
|
||||
private _canceled = false;
|
||||
public parent: Thread = null;
|
||||
private isCanceled = false;
|
||||
private readonly frameDuration: number;
|
||||
|
||||
public constructor(
|
||||
/**
|
||||
* The generator wrapped by this thread.
|
||||
*/
|
||||
public readonly runner: ThreadGenerator,
|
||||
) {}
|
||||
) {
|
||||
const project = useProject();
|
||||
this.frameDuration = project.framesToSeconds(1);
|
||||
this.time = project.time;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress the wrapped generator once.
|
||||
*/
|
||||
public next() {
|
||||
setThread(this);
|
||||
const result = this.runner.next(this.value);
|
||||
this.value = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the thread for the next update cycle.
|
||||
*/
|
||||
public update() {
|
||||
this.time += useProject().framesToSeconds(1);
|
||||
this.children = this.children.filter(child => !child.canceled);
|
||||
}
|
||||
|
||||
public add(child: Thread) {
|
||||
child.cancel();
|
||||
child.parent = this;
|
||||
child._canceled = false;
|
||||
child.isCanceled = false;
|
||||
child.time = this.time;
|
||||
this.children.push(child);
|
||||
|
||||
if (!Object.getPrototypeOf(child.runner).threadable) {
|
||||
@@ -58,9 +82,7 @@ export class Thread {
|
||||
}
|
||||
|
||||
public cancel() {
|
||||
if (!this.parent) return;
|
||||
this.parent.children = this.parent.children.filter(child => child !== this);
|
||||
this.isCanceled = true;
|
||||
this.parent = null;
|
||||
this._canceled = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {JoinYieldResult} from './join';
|
||||
import {CancelYieldResult} from './cancel';
|
||||
import {Thread} from './Thread';
|
||||
|
||||
/**
|
||||
* The main generator type produced by all generator functions in Motion Canvas.
|
||||
@@ -29,9 +28,9 @@ import {CancelYieldResult} from './cancel';
|
||||
* [promise]: https://developer.mozilla.org/en-US/docs/web/javascript/reference/global_objects/promise
|
||||
*/
|
||||
export type ThreadGenerator = Generator<
|
||||
ThreadGenerator | JoinYieldResult | CancelYieldResult | Promise<any>,
|
||||
ThreadGenerator | Promise<any>,
|
||||
void,
|
||||
ThreadGenerator | any
|
||||
Thread | any
|
||||
>;
|
||||
|
||||
/**
|
||||
|
||||
34
src/threading/cancel.test.ts
Normal file
34
src/threading/cancel.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import {Project} from '../Project';
|
||||
import {setProject, useProject, useTime} from '../utils';
|
||||
import {waitFor} from '../flow';
|
||||
import {threads} from './threads';
|
||||
import {join} from './join';
|
||||
import {cancel} from './cancel';
|
||||
|
||||
describe('cancel()', () => {
|
||||
beforeEach(() => {
|
||||
setProject(new Project({name: 'tests', scenes: []}));
|
||||
});
|
||||
|
||||
test('Elapsed time when canceling a thread', () => {
|
||||
const project = useProject();
|
||||
|
||||
let time: number;
|
||||
const task = threads(function* () {
|
||||
const waitTask = yield waitFor(2);
|
||||
cancel(waitTask);
|
||||
yield* join(waitTask);
|
||||
time = useTime();
|
||||
});
|
||||
|
||||
project.framerate = 10;
|
||||
project.frame = 0;
|
||||
for (const _ of task) {
|
||||
project.frame++;
|
||||
}
|
||||
|
||||
expect(time).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,6 @@
|
||||
import {decorate, threadable} from '../decorators';
|
||||
import {ThreadGenerator} from './ThreadGenerator';
|
||||
import {useThread} from '../utils';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export const THREAD_CANCEL = Symbol.for('THREAD_CANCEL');
|
||||
|
||||
/**
|
||||
* An instruction passed to the {@link threads} generator to cancel tasks.
|
||||
*/
|
||||
export interface CancelYieldResult {
|
||||
/**
|
||||
* Tasks to cancel.
|
||||
*/
|
||||
[THREAD_CANCEL]: ThreadGenerator[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given value is a {@link CancelYieldResult}.
|
||||
*
|
||||
* @param value A possible {@link CancelYieldResult}.
|
||||
*/
|
||||
export function isCancelYieldResult(
|
||||
value: unknown,
|
||||
): value is CancelYieldResult {
|
||||
return typeof value === 'object' && THREAD_CANCEL in value;
|
||||
}
|
||||
|
||||
decorate(cancel, threadable());
|
||||
/**
|
||||
* Cancel all listed tasks.
|
||||
*
|
||||
@@ -42,6 +15,13 @@ decorate(cancel, threadable());
|
||||
*
|
||||
* @param tasks
|
||||
*/
|
||||
export function* cancel(...tasks: ThreadGenerator[]): ThreadGenerator {
|
||||
yield {[THREAD_CANCEL]: tasks};
|
||||
export function cancel(...tasks: ThreadGenerator[]) {
|
||||
const thread = useThread();
|
||||
for (const task of tasks) {
|
||||
const child = thread.children.find(thread => thread.runner === task);
|
||||
if (child && !child.canceled) {
|
||||
child.cancel();
|
||||
child.time = thread.time;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
92
src/threading/join.test.ts
Normal file
92
src/threading/join.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import {Project} from '../Project';
|
||||
import {setProject, useProject, useTime} from '../utils';
|
||||
import {waitFor} from '../flow';
|
||||
import {threads} from './threads';
|
||||
import {join} from './join';
|
||||
|
||||
describe('join()', () => {
|
||||
beforeEach(() => {
|
||||
setProject(new Project({name: 'tests', scenes: []}));
|
||||
});
|
||||
|
||||
test('Elapsed time when joining all threads', () => {
|
||||
const project = useProject();
|
||||
|
||||
let time: number;
|
||||
const task = threads(function* () {
|
||||
const taskA = yield waitFor(0.15);
|
||||
const taskB = yield waitFor(0.17);
|
||||
yield* join(taskA, taskB);
|
||||
time = useTime();
|
||||
});
|
||||
|
||||
project.framerate = 10;
|
||||
project.frame = 0;
|
||||
for (const _ of task) {
|
||||
project.frame++;
|
||||
}
|
||||
|
||||
expect(time).toBeCloseTo(0.17);
|
||||
});
|
||||
|
||||
test('Elapsed time when joining any thread', () => {
|
||||
const project = useProject();
|
||||
|
||||
let time: number;
|
||||
const task = threads(function* () {
|
||||
const taskA = yield waitFor(0.15);
|
||||
const taskB = yield waitFor(0.17);
|
||||
yield* join(false, taskA, taskB);
|
||||
time = useTime();
|
||||
});
|
||||
|
||||
project.framerate = 10;
|
||||
project.frame = 0;
|
||||
for (const _ of task) {
|
||||
project.frame++;
|
||||
}
|
||||
|
||||
expect(time).toBeCloseTo(0.15);
|
||||
});
|
||||
|
||||
test('Elapsed time when joining an already canceled thread', () => {
|
||||
const project = useProject();
|
||||
|
||||
let time: number;
|
||||
const task = threads(function* () {
|
||||
const waitTask = yield waitFor(0.15);
|
||||
yield* waitFor(0.2);
|
||||
yield* join(waitTask);
|
||||
time = useTime();
|
||||
});
|
||||
|
||||
project.framerate = 10;
|
||||
project.frame = 0;
|
||||
for (const _ of task) {
|
||||
project.frame++;
|
||||
}
|
||||
|
||||
expect(time).toBeCloseTo(0.2);
|
||||
});
|
||||
|
||||
test('Elapsed time when joining a thread right after cancellation', () => {
|
||||
const project = useProject();
|
||||
|
||||
let time: number;
|
||||
const task = threads(function* () {
|
||||
const waitTask = yield waitFor(0.05);
|
||||
yield* join(waitTask);
|
||||
time = useTime();
|
||||
});
|
||||
|
||||
project.framerate = 10;
|
||||
project.frame = 0;
|
||||
for (const _ of task) {
|
||||
project.frame++;
|
||||
}
|
||||
|
||||
expect(time).toBeCloseTo(0.05);
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,6 @@
|
||||
import {decorate, threadable} from '../decorators';
|
||||
import {ThreadGenerator} from './ThreadGenerator';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export const THREAD_JOIN = Symbol.for('THREAD_JOIN');
|
||||
|
||||
/**
|
||||
* An instruction passed to the {@link threads} generator to join tasks.
|
||||
*/
|
||||
export interface JoinYieldResult {
|
||||
/**
|
||||
* Tasks to join.
|
||||
*/
|
||||
[THREAD_JOIN]: ThreadGenerator[];
|
||||
/**
|
||||
* Whether we should wait for all tasks or for at least one.
|
||||
*/
|
||||
all: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given value is a {@link JoinYieldResult}.
|
||||
*
|
||||
* @param value A possible {@link JoinYieldResult}.
|
||||
*/
|
||||
export function isJoinYieldResult(value: unknown): value is JoinYieldResult {
|
||||
return typeof value === 'object' && THREAD_JOIN in value;
|
||||
}
|
||||
import {useThread} from '../utils';
|
||||
|
||||
decorate(join, threadable());
|
||||
/**
|
||||
@@ -77,5 +50,25 @@ export function* join(
|
||||
tasks.push(first);
|
||||
}
|
||||
|
||||
yield* yield {[THREAD_JOIN]: tasks, all};
|
||||
const parent = useThread();
|
||||
const threads = tasks
|
||||
.map(task => parent.children.find(thread => thread.runner === task))
|
||||
.filter(thread => thread);
|
||||
|
||||
const startTime = parent.time;
|
||||
let childTime;
|
||||
if (all) {
|
||||
while (threads.find(thread => !thread.canceled)) {
|
||||
yield;
|
||||
}
|
||||
childTime = Math.max(...threads.map(thread => thread.time));
|
||||
} else {
|
||||
while (!threads.find(thread => thread.canceled)) {
|
||||
yield;
|
||||
}
|
||||
const canceled = threads.filter(thread => thread.canceled);
|
||||
childTime = Math.min(...canceled.map(thread => thread.time));
|
||||
}
|
||||
|
||||
parent.time = Math.max(startTime, childTime);
|
||||
}
|
||||
|
||||
45
src/threading/threads.test.ts
Normal file
45
src/threading/threads.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {threads} from './threads';
|
||||
import {noop, run} from '../flow';
|
||||
import {setProject} from '../utils';
|
||||
import {Project} from '../Project';
|
||||
|
||||
describe('threads()', () => {
|
||||
beforeEach(() => {
|
||||
setProject(new Project({name: 'tests', scenes: []}));
|
||||
});
|
||||
|
||||
test('Execution order', () => {
|
||||
const order: number[] = [];
|
||||
const task = threads(function* () {
|
||||
order.push(0);
|
||||
yield run(function* () {
|
||||
order.push(1);
|
||||
yield;
|
||||
order.push(4);
|
||||
});
|
||||
order.push(2);
|
||||
yield noop();
|
||||
order.push(3);
|
||||
yield;
|
||||
order.push(5);
|
||||
});
|
||||
|
||||
[...task];
|
||||
|
||||
expect(order).toEqual([0, 1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test('Cancellation of child threads', () => {
|
||||
const task = threads(function* () {
|
||||
yield run(function* () {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
yield;
|
||||
}
|
||||
});
|
||||
yield;
|
||||
yield;
|
||||
});
|
||||
|
||||
expect([...task].length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import {GeneratorHelper} from '../helpers';
|
||||
import {decorate, threadable} from '../decorators';
|
||||
import {Thread} from './Thread';
|
||||
import {isJoinYieldResult, THREAD_JOIN} from './join';
|
||||
import {isCancelYieldResult, THREAD_CANCEL} from './cancel';
|
||||
import {isThreadGenerator, ThreadGenerator} from './ThreadGenerator';
|
||||
|
||||
/**
|
||||
@@ -56,83 +54,45 @@ export function* threads(
|
||||
factory: ThreadsFactory,
|
||||
callback?: ThreadsCallback,
|
||||
): ThreadGenerator {
|
||||
let threads: Thread[] = [];
|
||||
const find = (runner: Generator) =>
|
||||
threads.find(thread => thread.runner === runner);
|
||||
|
||||
decorate(joinInternal, threadable());
|
||||
function* joinInternal(
|
||||
tasks: ThreadGenerator[],
|
||||
all: boolean,
|
||||
): ThreadGenerator {
|
||||
if (all) {
|
||||
while (tasks.find(runner => find(runner))) {
|
||||
yield;
|
||||
}
|
||||
} else {
|
||||
while (!tasks.find(runner => !find(runner))) {
|
||||
yield;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const root = factory();
|
||||
GeneratorHelper.makeThreadable(root, 'root');
|
||||
const rootThread = new Thread(root);
|
||||
callback?.(rootThread);
|
||||
threads.push(rootThread);
|
||||
while (threads.length > 0) {
|
||||
let hasChanged = false;
|
||||
const newThreads = [];
|
||||
|
||||
for (let i = 0; i < threads.length; i++) {
|
||||
const thread = threads[i];
|
||||
let threads: Thread[] = [rootThread];
|
||||
while (threads.length > 0) {
|
||||
const newThreads = [];
|
||||
const queue = [...threads];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const thread = queue.pop();
|
||||
if (thread.canceled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = thread.next();
|
||||
if (result.done) {
|
||||
hasChanged = true;
|
||||
thread.cancel();
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = result.value;
|
||||
if (isJoinYieldResult(value)) {
|
||||
value = joinInternal(value[THREAD_JOIN], value.all);
|
||||
} else if (isCancelYieldResult(value)) {
|
||||
value[THREAD_CANCEL].forEach((runner: Generator) => {
|
||||
const cancelThread = find(runner);
|
||||
if (cancelThread) {
|
||||
cancelThread.cancel();
|
||||
}
|
||||
});
|
||||
threads.push(thread);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isThreadGenerator(value)) {
|
||||
const child = find(value) ?? new Thread(value);
|
||||
thread.value = value;
|
||||
if (child.canceled) {
|
||||
console.warn('Reusing a canceled thread: ', child);
|
||||
}
|
||||
if (isThreadGenerator(result.value)) {
|
||||
const child = new Thread(result.value);
|
||||
thread.value = result.value;
|
||||
thread.add(child);
|
||||
hasChanged = true;
|
||||
|
||||
threads.push(thread);
|
||||
threads.push(child);
|
||||
} else if (value) {
|
||||
thread.value = yield value;
|
||||
threads.push(thread);
|
||||
queue.push(thread);
|
||||
queue.push(child);
|
||||
} else if (result.value) {
|
||||
thread.value = yield result.value;
|
||||
queue.push(thread);
|
||||
} else {
|
||||
newThreads.push(thread);
|
||||
thread.update();
|
||||
newThreads.unshift(thread);
|
||||
}
|
||||
}
|
||||
|
||||
threads = newThreads;
|
||||
if (hasChanged) callback?.(rootThread);
|
||||
threads = newThreads.filter(thread => !thread.canceled);
|
||||
if (threads.length > 0) yield;
|
||||
}
|
||||
}
|
||||
|
||||
68
src/tweening/tween.test.ts
Normal file
68
src/tweening/tween.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import {Project} from '../Project';
|
||||
import {setProject, useProject, useTime} from '../utils';
|
||||
import {threads} from '../threading';
|
||||
import {tween} from './tween';
|
||||
|
||||
describe('tween()', () => {
|
||||
beforeEach(() => {
|
||||
setProject(new Project({name: 'tests', scenes: []}));
|
||||
});
|
||||
|
||||
test('Framerate-independent tween duration', () => {
|
||||
const project = useProject();
|
||||
|
||||
let time60;
|
||||
const task60 = threads(function* () {
|
||||
yield* tween(3.1415, () => {
|
||||
// do nothing
|
||||
});
|
||||
time60 = useTime();
|
||||
});
|
||||
|
||||
let time24;
|
||||
const task24 = threads(function* () {
|
||||
yield* tween(3.1415, () => {
|
||||
// do nothing
|
||||
});
|
||||
time24 = useTime();
|
||||
});
|
||||
|
||||
project.framerate = 60;
|
||||
project.frame = 0;
|
||||
for (const _ of task60) {
|
||||
project.frame++;
|
||||
}
|
||||
|
||||
project.framerate = 24;
|
||||
project.frame = 0;
|
||||
for (const _ of task24) {
|
||||
project.frame++;
|
||||
}
|
||||
|
||||
expect(time60).toBeCloseTo(3.1415);
|
||||
expect(time24).toBeCloseTo(3.1415);
|
||||
});
|
||||
|
||||
test('Accumulated time offset', () => {
|
||||
const project = useProject();
|
||||
|
||||
let time: number;
|
||||
const task = threads(function* () {
|
||||
yield* tween(0.45, () => {
|
||||
// do nothing
|
||||
});
|
||||
time = useTime();
|
||||
});
|
||||
|
||||
project.framerate = 10;
|
||||
project.frame = 0;
|
||||
for (const _ of task) {
|
||||
project.frame++;
|
||||
}
|
||||
|
||||
expect(project.frame).toBe(5);
|
||||
expect(time).toBeCloseTo(0.45);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,30 @@
|
||||
import {decorate, threadable} from '../decorators';
|
||||
import {ThreadGenerator} from '../threading';
|
||||
import {useProject} from '../utils';
|
||||
import {useProject, useThread} from '../utils';
|
||||
|
||||
decorate(tween, threadable());
|
||||
export function* tween(
|
||||
duration: number,
|
||||
seconds: number,
|
||||
onProgress: (value: number, time: number) => void,
|
||||
onEnd?: (value: number, time: number) => void,
|
||||
): ThreadGenerator {
|
||||
const project = useProject();
|
||||
const frames = project.secondsToFrames(duration);
|
||||
const startFrame = project.frame;
|
||||
let value = 0;
|
||||
while (project.frame - startFrame < frames) {
|
||||
const time = project.framesToSeconds(project.frame - startFrame);
|
||||
value = (project.frame - startFrame) / frames;
|
||||
onProgress(value, time);
|
||||
const thread = useThread();
|
||||
|
||||
const startTime = thread.time;
|
||||
const endTime = thread.time + seconds;
|
||||
|
||||
onProgress(0, 0);
|
||||
while (endTime > project.time) {
|
||||
const time = project.time - startTime;
|
||||
const value = time / seconds;
|
||||
if (time > 0) {
|
||||
onProgress(value, time);
|
||||
}
|
||||
yield;
|
||||
}
|
||||
value = 1;
|
||||
onProgress(value, project.framesToSeconds(frames));
|
||||
onEnd?.(value, project.framesToSeconds(frames));
|
||||
thread.time = endTime;
|
||||
|
||||
onProgress(1, seconds);
|
||||
onEnd?.(1, seconds);
|
||||
}
|
||||
|
||||
@@ -3,4 +3,6 @@ export * from './useAnimator';
|
||||
export * from './useProject';
|
||||
export * from './useRef';
|
||||
export * from './useScene';
|
||||
export * from './useThread';
|
||||
export * from './useTime';
|
||||
export * from './useContext';
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import {useScene} from './useScene';
|
||||
import type {Project} from '../Project';
|
||||
|
||||
let currentProject: Project;
|
||||
|
||||
/**
|
||||
* Get a reference to the current project.
|
||||
*/
|
||||
export function useProject() {
|
||||
return useScene().project;
|
||||
return currentProject;
|
||||
}
|
||||
|
||||
export function setProject(project: Project) {
|
||||
currentProject = project;
|
||||
}
|
||||
|
||||
14
src/utils/useThread.ts
Normal file
14
src/utils/useThread.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type {Thread} from '../threading';
|
||||
|
||||
let currentThread: Thread;
|
||||
|
||||
/**
|
||||
* Get a reference to the current thread.
|
||||
*/
|
||||
export function useThread() {
|
||||
return currentThread;
|
||||
}
|
||||
|
||||
export function setThread(thread: Thread) {
|
||||
currentThread = thread;
|
||||
}
|
||||
21
src/utils/useTime.ts
Normal file
21
src/utils/useTime.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {useThread} from './useThread';
|
||||
|
||||
/**
|
||||
* Get the real time since the start of the animation.
|
||||
*
|
||||
* The returned value accounts for offsets caused by functions such as
|
||||
* {@link waitFor()}.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // current time: 0s
|
||||
* yield* waitFor(0.02);
|
||||
*
|
||||
* // current time: 0.016(6)s
|
||||
* // real time: 0.02s
|
||||
* const realTime = useTime();
|
||||
* ```
|
||||
*/
|
||||
export function useTime() {
|
||||
return useThread().time;
|
||||
}
|
||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["src/**/*.test.*"]
|
||||
}
|
||||
@@ -19,6 +19,5 @@
|
||||
"@motion-canvas/core/lib/jsx-runtime": ["jsx-runtime.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.*"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user