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:
Jacob
2022-07-14 18:59:00 +02:00
committed by GitHub
parent 352e131043
commit 6891f59741
41 changed files with 598 additions and 189 deletions

View File

@@ -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,
},
};

View File

@@ -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/**/*\"",

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;
}
}

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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';

View File

@@ -1,5 +1,4 @@
export * from './cached';
export * from './decorate';
export * from './getset';
export * from './KonvaNode';
export * from './threadable';

View File

@@ -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';

View File

@@ -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
View 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;
}

View 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);
});
});

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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;
}
}

View File

@@ -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
>;
/**

View 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);
});
});

View File

@@ -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;
}
}
}

View 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);
});
});

View File

@@ -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);
}

View 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);
});
});

View File

@@ -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;
}
}

View 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);
});
});

View File

@@ -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);
}

View File

@@ -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';

View File

@@ -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
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["src/**/*.test.*"]
}

View File

@@ -19,6 +19,5 @@
"@motion-canvas/core/lib/jsx-runtime": ["jsx-runtime.ts"]
}
},
"include": ["src"],
"exclude": ["src/**/*.test.*"]
"include": ["src"]
}