Skip to content

Commit 2aea7bf

Browse files
authored
Merge pull request #11 from diffusionstudio/konstantin/feature/animation-builder
Implemented and tested animation builder
2 parents cd11b75 + 1bf737a commit 2aea7bf

File tree

11 files changed

+322
-34
lines changed

11 files changed

+322
-34
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@diffusionstudio/core",
33
"private": false,
4-
"version": "1.0.0-rc.3",
4+
"version": "1.0.0-rc.4",
55
"type": "module",
66
"description": "Build bleeding edge video processing applications",
77
"files": [

playground/main.ts

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,39 +8,35 @@ const composition = new core.Composition({ background: '#76b7f5' });
88
setupControls(composition);
99
setupTimeline(composition);
1010

11-
await composition.add(
12-
new core.VideoClip(
13-
await core.VideoSource
14-
.from('/sample_aac_h264_yuv420p_1080p_60fps.mp4'),
15-
{
16-
volume: 0.1,
17-
anchor: 0.5,
18-
position: 'center',
19-
height: '100%',
20-
alpha: new core.Keyframe([0, 120, 240, 300], [0.5, 1, 0.5, 1]),
21-
scale: new core.Keyframe([0, 30], [0.1, 1], { easing: 'easeIn' }),
22-
rotation: new core.Keyframe([0, 30], [0, 360], { easing: 'easeOut' }),
23-
})
11+
const video = await composition.add(
12+
new core.VideoClip(await core.VideoSource
13+
.from('/sample_aac_h264_yuv420p_1080p_60fps.mp4'), {
14+
volume: 0.1,
15+
anchor: 0.5,
16+
position: 'center',
17+
height: '100%',
18+
})
2419
.subclip(30, 540)
2520
.offsetBy(30)
2621
);
2722

28-
await composition.add(
23+
video.animate()
24+
.alpha(0.5).to(1, 120).to(0.5, 120).to(1, 60)
25+
.scale(0.1, 0, 'easeIn').to(1, 30)
26+
.rotation(0, 0, 'easeOut').to(360, 30)
27+
28+
const image = await composition.add(
2929
new core.ImageClip(await core.ImageSource.from('/lenna.png'), {
3030
position: 'center',
3131
height: 600,
32-
translate: {
33-
x: new core.Keyframe([0, 40], [1700, -1400], { easing: 'easeOut' }),
34-
y: 0
35-
},
36-
rotation: new core.Keyframe(
37-
[0, 5, 10, 15, 20, 25, 30, 35, 40],
38-
[-16, 14, -7, 24, -3, 19, -14, 5, -30]
39-
),
40-
scale: new core.Keyframe([0, 40], [2, 1])
4132
})
4233
);
4334

35+
image.animate()
36+
.rotation(-16).to(14, 5).to(-7, 10).to(24, 7).to(-3, 9).to(19, 7).to(-14, 12).to(5, 9).to(-30, 13)
37+
.translateX(1700, 0, 'easeOut').to(-1400, 40)
38+
.scale(2).to(1, 40);
39+
4440
await composition.add(
4541
new core.HtmlClip(await core.HtmlSource.from('/test.html'), {
4642
position: {

src/clips/mixins/visual.animation.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Copyright (c) 2024 The Diffusion Studio Authors
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla
5+
* Public License, v. 2.0 that can be found in the LICENSE file.
6+
*/
7+
8+
import { AnimationBuilder as Builder, AnimationFunction } from '../../models/animation-builder';
9+
10+
export interface AnimationBuilder extends Builder {
11+
height: AnimationFunction<number, this>;
12+
width: AnimationFunction<number, this>;
13+
x: AnimationFunction<number, this>;
14+
y: AnimationFunction<number, this>;
15+
translateX: AnimationFunction<number, this>;
16+
translateY: AnimationFunction<number, this>;
17+
rotation: AnimationFunction<number, this>;
18+
alpha: AnimationFunction<number, this>;
19+
scale: AnimationFunction<number, this>;
20+
}
21+
22+
export class AnimationBuilder extends Builder { }

src/clips/mixins/visual.interfaces.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export interface VisualMixinProps {
1818
scale?: Scale | float | Keyframe<number> | NumberCallback;
1919
x?: int | Keyframe<int> | Percent | NumberCallback;
2020
y?: int | Keyframe<int> | Percent | NumberCallback;
21+
translateX?: int | Keyframe<int> | NumberCallback;
22+
translateY?: int | Keyframe<int> | NumberCallback;
2123
height?: Keyframe<int> | Percent | int | NumberCallback;
2224
width?: Keyframe<int> | Percent | int | NumberCallback;
2325
anchor?: Anchor | float;

src/clips/mixins/visual.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
import { serializable } from '../../services';
99
import * as deserializers from './visual.deserializers';
10+
import { createAnimationBuilder } from '../../models/animation-builder';
11+
import { AnimationBuilder } from './visual.animation';
1012
import { Keyframe } from '../../models';
1113
import { Sprite } from 'pixi.js';
1214

@@ -18,6 +20,13 @@ type BaseClass = { view: Container } & Serializer;
1820

1921
export function VisualMixin<T extends Constructor<BaseClass>>(Base: T) {
2022
class Mixin extends Base {
23+
/**
24+
* Apply one or more `Pixi.js` filters to the clip.
25+
* @example
26+
* clip.filters = [new BlurFilter()];
27+
*/
28+
public filters?: Filter | Filter[];
29+
2130
@serializable(deserializers.Deserializer1D)
2231
public _height?: int | Keyframe<int> | Percent | NumberCallback;
2332

@@ -33,12 +42,6 @@ export function VisualMixin<T extends Constructor<BaseClass>>(Base: T) {
3342
@serializable(deserializers.Deserializer2D)
3443
public _scale?: Scale;
3544

36-
/**
37-
* Apply one or more `Pixi.js` filters to the clip.
38-
* @example
39-
*/
40-
public filters?: Filter | Filter[];
41-
4245
/**
4346
* Defines the rotation of the clip in degrees
4447
* @default 0
@@ -124,6 +127,30 @@ export function VisualMixin<T extends Constructor<BaseClass>>(Base: T) {
124127
this._position.y = value;
125128
}
126129

130+
/**
131+
* Offset relative to the x position
132+
* @default 0
133+
*/
134+
public get translateX(): int | Keyframe<int> | NumberCallback {
135+
return this.translate.x;
136+
}
137+
138+
public set translateX(value: int | Keyframe<int> | NumberCallback) {
139+
this.translate.x = value;
140+
}
141+
142+
/**
143+
* Offset relative to the y position
144+
* @default 0
145+
*/
146+
public get translateY(): int | Keyframe<int> | NumberCallback {
147+
return this.translate.y;
148+
}
149+
150+
public set translateY(value: int | Keyframe<int> | NumberCallback) {
151+
this.translate.y = value;
152+
}
153+
127154
/**
128155
* The height of the clip/container
129156
*/
@@ -184,6 +211,12 @@ export function VisualMixin<T extends Constructor<BaseClass>>(Base: T) {
184211
this.view.filters = null as any;
185212
}
186213
}
214+
215+
public animate() {
216+
return createAnimationBuilder(
217+
new AnimationBuilder(this)
218+
);
219+
}
187220
}
188221

189222
return Mixin;

src/models/animation-builder.spec.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Copyright (c) 2024 The Diffusion Studio Authors
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla
5+
* Public License, v. 2.0 that can be found in the LICENSE file.
6+
*/
7+
8+
import { describe, it, expect, beforeEach } from 'vitest';
9+
import { Keyframe } from './keyframe';
10+
11+
import { AnimationBuilder as Builder, createAnimationBuilder } from './animation-builder';
12+
import { EasingFunction } from './keyframe.types';
13+
14+
export interface AnimationBuilder extends Builder {
15+
height(value: number, delay?: number, easing?: EasingFunction): this;
16+
width(value: number, delay?: number, easing?: EasingFunction): this;
17+
}
18+
19+
export class AnimationBuilder extends Builder { }
20+
21+
class TestObject {
22+
height = 5;
23+
width = new Keyframe([0], [0]);
24+
}
25+
26+
27+
describe('The Animation Builder', () => {
28+
let testObject: TestObject;
29+
let animate: AnimationBuilder;
30+
31+
32+
beforeEach(() => {
33+
testObject = new TestObject();
34+
animate = createAnimationBuilder(new AnimationBuilder(testObject));
35+
});
36+
37+
it('should create and assign a new Keyframe', () => {
38+
animate.height(20).to(100, 12).width(30).to(80, 18);
39+
40+
expect(testObject.height).toBeInstanceOf(Keyframe);
41+
expect(testObject.width).toBeInstanceOf(Keyframe);
42+
43+
const height = testObject.height as any as Keyframe<number>;
44+
45+
expect(height.input.length).toBe(2);
46+
expect(height.output.length).toBe(2);
47+
48+
expect(height.input[0]).toBe(0);
49+
expect(height.input[1]).toBe(12 / 30 * 1000);
50+
51+
expect(height.output[0]).toBe(20);
52+
expect(height.output[1]).toBe(100);
53+
54+
const width = testObject.width as any as Keyframe<number>;
55+
56+
expect(width.input.length).toBe(2);
57+
expect(width.output.length).toBe(2);
58+
59+
expect(width.input[0]).toBe(0);
60+
expect(width.input[1]).toBe(18 / 30 * 1000);
61+
62+
expect(width.output[0]).toBe(30);
63+
expect(width.output[1]).toBe(80);
64+
});
65+
66+
it('should be based on relative delays', () => {
67+
animate.height(20).to(90, 12).to(200, 6).to(280, 3);
68+
69+
const height = testObject.height as any as Keyframe<number>;
70+
71+
expect(height.input.length).toBe(4);
72+
expect(height.output.length).toBe(4);
73+
74+
expect(height.input[0]).toBe(0);
75+
expect(height.input[1]).toBe(12 / 30 * 1000);
76+
expect(height.input[2]).toBe(18 / 30 * 1000);
77+
expect(height.input[3]).toBe(21 / 30 * 1000);
78+
79+
expect(height.output[0]).toBe(20);
80+
expect(height.output[1]).toBe(90);
81+
expect(height.output[2]).toBe(200);
82+
expect(height.output[3]).toBe(280);
83+
});
84+
85+
it('should set the easing function', () => {
86+
animate.height(20, 0, 'easeIn').to(100, 12).width(30, 0, 'easeOut').to(80, 18);
87+
88+
const height = testObject.height as any as Keyframe<number>;
89+
const width = testObject.width as any as Keyframe<number>;
90+
91+
expect(height.options.easing).toBe('easeIn');
92+
expect(width.options.easing).toBe('easeOut');
93+
});
94+
95+
it('should animate from the current value', () => {
96+
animate.height(20, 12);
97+
98+
const height = testObject.height as any as Keyframe<number>;
99+
100+
expect(height.input.length).toBe(2);
101+
expect(height.output.length).toBe(2);
102+
103+
expect(height.input[0]).toBe(0);
104+
expect(height.input[1]).toBe(12 / 30 * 1000);
105+
106+
expect(height.output[0]).toBe(5);
107+
expect(height.output[1]).toBe(20);
108+
});
109+
110+
it("should not animate from the current value if it's a Keyframe", () => {
111+
animate.width(20, 12);
112+
113+
const width = testObject.width as any as Keyframe<number>;
114+
115+
expect(width.input.length).toBe(1);
116+
expect(width.output.length).toBe(1);
117+
118+
expect(width.input[0]).toBe(12 / 30 * 1000);
119+
expect(width.output[0]).toBe(20);
120+
});
121+
});

src/models/animation-builder.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Copyright (c) 2024 The Diffusion Studio Authors
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla
5+
* Public License, v. 2.0 that can be found in the LICENSE file.
6+
*/
7+
8+
import { EasingFunction, Keyframe, Timestamp } from "../models";
9+
10+
export type AnimationFunction<V extends number | string, T> =
11+
(value: V, delay?: number, easing?: EasingFunction) => T;
12+
13+
export interface AnimationBuilder {
14+
to(value: number, relframe: number): this;
15+
}
16+
17+
export class AnimationBuilder {
18+
private target: any;
19+
public animation: Keyframe<string | number> | undefined;
20+
21+
constructor(target: any) {
22+
this.target = target;
23+
}
24+
25+
init(property: string | symbol, value: number | string, delay: number = 0, easing?: EasingFunction) {
26+
if (!(property in this.target)) {
27+
throw new Error(`Property [${String(property)}] cannot be assigned`);
28+
}
29+
30+
const input = [delay];
31+
const ouptut = [value];
32+
33+
// animate from current value to next value
34+
if (typeof (this.target[property]) == typeof (value) && delay != 0) {
35+
input.unshift(0);
36+
ouptut.unshift(this.target[property]);
37+
}
38+
39+
this.target[property] = this.animation = new Keyframe(input, ouptut, { easing });
40+
41+
}
42+
}
43+
44+
export function createAnimationBuilder<T extends AnimationBuilder>(builder: T) {
45+
const proxy = new Proxy(builder, {
46+
get(obj: T, prop) {
47+
if (prop == 'to') {
48+
return (value: number, relframe: number) => {
49+
if (!obj.animation) {
50+
throw new Error("Cannot use 'to() before selecting a property");
51+
}
52+
53+
const timestamp = new Timestamp(obj.animation.input.at(-1));
54+
const absframe = timestamp.frames + relframe;
55+
56+
// ATTENTION: The arguments are inversed here
57+
obj.animation.push(absframe, value);
58+
59+
return proxy;
60+
}
61+
}
62+
63+
return (value: number, delay?: number, easing?: EasingFunction) => {
64+
obj.init(prop, value, delay, easing);
65+
66+
return proxy;
67+
};
68+
},
69+
});
70+
71+
return proxy;
72+
}

0 commit comments

Comments
 (0)