diff --git a/packages/2d/src/lib/components/Camera.ts b/packages/2d/src/lib/components/Camera.ts new file mode 100644 index 000000000..2ef46da18 --- /dev/null +++ b/packages/2d/src/lib/components/Camera.ts @@ -0,0 +1,359 @@ +import { + all, + DEFAULT, + easeInOutCubic, + InterpolationFunction, + modify, + PossibleVector2, + Reference, + SignalValue, + SimpleSignal, + threadable, + ThreadGenerator, + TimingFunction, + tween, + unwrap, + Vector2, +} from '@revideo/core'; +import {cloneable, signal} from '../decorators'; +import {Curve} from './Curve'; +import {Node, NodeProps} from './Node'; +import {Rect, RectProps} from './Rect'; + +export interface CameraProps extends NodeProps { + /** + * {@inheritDoc Camera.scene} + */ + scene?: Node; + + /** + * {@inheritDoc Camera.zoom} + */ + zoom?: SignalValue; +} + +/** + * A node representing an orthographic camera. + * + * @preview + * ```tsx editor + * import {Camera, Circle, makeScene2D, Rect} from '@motion-canvas/2d'; + * import {all, createRef} from '@motion-canvas/core'; + * + * export default makeScene2D(function* (view) { + * const camera = createRef(); + * const rect = createRef(); + * const circle = createRef(); + * + * view.add( + * <> + * + * + * + * + * , + * ); + * + * yield* all( + * camera().centerOn(rect(), 3), + * camera().rotation(180, 3), + * camera().zoom(1.8, 3), + * ); + * yield* camera().centerOn(circle(), 2); + * yield* camera().reset(1); + * }); + * ``` + */ +export class Camera extends Node { + /** + * The scene node that the camera is rendering. + */ + @signal() + public declare readonly scene: SimpleSignal; + + public constructor({children, ...props}: CameraProps) { + super(props); + + if (!this.scene()) { + this.scene(new Node({})); + } + + if (children) { + this.scene().add(children); + } + } + + /** + * The zoom level of the camera. + * + * @defaultValue 1 + */ + @cloneable(false) + @signal() + public declare readonly zoom: SimpleSignal; + + protected getZoom(): number { + return 1 / this.scale.x(); + } + + protected setZoom(value: SignalValue) { + this.scale(modify(value, unwrapped => 1 / unwrapped)); + } + + protected getDefaultZoom() { + return this.scale.x.context.getInitial(); + } + + protected *tweenZoom( + value: SignalValue, + duration: number, + timingFunction: TimingFunction, + interpolationFunction: InterpolationFunction, + ): ThreadGenerator { + const from = this.scale.x(); + yield* tween(duration, v => { + this.zoom( + 1 / interpolationFunction(from, 1 / unwrap(value), timingFunction(v)), + ); + }); + } + + /** + * Resets the camera's position, rotation and zoom level to their original + * values. + * + * @param duration - The duration of the tween. + * @param timingFunction - The timing function to use for the tween. + */ + @threadable() + public *reset( + duration: number, + timingFunction: TimingFunction = easeInOutCubic, + ): ThreadGenerator { + yield* all( + this.position(DEFAULT, duration, timingFunction), + this.zoom(DEFAULT, duration, timingFunction), + this.rotation(DEFAULT, duration, timingFunction), + ); + } + + /** + * Centers the camera on the specified position without changing the zoom + * level. + * + * @param position - The position to center the camera on. + * @param duration - The duration of the tween. + * @param timingFunction - The timing function to use for the tween. + * @param interpolationFunction - The interpolation function to use for the + * tween. + */ + public centerOn( + position: PossibleVector2, + duration: number, + timingFunction?: TimingFunction, + interpolationFunction?: InterpolationFunction, + ): ThreadGenerator; + /** + * Centers the camera on the specified node without changing the zoom level. + * + * @param node - The node to center the camera on. + * @param duration - The duration of the tween. + * @param timingFunction - The timing function to use for the tween. + * @param interpolationFunction - The interpolation function to use for the + * tween. + */ + public centerOn( + node: Node, + duration: number, + timingFunction?: TimingFunction, + interpolationFunction?: InterpolationFunction, + ): ThreadGenerator; + @threadable() + public *centerOn( + positionOrNode: Node | PossibleVector2, + duration: number, + timing: TimingFunction = easeInOutCubic, + interpolationFunction: InterpolationFunction = Vector2.lerp, + ): ThreadGenerator { + const position = + positionOrNode instanceof Node + ? positionOrNode + .absolutePosition() + .transformAsPoint(this.scene().worldToLocal()) + : positionOrNode; + yield* this.position(position, duration, timing, interpolationFunction); + } + + /** + * Makes the camera follow a path specified by the provided curve. + * + * @remarks + * This will not change the orientation of the camera. To make the camera + * orient itself along the curve, use {@link followCurveWithRotation} or + * {@link followCurveWithRotationReverse}. + * + * If you want to follow the curve in reverse, use {@link followCurveReverse}. + * + * @param curve - The curve to follow. + * @param duration - The duration of the tween. + * @param timing - The timing function to use for the tween. + */ + @threadable() + public *followCurve( + curve: Curve, + duration: number, + timing: TimingFunction = easeInOutCubic, + ): ThreadGenerator { + yield* tween(duration, value => { + const t = timing(value); + const point = curve + .getPointAtPercentage(t) + .position.transformAsPoint(curve.localToWorld()); + + this.position(point); + }); + } + + /** + * Makes the camera follow a path specified by the provided curve in reverse. + * + * @remarks + * This will not change the orientation of the camera. To make the camera + * orient itself along the curve, use {@link followCurveWithRotation} or + * {@link followCurveWithRotationReverse}. + * + * If you want to follow the curve forward, use {@link followCurve}. + * + * @param curve - The curve to follow. + * @param duration - The duration of the tween. + * @param timing - The timing function to use for the tween. + */ + @threadable() + public *followCurveReverse( + curve: Curve, + duration: number, + timing: TimingFunction = easeInOutCubic, + ) { + yield* tween(duration, value => { + const t = 1 - timing(value); + const point = curve + .getPointAtPercentage(t) + .position.transformAsPoint(curve.localToWorld()); + + this.position(point); + }); + } + + /** + * Makes the camera follow a path specified by the provided curve while + * pointing the camera the direction of the tangent. + * + * @remarks + * To make the camera follow the curve without changing its orientation, use + * {@link followCurve} or {@link followCurveReverse}. + * + * If you want to follow the curve in reverse, use + * {@link followCurveWithRotationReverse}. + * + * @param curve - The curve to follow. + * @param duration - The duration of the tween. + * @param timing - The timing function to use for the tween. + */ + @threadable() + public *followCurveWithRotation( + curve: Curve, + duration: number, + timing: TimingFunction = easeInOutCubic, + ) { + yield* tween(duration, value => { + const t = timing(value); + const {position, normal} = curve.getPointAtPercentage(t); + const point = position.transformAsPoint(curve.localToWorld()); + const angle = normal.flipped.perpendicular.degrees; + + this.position(point); + this.rotation(angle); + }); + } + + /** + * Makes the camera follow a path specified by the provided curve in reverse + * while pointing the camera the direction of the tangent. + * + * @remarks + * To make the camera follow the curve without changing its orientation, use + * {@link followCurve} or {@link followCurveReverse}. + * + * If you want to follow the curve forward, use + * {@link followCurveWithRotation}. + * + * @param curve - The curve to follow. + * @param duration - The duration of the tween. + * @param timing - The timing function to use for the tween. + */ + @threadable() + public *followCurveWithRotationReverse( + curve: Curve, + duration: number, + timing: TimingFunction = easeInOutCubic, + ) { + yield* tween(duration, value => { + const t = 1 - timing(value); + const {position, normal} = curve.getPointAtPercentage(t); + const point = position.transformAsPoint(curve.localToWorld()); + const angle = normal.flipped.perpendicular.degrees; + + this.position(point); + this.rotation(angle); + }); + } + + protected override transformContext(context: CanvasRenderingContext2D) { + const matrix = this.localToParent().inverse(); + context.transform( + matrix.a, + matrix.b, + matrix.c, + matrix.d, + matrix.e, + matrix.f, + ); + } + + public override hit(position: Vector2): Node | null { + const local = position.transformAsPoint(this.localToParent()); + return this.scene().hit(local); + } + + protected override async drawChildren(context: CanvasRenderingContext2D) { + await (this.scene() as Camera).drawChildren(context); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + public static Stage({ + children, + cameraRef, + scene, + ...props + }: RectProps & {cameraRef?: Reference; scene?: Node}) { + const camera = new Camera({scene: scene, children}); + + cameraRef?.(camera); + + return new Rect({ + clip: true, + ...props, + children: [camera], + }); + } +} \ No newline at end of file diff --git a/packages/2d/src/lib/components/Node.ts b/packages/2d/src/lib/components/Node.ts index 648317a1c..2551823c9 100644 --- a/packages/2d/src/lib/components/Node.ts +++ b/packages/2d/src/lib/components/Node.ts @@ -605,6 +605,18 @@ export class Node implements Promisable { return this.parent()?.worldToLocal() ?? new DOMMatrix(); } + /** + * Get the parent-to-world matrix for this node. + * + * @remarks + * This matrix transforms vectors from local space of this node's parent to + * world space. + */ + @computed() + public parentToWorld(): DOMMatrix { + return this.parent()?.localToWorld() ?? new DOMMatrix(); + } + /** * Get the local-to-parent matrix for this node. * diff --git a/packages/2d/src/lib/components/index.ts b/packages/2d/src/lib/components/index.ts index 4721f04d3..7f5273e95 100644 --- a/packages/2d/src/lib/components/index.ts +++ b/packages/2d/src/lib/components/index.ts @@ -1,5 +1,6 @@ export * from './Audio'; export * from './Bezier'; +export * from './Camera'; export * from './Circle'; export * from './Code'; export * from './CubicBezier'; diff --git a/packages/2d/src/lib/scenes/Scene2D.ts b/packages/2d/src/lib/scenes/Scene2D.ts index e32a56f3f..3a4fd5ccc 100644 --- a/packages/2d/src/lib/scenes/Scene2D.ts +++ b/packages/2d/src/lib/scenes/Scene2D.ts @@ -14,8 +14,9 @@ import { transformVectorAsPoint, useLogger, } from '@revideo/core'; -import type {Node} from '../components'; -import {Audio, Media, Video, View2D} from '../components'; + +import {Audio, Camera, Media, Node, Video, View2D} from '../components'; +import {is} from '../utils'; export class Scene2D extends GeneratorScene implements Inspectable { private view: View2D | null = null; @@ -121,6 +122,34 @@ export class Scene2D extends GeneratorScene implements Inspectable { if (node) { this.execute(() => { node.drawOverlay(context, matrix.multiply(node.localToWorld())); + const cameras = this.getView().findAll(is(Camera)); + const parentCameras = []; + for (const camera of cameras) { + const scene = camera.scene(); + if (!scene) continue; + + if (scene === node || scene.findFirst(n => n === node)) { + parentCameras.push(camera); + } + } + + if (parentCameras.length > 0) { + for (const camera of parentCameras) { + const cameraParentToWorld = camera.parentToWorld(); + const cameraLocalToParent = camera.localToParent().inverse(); + const nodeLocalToWorld = node.localToWorld(); + + node.drawOverlay( + context, + matrix + .multiply(cameraParentToWorld) + .multiply(cameraLocalToParent) + .multiply(nodeLocalToWorld), + ); + } + } else { + node.drawOverlay(context, matrix.multiply(node.localToWorld())); + } }); } } diff --git a/packages/core/src/types/Vector.ts b/packages/core/src/types/Vector.ts index 18e48fd33..79753fcd8 100644 --- a/packages/core/src/types/Vector.ts +++ b/packages/core/src/types/Vector.ts @@ -3,6 +3,7 @@ import {CompoundSignalContext} from '../signals'; import type {InterpolationFunction} from '../tweening'; import {arcLerp, clamp, map} from '../tweening'; import {DEG2RAD, RAD2DEG} from '../utils'; +import {Matrix2D, PossibleMatrix2D} from './Matrix2D'; import {Direction, Origin} from './alignment-enums'; import type {Type, WebGLConvertible} from './Type'; import {EPSILON} from './Type'; @@ -387,6 +388,25 @@ export class Vector2 implements Type, WebGLConvertible { return new Vector2(this.x * value, this.y * value); } + public transformAsPoint(matrix: PossibleMatrix2D) { + const m = new Matrix2D(matrix); + + return new Vector2( + this.x * m.scaleX + this.y * m.skewY + m.translateX, + this.x * m.skewX + this.y * m.scaleY + m.translateY, + ); + } + + public transform(matrix: PossibleMatrix2D) { + const m = new Matrix2D(matrix); + + return new Vector2( + this.x * m.scaleX + this.y * m.skewY, + this.x * m.skewX + this.y * m.scaleY, + ); + } + + public mul(possibleVector: PossibleVector2) { const vector = new Vector2(possibleVector); return new Vector2(this.x * vector.x, this.y * vector.y); diff --git a/packages/template/src/camera-example.tsx b/packages/template/src/camera-example.tsx new file mode 100644 index 000000000..cf75ab41c --- /dev/null +++ b/packages/template/src/camera-example.tsx @@ -0,0 +1,17 @@ +import {Camera, Circle, makeScene2D, Rect} from '@revideo/2d'; +import {createRef} from '@revideo/core'; + +export default makeScene2D('camera-example', function* (view) { + const camera = createRef(); + + view.add( + + + + , + ); + + yield* camera().position([-100, -30], 1); + yield* camera().position([100, -30], 1); + yield* camera().position(0, 1); +}); diff --git a/packages/template/src/project.ts b/packages/template/src/project.ts index b68fee8fa..6a750e86b 100644 --- a/packages/template/src/project.ts +++ b/packages/template/src/project.ts @@ -1,18 +1,18 @@ import {Color, makeProject, Vector2} from '@revideo/core'; import example from './example'; +import cameraExample from './camera-example'; import './global.css'; export const project = makeProject({ name: 'project', - scenes: [example], + scenes: [cameraExample], variables: { fill: 'green', }, settings: { shared: { - background: new Color('#FFFFFF'), range: [0, Infinity], size: new Vector2(1920, 1080), },