├── .gitignore ├── src ├── index.ts ├── globals.d.ts ├── utils.ts └── CameraView.ts ├── .github └── workflows │ ├── commitlint.yml │ ├── release-please.yml │ └── publish.yml ├── CHANGELOG.md ├── .prettierrc ├── tsconfig.json ├── package.json ├── examples ├── zoomOnto.tsx ├── resetZoom.tsx ├── rotate.tsx ├── zoom.tsx └── trailer.tsx ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .vscode/ 3 | lib/ -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./CameraView"; 2 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare type Callback = (...args: any[]) => void; 2 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Commit Messages 2 | on: [pull_request] 3 | 4 | jobs: 5 | commitlint: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | with: 10 | fetch-depth: 0 11 | - uses: wagoid/commitlint-github-action@v5 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.5.0](https://github.com/ksassnowski/motion-canvas-camera/compare/0.4.2...v0.5.0) (2023-03-11) 4 | 5 | 6 | ### Features 7 | 8 | * support motion canvas 3.0 ([0b4bd03](https://github.com/ksassnowski/motion-canvas-camera/commit/0b4bd035eade48eef0058e8c63a5f32bcee433d1)) 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "semi": true, 4 | "tabWidth": 2, 5 | "singleQuote": false, 6 | "trailingComma": "all", 7 | "importOrder": [ 8 | "^@motion-canvas/(.*)$", 9 | "^@components/(.*)$", 10 | "^@utils/(.*)$", 11 | "^[./]" 12 | ], 13 | "importOrderSeparation": true, 14 | "importOrderSortSpecifiers": true, 15 | "importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"] 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | name: release-please 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: google-github-actions/release-please-action@v3 17 | with: 18 | release-type: node 19 | package-name: "@ksassnowski/motion-canvas-camera" -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["node_modules", "lib"], 4 | "compilerOptions": { 5 | "baseUrl": "src", 6 | "outDir": "./lib", 7 | "strict": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "noImplicitAny": true, 11 | "module": "esnext", 12 | "target": "es2020", 13 | "allowJs": true, 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "allowSyntheticDefaultImports": true, 17 | "experimentalDecorators": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | # Setup .npmrc file to publish to npm 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: "16.x" 14 | registry-url: "https://registry.npmjs.org" 15 | - run: npm ci 16 | - run: npm publish --access public 17 | env: 18 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 19 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a cycled entry from the list. If the provided index is 3 | * out of bounds of the list, it will instead wrap around to the 4 | * beginning. 5 | * 6 | * Throws an error if the provided list is empty. 7 | * 8 | * @param list - The list to search in 9 | * @param index - The index of the item 10 | */ 11 | export function getFromCycled(list: T[], index: number): T { 12 | if (list.length === 0) { 13 | throw new Error("Trying to get cycled entry from empty list"); 14 | } 15 | return list[index % (list.length - 1)]; 16 | } 17 | 18 | /** 19 | * Wraps a value into an array if it isn't already. 20 | * 21 | * @param value - The value to wrap 22 | */ 23 | export function wrapArray(value: T | T[]): T[] { 24 | if (Array.isArray(value)) { 25 | return value; 26 | } 27 | return [value]; 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ksassnowski/motion-canvas-camera", 3 | "author": "Kai Sassnowski", 4 | "version": "0.6.0", 5 | "description": "A camera component for Motion Canvas", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "keywords": [ 9 | "motion-canvas", 10 | "camera", 11 | "animation" 12 | ], 13 | "scripts": { 14 | "test": "echo \"Error: no test specified\" && exit 1", 15 | "build": "tsc", 16 | "format": "pretetier --write \"src/**/*.ts\"", 17 | "prepare": "npm run build", 18 | "version": "npm run format && git add -A src", 19 | "postversion": "git push && git push --tags" 20 | }, 21 | "files": [ 22 | "lib/**/*" 23 | ], 24 | "license": "MIT", 25 | "dependencies": { 26 | "@motion-canvas/2d": "^3.4.0", 27 | "@motion-canvas/core": "^3.4.0" 28 | }, 29 | "devDependencies": { 30 | "@trivago/prettier-plugin-sort-imports": "^4.0.0", 31 | "prettier": "^2.8.4", 32 | "typescript": "^4.9.5" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/zoomOnto.tsx: -------------------------------------------------------------------------------- 1 | import { CameraView } from "@ksassnowski/motion-canvas-camera"; 2 | 3 | import { makeScene2D } from "@motion-canvas/2d"; 4 | import { Circle } from "@motion-canvas/2d/lib/components"; 5 | import { createRef } from "@motion-canvas/core/lib/utils"; 6 | 7 | export default makeScene2D(function* (view) { 8 | const camera = createRef(); 9 | const circle1 = createRef(); 10 | const circle2 = createRef(); 11 | 12 | const circleStyles = { 13 | width: 200, 14 | height: 200, 15 | }; 16 | 17 | view.add( 18 | 19 | 25 | 31 | , 32 | ); 33 | 34 | yield* camera().zoomOnto(circle1(), 3, 100); 35 | yield* camera().zoomOnto(circle2(), 3, 10); 36 | }); 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Kai Sassnowski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/resetZoom.tsx: -------------------------------------------------------------------------------- 1 | import { CameraView } from "@ksassnowski/motion-canvas-camera; 2 | 3 | import { makeScene2D } from "@motion-canvas/2d"; 4 | import { Layout, Rect } from "@motion-canvas/2d/lib/components"; 5 | import { createRef } from "@motion-canvas/core/lib/utils"; 6 | 7 | export default makeScene2D(function* (view) { 8 | const camera = createRef(); 9 | const rect = createRef(); 10 | 11 | yield view.add( 12 | 13 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | , 30 | ); 31 | 32 | yield* camera().zoomOnto(rect(), 1.5, 25); 33 | yield* camera().resetZoom(); 34 | yield* camera().reset(); 35 | }); 36 | -------------------------------------------------------------------------------- /examples/rotate.tsx: -------------------------------------------------------------------------------- 1 | import { CameraView } from "@ksassnowski/motion-canvas-camera"; 2 | 3 | import { makeScene2D } from "@motion-canvas/2d"; 4 | import { Rect } from "@motion-canvas/2d/lib/components"; 5 | import { Vector2 } from "@motion-canvas/core/lib/types"; 6 | import { createRef } from "@motion-canvas/core/lib/utils"; 7 | 8 | export default makeScene2D(function* (view) { 9 | const camera = createRef(); 10 | const rect = createRef(); 11 | 12 | const rectStyles = { 13 | width: 200, 14 | height: 200, 15 | radius: 14, 16 | }; 17 | 18 | yield view.add( 19 | 20 | 21 | 22 | 23 | 24 | , 25 | ); 26 | 27 | yield* camera().zoomOnto(rect(), 1.5, 300); 28 | yield* camera().rotate(45); 29 | yield* camera().shift(Vector2.right.scale(200)); 30 | yield* camera().rotate(-20); 31 | yield* camera().rotate(90); 32 | yield* camera().resetZoom(); 33 | yield* camera().reset(); 34 | }); 35 | -------------------------------------------------------------------------------- /examples/zoom.tsx: -------------------------------------------------------------------------------- 1 | import { CameraView } from "@ksassnowski/motion-canvas-camera"; 2 | 3 | import { makeScene2D } from "@motion-canvas/2d"; 4 | import { Layout, Rect } from "@motion-canvas/2d/lib/components"; 5 | import { Vector2 } from "@motion-canvas/core/lib/types"; 6 | import { createRef } from "@motion-canvas/core/lib/utils"; 7 | 8 | export default makeScene2D(function* (view) { 9 | const camera = createRef(); 10 | 11 | yield view.add( 12 | 13 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | , 30 | ); 31 | 32 | yield* camera().shift(Vector2.left.scale(200)); 33 | yield* camera().zoom(2.5, 2); 34 | yield* camera().zoom(1); 35 | yield* camera().shift(Vector2.right.scale(400)); 36 | yield* camera().zoom(2.5, 2); 37 | yield* camera().reset(1); 38 | }); 39 | -------------------------------------------------------------------------------- /examples/trailer.tsx: -------------------------------------------------------------------------------- 1 | import { CameraView } from "@ksassnowski/motion-canvas-camera"; 2 | 3 | import { 4 | Circle, 5 | CircleProps, 6 | Layout, 7 | Line, 8 | LineProps, 9 | Node, 10 | Rect, 11 | Text, 12 | } from "@motion-canvas/2d/lib/components"; 13 | import { CodeBlock, edit } from "@motion-canvas/2d/lib/components/CodeBlock"; 14 | import { makeScene2D } from "@motion-canvas/2d/lib/scenes"; 15 | import { 16 | all, 17 | chain, 18 | delay, 19 | waitFor, 20 | waitUntil, 21 | } from "@motion-canvas/core/lib/flow"; 22 | import { createSignal } from "@motion-canvas/core/lib/signals"; 23 | import { 24 | easeInOutBack, 25 | easeOutBack, 26 | linear, 27 | } from "@motion-canvas/core/lib/tweening"; 28 | import { createRef, makeRef, range } from "@motion-canvas/core/lib/utils"; 29 | 30 | export default makeScene2D(function* (view) { 31 | const camera = createRef(); 32 | const layout = createRef(); 33 | const rect = createRef(); 34 | const circles: Circle[] = []; 35 | const line = createRef(); 36 | const code = createRef(); 37 | const disclaimer = createRef(); 38 | 39 | const circleProps: CircleProps[] = [ 40 | { position: [-400, 200], width: 400, height: 400, fill: "hotpink" }, 41 | { position: [400, -200], width: 120, height: 120, fill: "steelblue" }, 42 | { position: [-300, -300], width: 60, height: 60, fill: "forestgreen" }, 43 | { position: [150, 170], width: 40, height: 40, fill: "yellow" }, 44 | { position: [150, 450], width: 40, height: 40, fill: "coral" }, 45 | { position: [-190, 360], width: 40, height: 40, fill: "#bad455" }, 46 | { position: [440, 360], width: 40, height: 40, fill: "blanchedalmond" }, 47 | { position: [240, 340], width: 40, height: 40, fill: "blanchedalmond" }, 48 | ]; 49 | 50 | const cameraStyles = { 51 | clip: false, 52 | width: 1000, 53 | height: 720, 54 | layout: false, 55 | baseZoom: 0.6, 56 | }; 57 | 58 | const rectStyles = { 59 | width: 1000, 60 | height: 720, 61 | fill: "#101010", 62 | clip: true, 63 | radius: 12, 64 | }; 65 | 66 | const lineStyles: LineProps = { 67 | lineWidth: 16, 68 | lineDash: [28, 22], 69 | points: [ 70 | [320, -200], 71 | [100, 0], 72 | [100, 70], 73 | [200, 120], 74 | [400, 300], 75 | [380, 380], 76 | [0, 420], 77 | [-400, 200], 78 | ], 79 | }; 80 | 81 | const codeStyles = { 82 | marginTop: 20, 83 | fontSize: 48, 84 | lineHeight: 55, 85 | fontFamily: "Monogram", 86 | }; 87 | 88 | const circlesSpawned = createSignal(0); 89 | 90 | yield view.add( 91 | 100 | 109 | 110 | 111 | 112 | 113 | 114 | 116 | range(circlesSpawned()).map((i) => ( 117 | 118 | )) 119 | } 120 | /> 121 | 122 | 123 | , 124 | ); 125 | 126 | yield* rect().opacity(0, 0); 127 | yield* all( 128 | rect() 129 | .margin({ top: 300, left: 0, right: 0, bottom: 0 }, 0) 130 | .to(0, 1.2, easeInOutBack), 131 | rect().opacity(1, 1.2), 132 | ); 133 | 134 | yield* circlesSpawned(0, 0).to(circleProps.length - 1, 3); 135 | 136 | yield* code().opacity(1, 1); 137 | yield* waitUntil("zoom-onto"); 138 | 139 | yield* camera().zoomOnto(circles[0], 2, 100); 140 | 141 | yield* code().edit(1.8, false)`yield* camera().${edit( 142 | `zoomOnto( 143 | circles[0], 2, 100 144 | )`, 145 | "reset()", 146 | )}`; 147 | yield* waitUntil("reset"); 148 | yield* camera().reset(); 149 | 150 | yield* waitUntil("line-draw"); 151 | view.add( 152 | , 160 | ); 161 | yield* all( 162 | line().opacity(1, 0), 163 | line().end(0, 0).to(1, 4), 164 | disclaimer().opacity(0, 0), 165 | delay(1, disclaimer().opacity(1, 1.5)), 166 | ); 167 | yield* disclaimer().opacity(0, 1); 168 | disclaimer().remove(); 169 | yield* waitUntil("code-1"); 170 | 171 | yield* code().edit(1.8, false)`yield* camera().${edit( 172 | "reset()", 173 | `zoomOnto( 174 | [320, -200, 500, 500], 175 | 2 176 | )`, 177 | )}`; 178 | yield* waitUntil("zoom-onto-2"); 179 | 180 | yield* camera().zoomOnto([320, -200, 500, 500], 2); 181 | yield* waitUntil("code-2"); 182 | 183 | yield* code().edit(1.8, false)`yield* ${edit( 184 | `camera().zoomOnto( 185 | [320, -200, 500, 500], 186 | 2 187 | )`, 188 | `all( 189 | camera().followPath(line(), 5), 190 | camera().rotate(120, 5), 191 | chain( 192 | camera().zoom(5, 2, linear), 193 | camera().zoom(2.5, 3) 194 | ), 195 | )`, 196 | )}`; 197 | yield* waitUntil("path"); 198 | 199 | yield* all( 200 | camera().followPath(line(), 5), 201 | camera().rotate(120, 5), 202 | chain(camera().zoom(5, 2, linear), camera().zoom(2.5, 3)), 203 | ); 204 | 205 | yield* code().edit(1.8, false)`yield* ${edit( 206 | `all( 207 | camera().followPath(line(), 5), 208 | camera().rotate(120, 5), 209 | chain( 210 | camera().zoom(5, 2, linear), 211 | camera().zoom(2.5, 3) 212 | ), 213 | )`, 214 | `camera.reset(3, easeOutBack)`, 215 | )}`; 216 | yield* waitUntil("reset-2"); 217 | 218 | yield* camera().reset(3, easeOutBack); 219 | yield* waitFor(1); 220 | }); 221 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :movie_camera: Motion Canvas Camera 2 | 3 | > [!IMPORTANT] 4 | > Motion Canvas now has a [built-in camera component](https://motioncanvas.io/docs/camera) so this package is no longer necessary. It will probably continue to work but will not receive any further updates. 5 | 6 | https://user-images.githubusercontent.com/5139098/218100233-fa3bde50-122b-4e21-8ecb-0817ae5ed76a.mp4 7 | 8 | A camera component for [Motion Canvas](https://github.com/motion-canvas/motion-canvas) that allows you focus on elements, move the camera, follow paths and much more. 9 | 10 | ## Installation 11 | 12 | To install, run the following command inside your Motion Canvas project. 13 | 14 | ``` 15 | npm install --save @ksassnowski/motion-canvas-camera 16 | ``` 17 | 18 | Or, if you're using Yarn: 19 | 20 | ``` 21 | yarn add @ksassnowski/motion-canvas-camera 22 | ``` 23 | 24 | ## Basic Usage 25 | 26 | ```tsx 27 | import { CameraView } from "@ksassnowski/motion-canvas-camera"; 28 | 29 | import { makeScene2D } from "@motion-canvas/2d"; 30 | import { Circle, Line, Rect } from "@motion-canvas/2d/lib/components"; 31 | import { easeInOutSine } from "@motion-canvas/core/lib/tweening"; 32 | import { createRef } from "@motion-canvas/core/lib/utils"; 33 | 34 | export default makeScene2D(function* (view) { 35 | const camera = createRef(); 36 | const rect = createRef(); 37 | const circle = createRef(); 38 | const path = createRef(); 39 | 40 | view.add( 41 | 42 | 50 | , 51 | ); 52 | 53 | yield* camera().zoomOnto(rect(), 1.5, 200); 54 | 55 | // Make sure to add elements to the `camera`, not to the `view` 56 | // if you want them to be part of the camera's "field of view". 57 | camera().add( 58 | , 65 | ); 66 | camera().add( 67 | , 68 | ); 69 | 70 | yield* camera().rotate(35); 71 | yield* camera().followPath(path(), 4, easeInOutSine); 72 | yield* camera().reset(2); 73 | }); 74 | ``` 75 | 76 | **Result** 77 | 78 | https://user-images.githubusercontent.com/5139098/217892986-96c1ff6c-b846-4b03-9fa8-d3d63bd3fa3c.mp4 79 | 80 | Note that any node that isn't a child of the `CameraView` (either directly or transitively), will not be 81 | affected by the camera's transformation. 82 | 83 | > **Warning**
84 | > The camera updates its `position`, `scale` and `rotation` internally so you should **not** set or change these properties manually. If you want to position the camera in a different location of the screen, wrap it in a `Layout` node and position that node instead. 85 | 86 | ### Props 87 | 88 | ```ts 89 | interface CameraViewProps extends LayoutProps { 90 | /** 91 | * Sets the camera's default zoom level. When calling the 92 | * `reset` or `resetZoom` methods, the camera will reset 93 | * to this zoom level. 94 | */ 95 | baseZoom?: number; 96 | } 97 | ``` 98 | 99 | ### Method Reference 100 | 101 | #### `reset` 102 | 103 | Resets the camera's zoom, rotation and position back to the defaults. 104 | 105 | **Method signature** 106 | 107 | ```ts 108 | *reset( 109 | duration: number = 1, 110 | timing: TimingFunction = easeInOutCubic 111 | ): ThreadGenerator; 112 | ``` 113 | 114 | **Example** 115 | 116 | ```tsx 117 | export default makeScene2D(function* (view) { 118 | const camera = createRef(); 119 | const circle1 = createRef(); 120 | const circle2 = createRef(); 121 | 122 | view.add( 123 | 124 | 129 | 134 | , 135 | ); 136 | 137 | yield* camera().zoomOnto(circle1(), 2, 100); 138 | yield* camera().reset(1); 139 | yield* camera().zoomOnto(circle2(), 2, 100); 140 | yield* camera().reset(1); 141 | }); 142 | ``` 143 | 144 | https://user-images.githubusercontent.com/5139098/217865658-c08b2c38-700b-4849-943c-49c2b047bfb8.mp4 145 | 146 | #### `zoom` 147 | 148 | Zooms the camera in on the current position. 149 | 150 | **Method signature** 151 | 152 | ```ts 153 | *zoom( 154 | zoom: nummber, 155 | duration: number = 1, 156 | timing: TimingFunction = easeInOutCubic 157 | ): ThreadGenerator; 158 | ``` 159 | 160 | **Example** 161 | 162 | ```tsx 163 | export default makeScene2D(function* (view) { 164 | const camera = createRef(); 165 | 166 | yield view.add( 167 | 168 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | , 182 | ); 183 | 184 | yield* camera().shift(Vector2.left.scale(200)); 185 | yield* camera().zoom(2.5, 2); 186 | yield* camera().zoom(1); 187 | yield* camera().shift(Vector2.right.scale(400)); 188 | yield* camera().zoom(2.5, 2); 189 | yield* camera().reset(1); 190 | }); 191 | ``` 192 | 193 | https://user-images.githubusercontent.com/5139098/217865846-af1ce5ef-ad02-4947-8270-da1c04c5a771.mp4 194 | 195 | #### `zoomOnto` 196 | 197 | Zooms the camera onto the provided area or node until it fills the viewport. When providing 198 | a node, the node **must** be a child of the camera, although it doesn't have to be a direct child. 199 | Areas should be provided in local space of the camera. 200 | 201 | Can optionally apply `buffer` around the area and the viewport. 202 | 203 | **Method signature** 204 | 205 | ```ts 206 | *zoomOnto( 207 | area: Node | PossibleRect, 208 | duration: number = 1, 209 | buffer: number = 0, 210 | timing: TimingFunction = easeInOutCubic 211 | ): ThreadGenerator; 212 | ``` 213 | 214 | **Example** 215 | 216 | ```tsx 217 | export default makeScene2D(function* (view) { 218 | const camera = createRef(); 219 | const circle1 = createRef(); 220 | const circle2 = createRef(); 221 | 222 | view.add( 223 | 224 | 229 | 234 | , 235 | ); 236 | 237 | yield* camera().zoomOnto(circle1(), 3, 100); 238 | yield* camera().zoomOnto(circle2(), 3, 10); 239 | }); 240 | ``` 241 | 242 | https://user-images.githubusercontent.com/5139098/217832467-6c9c999a-d67e-42bd-8ed2-ad17bea8cc14.mp4 243 | 244 | #### `resetZoom` 245 | 246 | Resets the camera's zoom to `baseZoom` without changing the camera's position. 247 | 248 | **Method signature** 249 | 250 | ```ts 251 | *resetZoom( 252 | duration: number = 1, 253 | timing: TimingFunction = easeInOutCubic 254 | ): ThreadGenerator; 255 | ``` 256 | 257 | **Example** 258 | 259 | ```tsx 260 | export default makeScene2D(function* (view) { 261 | const camera = createRef(); 262 | const rect = createRef(); 263 | 264 | yield view.add( 265 | 266 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | , 280 | ); 281 | 282 | yield* camera().zoomOnto(rect(), 1.5, 25); 283 | yield* camera().resetZoom(); 284 | yield* camera().reset(); 285 | }); 286 | ``` 287 | 288 | https://user-images.githubusercontent.com/5139098/217866036-3e677a3f-9738-4ceb-a50b-de9d3123bb25.mp4 289 | 290 | #### `rotate` 291 | 292 | Rotates the camera around its current position. The angle is provided in degrees. 293 | 294 | **Method Signature** 295 | 296 | ```ts 297 | *rotate( 298 | angle: number, 299 | duration: number = 1, 300 | timing: TimingFunction = easeInOutCubic 301 | ): ThreadGenerator; 302 | ``` 303 | 304 | **Example** 305 | 306 | ```tsx 307 | export default makeScene2D(function* (view) { 308 | const camera = createRef(); 309 | const rect = createRef(); 310 | 311 | yield view.add( 312 | 313 | 314 | 315 | 316 | 317 | , 318 | ); 319 | 320 | yield* camera().zoomOnto(rect(), 1.5, 300); 321 | yield* camera().rotate(45); 322 | yield* camera().shift(Vector2.right.scale(200)); 323 | yield* camera().rotate(-20); 324 | yield* camera().rotate(90); 325 | yield* camera().resetZoom(); 326 | yield* camera().reset(); 327 | }); 328 | ``` 329 | 330 | https://user-images.githubusercontent.com/5139098/217883813-bbe1595e-501a-4b36-8dee-12f1cdeda57b.mp4 331 | 332 | #### `resetRotation` 333 | 334 | Resets the camera's rotation without changing it's scale or position. 335 | 336 | **Method Signature** 337 | 338 | ```ts 339 | *resetRotation( 340 | duration: number = 1, 341 | timing: TimingFunction = easeInOutCubic 342 | ): ThreadGenerator; 343 | ``` 344 | 345 | **Example** 346 | 347 | ```tsx 348 | export default makeScene2D(function* (view) { 349 | const camera = createRef(); 350 | 351 | yield view.add( 352 | 353 | 354 | 355 | 356 | 357 | , 358 | ); 359 | 360 | yield* camera().zoom(1.5); 361 | yield* camera().shift(new Vector2(200, -100)); 362 | yield* camera().resetZoom(); 363 | }); 364 | ``` 365 | 366 | https://user-images.githubusercontent.com/5139098/218136179-81a3b3af-0a09-443b-8dea-12c1cb84931c.mp4 367 | 368 | #### `shift` 369 | 370 | Shifts the camera's position by the provided vector. 371 | 372 | **Method Signature** 373 | 374 | ```ts 375 | *shift( 376 | by: Vector2, 377 | duration: number = 1, 378 | timing: TimingFunction = easeInOutCubic 379 | ): ThreadGenerator; 380 | ``` 381 | 382 | **Example** 383 | 384 | ```tsx 385 | export default makeScene2D(function* (view) { 386 | const camera = createRef(); 387 | 388 | yield view.add( 389 | 390 | 391 | 392 | 393 | 394 | , 395 | ); 396 | 397 | yield* camera().rotate(46); 398 | yield* camera().rotate(-10); 399 | yield* camera().resetRotation(); 400 | }); 401 | ``` 402 | 403 | https://user-images.githubusercontent.com/5139098/218135450-dc6d0559-b239-4416-bc33-f6169beee5be.mp4 404 | 405 | #### `centerOn` 406 | 407 | Centers the camera viewport on the provided point, area or node without changing 408 | it's rotation or zoom. 409 | 410 | **Method Signature** 411 | 412 | ```ts 413 | *centerOn( 414 | area: Vector2 | PossibleRect | Node, 415 | duration: number = 1, 416 | timing: TimingFunction = easeInOutCubic, 417 | ): ThreadGenerator; 418 | ``` 419 | 420 | **Example** 421 | 422 | _coming soon_ 423 | 424 | #### `moveBetween` 425 | 426 | Moves the camera the provided nodes, one after the other. 427 | 428 | **Method Signature** 429 | 430 | ```ts 431 | *moveBetween( 432 | nodes: Node[], 433 | duration: number, 434 | /** 435 | * If provided, this callback will get called before each 436 | * move starts. 437 | * 438 | * @param next - The animations for the next move. When providing this callback, 439 | * you should yield these animations for the next move to start. Having 440 | * access to these animations allows you to compose them together with 441 | * other animations you might want to apply during a specific move. 442 | * @param node - The next node the camera will move to. 443 | * 444 | */ 445 | onBeforeMove?: (next: ThreadGenerator, target: Node) => ThreadGenerator, 446 | timing?: TimingFunction = easeInOutCubic, 447 | ): ThreadGenerator; 448 | ``` 449 | 450 | **Example** 451 | 452 | _coming soon_ 453 | 454 | #### `followPath` 455 | 456 | Moves the camera along the provided path. 457 | 458 | **Method Signature** 459 | 460 | ```ts 461 | *followPath( 462 | path: Line, 463 | duration: number = 1, 464 | timing: TimingFunction = easeInOutCubic, 465 | ): ThreadGenerator; 466 | ``` 467 | 468 | **Example** 469 | 470 | _coming soon_ 471 | -------------------------------------------------------------------------------- /src/CameraView.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Layout, 3 | LayoutProps, 4 | Line, 5 | Node, 6 | NodeState, 7 | } from "@motion-canvas/2d/lib/components"; 8 | import { 9 | computed, 10 | initial, 11 | signal, 12 | vector2Signal, 13 | } from "@motion-canvas/2d/lib/decorators"; 14 | import { all, waitFor } from "@motion-canvas/core/lib/flow"; 15 | import { SignalValue, SimpleSignal } from "@motion-canvas/core/lib/signals"; 16 | import { ThreadGenerator } from "@motion-canvas/core/lib/threading"; 17 | import { 18 | TimingFunction, 19 | easeInOutCubic, 20 | } from "@motion-canvas/core/lib/tweening"; 21 | import { 22 | BBox, 23 | PossibleBBox, 24 | PossibleVector2, 25 | Vector2, 26 | Vector2Signal, 27 | } from "@motion-canvas/core/lib/types"; 28 | 29 | import { getFromCycled, wrapArray } from "./utils"; 30 | 31 | export interface TravelOptions { 32 | /** 33 | * How long each movement transition takes. 34 | * 35 | * If a single value is provided, the same duration will get used for all moves. 36 | * 37 | * If an array is provided, the duration that corresponds to the index of the 38 | * current move will get used. For example, if the camera is moving from the first 39 | * node to the second node, the index will be `0` and so the first duration from 40 | * the array gets used. Note that this array gets cycled, meaning that if you 41 | * provide fewer durations than there are transitions, the array will wrap around 42 | * to the beginning. 43 | */ 44 | duration: number | number[]; 45 | 46 | /** 47 | * Whether the camera should zoom onto the target node. 48 | * 49 | * If a single value is provided, the same setting will get used for all moves. 50 | * 51 | * If an array is provided, the entry that corresponds to the index of the current 52 | * move will get used. For example, if the camera is moving from the first node to 53 | * the second node, the index will be `0` and so the first entry from the array 54 | * gets used. Note that this array gets cycled, meaning that if you provide fewer 55 | * entries than there are transitions, the array will wrap around to the beginning. 56 | */ 57 | zoom: boolean | boolean[]; 58 | 59 | /** 60 | * How long to wait before starting each move. 61 | * 62 | * If a single value is provided, the same delay will get used for all moves. 63 | * 64 | * If an array is provided, the entry that corresponds to the index of the current 65 | * move will get used. For example, if the camera is moving from the first node to 66 | * the second node, the index will be `0` and so the first entry from the array 67 | * gets used. Note that this array gets cycled, meaning that if you provide fewer 68 | * entries than there are transitions, the array will wrap around to the beginning. 69 | */ 70 | buffer: number | number[]; 71 | 72 | /** 73 | * How long to wait before starting each move. 74 | * 75 | * If a single value is provided, the same delay will get used for all moves. 76 | * 77 | * If an array is provided, the entry that corresponds to the index of the current 78 | * move will get used. For example, if the camera is moving from the first node to 79 | * the second node, the index will be `0` and so the first entry from the array 80 | * gets used. Note that this array gets cycled, meaning that if you provide fewer 81 | * entries than there are transitions, the array will wrap around to the beginning. 82 | */ 83 | wait: number | number[]; 84 | 85 | /** 86 | * The timing function used for the transitions of the move. 87 | * 88 | * If a single value is provided, the same timing function will get applied 89 | * for all moves. 90 | * 91 | * If an array is provided, the entry that corresponds to the index of the current 92 | * move will get used. For example, if the camera is moving from the first node to 93 | * the second node, the index will be `0` and so the first entry from the array 94 | * gets used. Note that this array gets cycled, meaning that if you provide fewer 95 | * entries than there are transitions, the array will wrap around to the beginning. 96 | */ 97 | timing: TimingFunction | TimingFunction[]; 98 | 99 | /** 100 | * Hook that gets called every time before the camera moves on to the next node. 101 | * 102 | * @param next - The animations for moving the camera to the next node. 103 | * These animations should be part of the resulting ThreadGenerator 104 | * that this callback returns. Otherwise, the camera won't continue 105 | * on to the next node. The advantage is that you can compose the 106 | * movement animations with any other animations that should get 107 | * applied during the next move as well 108 | * @param target - The next node the camera will move to 109 | */ 110 | onBeforeMove: (next: ThreadGenerator, target: Node) => ThreadGenerator; 111 | } 112 | 113 | export interface CameraViewProps 114 | extends Omit { 115 | baseZoom?: SignalValue; 116 | scene?: SignalValue; 117 | translation?: SignalValue; 118 | } 119 | 120 | export class CameraView extends Layout { 121 | @initial(1) 122 | @signal() 123 | public declare readonly baseZoom: SimpleSignal; 124 | 125 | @vector2Signal("translation") 126 | public declare readonly translation: Vector2Signal; 127 | 128 | @initial(null) 129 | @signal() 130 | public declare readonly scene: SimpleSignal; 131 | 132 | public constructor(props: CameraViewProps) { 133 | super({ 134 | clip: true, 135 | ...props, 136 | scale: props.baseZoom ?? 1, 137 | position: () => this.actualPosition(), 138 | }); 139 | } 140 | 141 | @computed() 142 | private rotationMatrix(): DOMMatrix { 143 | const matrix = new DOMMatrix(); 144 | matrix.rotateSelf(0, 0, this.rotation()); 145 | return matrix; 146 | } 147 | 148 | @computed() 149 | private actualPosition() { 150 | return this.translation() 151 | .mul(this.scale()) 152 | .transformAsPoint(this.rotationMatrix()); 153 | } 154 | 155 | /** 156 | * Resets the camera's viewport to its original position, scale and rotation. 157 | * 158 | * @param duration The duration of the transition 159 | * @param timing The timing function to use for the transition 160 | */ 161 | public *reset( 162 | duration: number = 1, 163 | timing: TimingFunction = easeInOutCubic, 164 | ): ThreadGenerator { 165 | yield* all( 166 | this.scale(this.baseZoom(), duration, timing), 167 | this.translation(Vector2.zero, duration, timing), 168 | this.rotation(0, duration, timing), 169 | ); 170 | } 171 | 172 | /** 173 | * Zooms the camera onto the current position. 174 | * 175 | * @param zoom The zoom level that should get applied as a percentage of the base zoom level. 176 | * 1 means no zoom. 177 | * @param duration The duration of the transition 178 | * @param timing The timing function used for the transition 179 | */ 180 | public *zoom( 181 | zoom: number, 182 | duration: number = 1, 183 | timing: TimingFunction = easeInOutCubic, 184 | ): ThreadGenerator { 185 | yield* this.scale(zoom * this.baseZoom(), duration, timing); 186 | } 187 | 188 | /** 189 | * Resets the camera's zoom without changing it's position. 190 | * 191 | * @param duration - The duration of the transition 192 | * @param timing - The timing function to use for the transition 193 | */ 194 | public *resetZoom( 195 | duration: number = 1, 196 | timing: TimingFunction = easeInOutCubic, 197 | ): ThreadGenerator { 198 | yield* this.scale(this.baseZoom(), duration, timing); 199 | } 200 | 201 | /** 202 | * Rotates the camera around its current position. 203 | * 204 | * @param angle - The rotation to apply in degrees 205 | * @param duration - The duration of the transition 206 | * @param timing - The timing function to use for the transition 207 | */ 208 | public *rotate( 209 | angle: number, 210 | duration: number = 1, 211 | timing: TimingFunction = easeInOutCubic, 212 | ): ThreadGenerator { 213 | yield* this.rotation(this.rotation() + angle, duration, timing); 214 | } 215 | 216 | /** 217 | * Resets the camera's rotation. 218 | * 219 | * @param duration - The duration of the transition 220 | * @param timing - The timing function to use for the transition 221 | */ 222 | public *resetRotation( 223 | duration: number = 1, 224 | timing: TimingFunction = easeInOutCubic, 225 | ): ThreadGenerator { 226 | yield* this.rotation(0, duration, timing); 227 | } 228 | 229 | /** 230 | * Shifts the camera into the provided direction. 231 | * 232 | * @param by - The amount and direction in which to shift the camera 233 | * @param duration - The duration of the transition 234 | * @param timing - The timing function used for the transition 235 | */ 236 | public *shift( 237 | by: Vector2, 238 | duration: number = 1, 239 | timing: TimingFunction = easeInOutCubic, 240 | ): ThreadGenerator { 241 | yield* this.translation(this.translation().sub(by), duration, timing); 242 | } 243 | 244 | /** 245 | * Zooms the view onto the provided area until it fills out the viewport. 246 | * Can optionally apply a buffer around the node. 247 | * 248 | * @param area - The area on which to zoom onto. The position of the area needs to 249 | * be in local space. 250 | * @param duration - The duration of the transition 251 | * @param buffer - The buffer to apply around the node and the viewport edges 252 | * @param timing - The timing function to use for the transition 253 | */ 254 | public zoomOnto( 255 | area: PossibleBBox, 256 | duration?: number, 257 | buffer?: number, 258 | timing?: TimingFunction, 259 | ): ThreadGenerator; 260 | 261 | /** 262 | * Zooms the view onto the provided node until it fills out the viewport. 263 | * Can optionally apply a buffer around the node. 264 | * 265 | * @param node - The node to zoom onto. The node needs to either be a direct or 266 | * transitive child of the camera node. 267 | * @param duration - The duration of the transition 268 | * @param buffer - The buffer to apply around the node and the viewport edges 269 | * @param timing - The timing function to use for the transition 270 | */ 271 | public zoomOnto( 272 | node: Node, 273 | duration?: number, 274 | buffer?: number, 275 | timing?: TimingFunction, 276 | ): ThreadGenerator; 277 | 278 | public *zoomOnto( 279 | area: PossibleBBox | Node, 280 | duration: number = 1, 281 | buffer: number = 0, 282 | timing: TimingFunction = easeInOutCubic, 283 | ): ThreadGenerator { 284 | const rect = this.getRectFromInput(area); 285 | const scale = this.size().div(this.fitRectAroundArea(rect, buffer)); 286 | 287 | yield* all( 288 | this.scale(scale, duration, timing), 289 | this.translation(rect.position.flipped, duration, timing), 290 | ); 291 | } 292 | 293 | /** 294 | * Centers the camera view on the provided node without changing the zoom level. 295 | * 296 | * @param node - The node to center on. The node needs to either be a direct or 297 | * transitive child of the camera node. 298 | * @param duration - The duration of the transition 299 | * @param timing - The timing function to use for the transition 300 | */ 301 | public centerOn( 302 | node: Node, 303 | duration?: number, 304 | timing?: TimingFunction, 305 | ): ThreadGenerator; 306 | 307 | /** 308 | * Centers the camera view on the provided area without changing the zoom level. 309 | * 310 | * @param area - The area to center on. The position of the area should be in local space. 311 | * @param duration - The duration of the transition 312 | * @param timing - The timing function to use for the transition 313 | */ 314 | public centerOn( 315 | area: PossibleBBox, 316 | duration?: number, 317 | timing?: TimingFunction, 318 | ): ThreadGenerator; 319 | public *centerOn( 320 | area: Vector2 | PossibleBBox | Node, 321 | duration: number = 1, 322 | timing: TimingFunction = easeInOutCubic, 323 | ): ThreadGenerator { 324 | yield* this.translation( 325 | this.getRectFromInput(area).position.flipped, 326 | duration, 327 | timing, 328 | ); 329 | } 330 | 331 | /** 332 | * Move the camera between the provided nodes, one after the other. 333 | * 334 | * @param nodes - The nodes to move between 335 | * @param options - The options describing each itinerary of the transitions. 336 | * See @see{TravelOptions} for move information about what 337 | * each of the options does. 338 | */ 339 | public moveBetween( 340 | nodes: Node[], 341 | options: Partial, 342 | ): ThreadGenerator; 343 | 344 | /** 345 | * Move the camera between the provided nodes, one after the other. 346 | * 347 | * @param nodes - The nodes to move between 348 | * @param duration - The duration of the transition 349 | * @param onBeforeMove - Callback that gets called before starting each itinerary 350 | * @param timing - The timing function used for the transition 351 | */ 352 | public moveBetween( 353 | nodes: Node[], 354 | duration: number, 355 | onBeforeMove?: (next: ThreadGenerator, target: Node) => ThreadGenerator, 356 | timing?: TimingFunction, 357 | ): ThreadGenerator; 358 | 359 | public *moveBetween( 360 | nodes: Node[], 361 | options: number | Partial = 1, 362 | onBeforeMove?: (next: ThreadGenerator, target: Node) => ThreadGenerator, 363 | timing: TimingFunction = easeInOutCubic, 364 | ): ThreadGenerator { 365 | let defaults: TravelOptions = { 366 | duration: 1, 367 | buffer: 0, 368 | wait: 0.25, 369 | zoom: false, 370 | timing, 371 | onBeforeMove: 372 | onBeforeMove ?? 373 | function* (next) { 374 | yield* next; 375 | }, 376 | }; 377 | 378 | if (typeof options === "number") { 379 | defaults.duration = options; 380 | } else { 381 | defaults = Object.assign({}, defaults, options); 382 | } 383 | 384 | defaults.duration = wrapArray(defaults.duration); 385 | defaults.buffer = wrapArray(defaults.buffer); 386 | defaults.wait = wrapArray(defaults.wait); 387 | defaults.zoom = wrapArray(defaults.zoom); 388 | defaults.timing = wrapArray(defaults.timing); 389 | 390 | for (let i = 0; i < nodes.length; i++) { 391 | const zoom = getFromCycled(defaults.zoom, i); 392 | const node = getFromCycled(nodes, i); 393 | const duration = getFromCycled(defaults.duration, i); 394 | const wait = getFromCycled(defaults.wait, i); 395 | const timing = getFromCycled(defaults.timing, i); 396 | 397 | let transition: ThreadGenerator; 398 | 399 | if (zoom) { 400 | transition = this.zoomOnto(node, duration, defaults.buffer[i], timing); 401 | } else { 402 | transition = all( 403 | this.resetZoom(duration, timing), 404 | this.centerOn(node, duration, timing), 405 | ); 406 | } 407 | 408 | yield* defaults.onBeforeMove(transition, node); 409 | yield* waitFor(wait); 410 | } 411 | } 412 | 413 | /** 414 | * Make the camera follow the provided path. 415 | * 416 | * @param path - The path to follow 417 | * @param duration - The duration of the transition 418 | * @param timing - The timing function used for the transition 419 | */ 420 | public *followPath( 421 | path: Line, 422 | duration: number = 1, 423 | timing: TimingFunction = easeInOutCubic, 424 | ): ThreadGenerator { 425 | const transformPoint = (point: Vector2): Vector2 => 426 | point 427 | .transformAsPoint(path.localToWorld()) 428 | .transformAsPoint(this.worldToLocal()).flipped; 429 | 430 | const destination = transformPoint(path.getPointAtPercentage(1).position); 431 | 432 | yield* this.translation(destination, duration, timing, (from, to, value) => 433 | transformPoint(path.getPointAtPercentage(value).position), 434 | ); 435 | } 436 | 437 | private getRectFromInput(area: Node | PossibleBBox): BBox { 438 | if (area instanceof Node) { 439 | return new BBox( 440 | area.absolutePosition().transformAsPoint(this.worldToLocal()), 441 | area.cacheBBox().size, 442 | ); 443 | } 444 | 445 | return new BBox(area); 446 | } 447 | 448 | /** 449 | * Calculates the size of a rectangle that encloses the provided area 450 | * while maintaining the same aspect ratio as the camera view. 451 | * 452 | * @param area - The node that the rectangle should be fitted around 453 | * @param buffer - Buffer to apply around the node and the edges of the rectangle 454 | */ 455 | private fitRectAroundArea(area: BBox, buffer: number): Vector2 { 456 | const aspectRatio = this.size().height / this.size().width; 457 | const areaAspectRatio = area.height / area.width; 458 | 459 | let size: Vector2; 460 | 461 | if (areaAspectRatio === aspectRatio) { 462 | size = area.size; 463 | } else if (areaAspectRatio > aspectRatio) { 464 | size = new Vector2(area.height / aspectRatio, area.height); 465 | } else { 466 | size = new Vector2(area.width, area.width * aspectRatio); 467 | } 468 | 469 | const xScaleFactor = (size.x + buffer) / size.x; 470 | const yScaleFactor = (size.y + buffer) / size.y; 471 | const scaleFactor = Math.max(xScaleFactor, yScaleFactor); 472 | 473 | return size.scale(scaleFactor); 474 | } 475 | 476 | public override getState(): NodeState { 477 | return { 478 | translation: this.translation(), 479 | rotation: this.rotation(), 480 | scale: this.scale(), 481 | }; 482 | } 483 | 484 | public override hit(position: Vector2): Node | null { 485 | return this.scene()?.hit(position) ?? super.hit(position); 486 | } 487 | 488 | protected override draw(context: CanvasRenderingContext2D): void { 489 | super.draw(context); 490 | this.scene()?.render(context); 491 | } 492 | } 493 | --------------------------------------------------------------------------------