├── src
├── global.d.ts
├── react-app-env.d.ts
├── index.css
├── animations
│ ├── shaders
│ │ ├── minimalShader.glsl
│ │ ├── dither.glsl
│ │ ├── noiseDodecahedron.glsl
│ │ └── anaglyph.glsl
│ ├── minimalShader.tsx
│ ├── dither.tsx
│ ├── minimal2D.tsx
│ ├── noiseDodecahedron.tsx
│ ├── anaglyph.tsx
│ ├── hypocycloids.tsx
│ └── piArcs.tsx
├── index.tsx
└── lib
│ ├── graphics.ts
│ ├── utils.ts
│ └── Animation.tsx
├── .eslintrc.json
├── public
├── images
│ ├── cat.jpg
│ └── butterfly-text.png
├── index.html
└── CCapture.all.min.js
├── .vscode
├── launch.json
├── settings.json
└── extensions.json
├── dev-guide.md
├── tailwind.config.js
├── config-overrides.js
├── scripts
├── templates
│ ├── shader.glsl
│ ├── shader-animation.tsx
│ └── 2d-animation.tsx
└── new-animation.js
├── .prettierrc
├── tsconfig.json
├── LICENSE
├── package.json
├── .github
└── workflows
│ └── deploy-pages.yaml
├── .gitignore
├── CLAUDE.md
└── README.md
/src/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.glsl' {}
2 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "react-hooks/exhaustive-deps": "off"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/public/images/cat.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/matthen/animations/HEAD/public/images/cat.jpg
--------------------------------------------------------------------------------
/public/images/butterfly-text.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/matthen/animations/HEAD/public/images/butterfly-text.png
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | color-scheme: dark;
7 | }
8 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "configurations": [
3 | {
4 | "command": "pnpm run start",
5 | "name": "launch",
6 | "request": "launch",
7 | "type": "node-terminal",
8 | "cwd": "${workspaceRoot}"
9 | }
10 | ]
11 | }
--------------------------------------------------------------------------------
/dev-guide.md:
--------------------------------------------------------------------------------
1 | # Setup
2 |
3 | Install npm then pnpm:
4 |
5 | ```bash
6 | brew install npm
7 | npm install -g pnpm
8 | ```
9 |
10 | # Install dependencies
11 |
12 | ```bash
13 | pnpm install
14 | ```
15 |
16 | # Run the development server
17 |
18 | ```bash
19 | pnpm start
20 | ```
21 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["./src/**/*.{js,jsx,ts,tsx}"],
4 | theme: {
5 | extend: {},
6 | },
7 |
8 | plugins: [
9 | // https://draculatheme.com/tailwind
10 | require("tailwind-dracula")(),
11 | ],
12 | };
13 |
--------------------------------------------------------------------------------
/src/animations/shaders/minimalShader.glsl:
--------------------------------------------------------------------------------
1 | uniform vec2 u_resolution;
2 | uniform float u_t;
3 | uniform float u_speed;
4 |
5 | void main() {
6 | vec2 pixel = 1.0 / u_resolution.xy;
7 | vec2 uv = gl_FragCoord.xy * pixel;
8 | float t = u_t * u_speed;
9 | vec3 color = 0.5 + 0.5 * cos(t + uv.xyx + vec3(0, 2, 4));
10 | gl_FragColor = vec4(color, 1.0);
11 | }
12 |
--------------------------------------------------------------------------------
/config-overrides.js:
--------------------------------------------------------------------------------
1 | // Rule to import glsl contents as strings
2 | module.exports = function override(config, env) {
3 | const newRule = {
4 | test: /\.glsl$/i,
5 | loader: 'raw-loader',
6 | options: {
7 | esModule: false,
8 | },
9 | };
10 | config.module.rules.find((r) => r.oneOf).oneOf.unshift(newRule);
11 | return config;
12 | };
13 |
--------------------------------------------------------------------------------
/scripts/templates/shader.glsl:
--------------------------------------------------------------------------------
1 | uniform vec2 u_resolution;
2 | uniform float u_t;
3 | {{UNIFORM_DECLARATIONS}}
4 |
5 | void main() {
6 | vec2 pixel = 1.0 / u_resolution.xy;
7 | vec2 uv = gl_FragCoord.xy * pixel;
8 |
9 | // TODO: Implement your shader logic here
10 | // Available uniforms: u_t (time){{UNIFORM_COMMENTS}}
11 |
12 | vec3 color = 0.5 + 0.5 * cos(u_t + uv.xyx + vec3(0, 2, 4));
13 | gl_FragColor = vec4(color, 1.0);
14 | }
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 4,
3 | "useTabs": false,
4 | "trailingComma": "all",
5 | "singleQuote": true,
6 | "semi": true,
7 | "printWidth": 120,
8 | "plugins": [
9 | "@trivago/prettier-plugin-sort-imports",
10 | "prettier-plugin-tailwindcss"
11 | ],
12 | "importOrder": [
13 | "^@",
14 | "^lib",
15 | "^[./]"
16 | ],
17 | "importOrderSeparation": true,
18 | "importOrderSortSpecifiers": true
19 | }
20 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | animations
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "vscode-color-picker.languages": [
3 | "python",
4 | "javascript",
5 | "typescript",
6 | "typescriptreact",
7 | ],
8 | "webgl-glsl-editor.format.placeSpaceAfterKeywords": true,
9 | "[typescript]": {
10 | "editor.defaultFormatter": "esbenp.prettier-vscode",
11 | "editor.formatOnSave": true
12 | },
13 | "[typescriptreact]": {
14 | "editor.defaultFormatter": "esbenp.prettier-vscode",
15 | "editor.formatOnSave": true
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
4 | // List of extensions which should be recommended for users of this workspace.
5 | "recommendations": [
6 | "esbenp.prettier-vscode",
7 | "bradlc.vscode-tailwindcss",
8 | "MarkosTh09.color-picker",
9 | "raczzalan.webgl-glsl-editor"
10 | ],
11 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace.
12 | "unwantedRecommendations": []
13 | }
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx",
22 | "baseUrl": "src"
23 | },
24 | "include": [
25 | "src"
26 | ]
27 | }
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Matthew Henderson
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 |
--------------------------------------------------------------------------------
/src/animations/shaders/dither.glsl:
--------------------------------------------------------------------------------
1 | uniform vec2 u_resolution;
2 | uniform sampler2D u_tex0;
3 | uniform float u_tt;
4 |
5 | float hash13(vec2 uv, float t) {
6 | vec3 p3 = vec3(uv.x, uv.y, t);
7 | p3 = fract(p3 * .1031);
8 | p3 += dot(p3, p3.zyx + 31.32);
9 | return fract((p3.x + p3.y) * p3.z);
10 | }
11 |
12 | void main() {
13 | vec2 pixel = 1.0 / u_resolution.xy;
14 | vec2 uv = gl_FragCoord.xy * pixel;
15 | float res = 256.0;
16 | uv = floor(uv * res) / res;
17 |
18 | uv *= 2.0;
19 |
20 | // shift the top
21 | uv.x -= 0.5 * floor(uv.y);
22 |
23 | float color = texture(u_tex0, uv).r;
24 |
25 | // add noise on right
26 | float tres = 0.01;
27 | float t = floor(u_tt / tres) * tres;
28 | float noise = hash13(vec2(uv.x, mod(uv.y, 1.0)) * u_resolution.xy, t);
29 | color = mix(color, color + (noise - 0.5), step(1., uv.x));
30 | // binarize on bottom
31 | color = mix(color, step(0.5, color), 1. - step(1., uv.y));
32 |
33 | // mask out on the top
34 | color = mix(color, 0., step(1.0, uv.y) * step(1.0, uv.x));
35 | color = mix(color, 0., step(1.0, uv.y) * step(uv.x, 0.));
36 |
37 | gl_FragColor = vec4(vec3(color), 1.0);
38 |
39 | }
40 |
--------------------------------------------------------------------------------
/scripts/templates/shader-animation.tsx:
--------------------------------------------------------------------------------
1 | // @ts-ignore
2 | import { GlslPipeline } from 'glsl-pipeline';
3 | import { WebGLRenderer } from 'three';
4 |
5 | import { Animation, DrawArgs, MakeDrawFn, ParameterConfig } from 'lib/Animation';
6 | import Utils from 'lib/utils';
7 |
8 | import shader from './shaders/{{CAMEL_NAME}}.glsl';
9 |
10 | const {{PASCAL_NAME}} = () => {
11 | const duration = 6;
12 | const canvasWidth = 768;
13 | const canvasHeight = 768;
14 |
15 | const parameters = {
16 | {{PARAMETER_DEFINITIONS}}
17 | } as const satisfies ParameterConfig;
18 |
19 | const makeDrawFn: MakeDrawFn = (canvas) => {
20 | const renderer = new WebGLRenderer({
21 | canvas,
22 | });
23 | let pipeline = new GlslPipeline(renderer, {{UNIFORMS_OBJECT}});
24 | pipeline.load(shader);
25 | pipeline.renderMain();
26 |
27 | const drawFn = ({{DRAW_ARGS_TYPE}}: DrawArgs) => {
28 | if (t == 0) {
29 | Utils.resetGlslPipeline(pipeline);
30 | }
31 | pipeline.uniforms.u_t.value = t;
32 | {{UNIFORM_ASSIGNMENTS}}
33 | pipeline.renderMain();
34 | };
35 |
36 | return drawFn;
37 | };
38 |
39 | return (
40 |
47 | );
48 | };
49 |
50 | export default {{PASCAL_NAME}};
--------------------------------------------------------------------------------
/src/animations/minimalShader.tsx:
--------------------------------------------------------------------------------
1 | // @ts-ignore
2 | import { GlslPipeline } from 'glsl-pipeline';
3 | import { WebGLRenderer } from 'three';
4 |
5 | import { Animation, DrawArgs, MakeDrawFn, ParameterConfig } from 'lib/Animation';
6 | import Utils from 'lib/utils';
7 |
8 | import shader from './shaders/minimalShader.glsl';
9 |
10 | const MinimalShader = () => {
11 | const duration = 2;
12 | const canvasWidth = 768;
13 | const canvasHeight = 768;
14 |
15 | const parameters = { speed: { min: 0, max: 10, default: 1 } } as const satisfies ParameterConfig;
16 |
17 | const makeDrawFn: MakeDrawFn = (canvas) => {
18 | const renderer = new WebGLRenderer({
19 | canvas,
20 | });
21 | let pipeline = new GlslPipeline(renderer, { u_t: { value: 0 }, u_speed: { value: 0 } });
22 | pipeline.load(shader);
23 | pipeline.renderMain();
24 |
25 | const drawFn = ({ t, speed }: DrawArgs) => {
26 | if (t == 0) {
27 | Utils.resetGlslPipeline(pipeline);
28 | }
29 | pipeline.uniforms.u_t.value = t;
30 | pipeline.uniforms.u_speed.value = speed;
31 | pipeline.renderMain();
32 | };
33 |
34 | return drawFn;
35 | };
36 |
37 | return (
38 |
45 | );
46 | };
47 |
48 | export default MinimalShader;
49 |
--------------------------------------------------------------------------------
/scripts/templates/2d-animation.tsx:
--------------------------------------------------------------------------------
1 | import { Animation, DrawArgs, MakeDrawFn, ParameterConfig } from 'lib/Animation';
2 | import Graphics from 'lib/graphics';
3 | import Utils from 'lib/utils';
4 |
5 | const {{PASCAL_NAME}} = () => {
6 | const duration = 6;
7 | const canvasWidth = 768;
8 | const canvasHeight = 768;
9 |
10 | const parameters = {
11 | {{PARAMETER_DEFINITIONS}}
12 | } as const satisfies ParameterConfig;
13 |
14 | const makeDrawFn: MakeDrawFn = (canvas) => {
15 | const ctx = canvas.getContext('2d')!;
16 |
17 | const drawFn = ({{DRAW_ARGS_TYPE}}: DrawArgs) => {
18 |
19 | Graphics.draw(
20 | [
21 | Graphics.AbsoluteLineWidth(2),
22 | Graphics.Set({ strokeStyle: '#ffffff', fillStyle: '#005f5f' }),
23 | // TODO: Add your drawing commands here
24 | // Available parameters: {{PARAMETER_COMMENTS}}
25 | ],
26 | {
27 | xmin: -1.1,
28 | xmax: 1.1,
29 | ymin: -1.1,
30 | ymax: 1.1,
31 | backgroundColor: '#020115',
32 | },
33 | ctx,
34 | );
35 | };
36 |
37 | return drawFn;
38 | };
39 |
40 | return (
41 |
48 | );
49 | };
50 |
51 | export default {{PASCAL_NAME}};
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "animations",
3 | "homepage": "/animations",
4 | "version": "0.1.0",
5 | "private": true,
6 | "dependencies": {
7 | "@testing-library/jest-dom": "^5.17.0",
8 | "@testing-library/react": "^13.4.0",
9 | "@testing-library/user-event": "^13.5.0",
10 | "@types/jest": "^27.5.2",
11 | "@types/node": "^16.18.59",
12 | "@types/react": "^18.2.30",
13 | "@types/react-dom": "^18.2.14",
14 | "chroma-js": "^2.4.2",
15 | "glsl-pipeline": "^1.0.6",
16 | "react": "^18.2.0",
17 | "react-dom": "^18.2.0",
18 | "react-icons": "^4.11.0",
19 | "react-router-dom": "^6.17.0",
20 | "react-scripts": "5.0.1",
21 | "three": "^0.158.0",
22 | "typescript": "^4.9.5",
23 | "use-debounce": "^9.0.4",
24 | "web-vitals": "^2.1.4"
25 | },
26 | "scripts": {
27 | "start": "react-app-rewired start",
28 | "build": "react-app-rewired build",
29 | "new-animation": "node scripts/new-animation.js"
30 | },
31 | "eslintConfig": {
32 | "extends": [
33 | "react-app",
34 | "react-app/jest"
35 | ]
36 | },
37 | "browserslist": {
38 | "production": [
39 | ">0.2%",
40 | "not dead",
41 | "not op_mini all"
42 | ],
43 | "development": [
44 | "last 1 chrome version",
45 | "last 1 firefox version",
46 | "last 1 safari version"
47 | ]
48 | },
49 | "devDependencies": {
50 | "@trivago/prettier-plugin-sort-imports": "^4.2.0",
51 | "@types/chroma-js": "^2.4.2",
52 | "@types/inquirer": "^9.0.8",
53 | "@types/matter-js": "^0.19.2",
54 | "@types/three": "^0.158.0",
55 | "@types/webpack-env": "^1.18.8",
56 | "autoprefixer": "^10.4.16",
57 | "commander": "^14.0.0",
58 | "inquirer": "^12.9.0",
59 | "postcss": "^8.4.31",
60 | "prettier": "^3.0.0",
61 | "prettier-plugin-tailwindcss": "^0.5.7",
62 | "raw-loader": "^4.0.2",
63 | "react-app-rewired": "^2.2.1",
64 | "tailwind-dracula": "^1.1.0",
65 | "tailwindcss": "^3.3.3"
66 | }
67 | }
--------------------------------------------------------------------------------
/src/animations/dither.tsx:
--------------------------------------------------------------------------------
1 | // @ts-ignore
2 | import { GlslPipeline } from 'glsl-pipeline';
3 | import { RepeatWrapping, TextureLoader, WebGLRenderer } from 'three';
4 |
5 | import { Animation, DrawArgs, MakeDrawFn, ParameterConfig } from 'lib/Animation';
6 | import Utils from 'lib/utils';
7 |
8 | import shader from './shaders/dither.glsl';
9 |
10 | const Dither = () => {
11 | const duration = 6;
12 | const canvasWidth = 768;
13 | const canvasHeight = 768;
14 |
15 | const parameters = {} as const satisfies ParameterConfig;
16 |
17 | const makeDrawFn: MakeDrawFn = (canvas) => {
18 | const texLoader = new TextureLoader();
19 | let u_tex0 = texLoader.load('/animations/images/cat.jpg', () => {
20 | drawFn({ t: 0 });
21 | });
22 | u_tex0.generateMipmaps = false;
23 | u_tex0.wrapS = RepeatWrapping;
24 | u_tex0.wrapT = RepeatWrapping;
25 | const renderer = new WebGLRenderer({
26 | canvas,
27 | });
28 | let pipeline = new GlslPipeline(renderer, {
29 | u_t: { value: 0 },
30 | u_tt: { value: 0 },
31 | u_tex0: { type: 't', value: u_tex0 },
32 | });
33 | pipeline.load(shader);
34 | Utils.resetGlslPipeline(pipeline);
35 |
36 | const drawFn = ({ t }: DrawArgs) => {
37 | if (t == 0) {
38 | Utils.resetGlslPipeline(pipeline);
39 | }
40 | pipeline.uniforms.u_t.value = t;
41 | if (t > 0.5) {
42 | pipeline.uniforms.u_tt.value = t;
43 | }
44 |
45 | pipeline.renderMain();
46 | };
47 |
48 | return drawFn;
49 | };
50 |
51 | return (
52 |
59 | );
60 | };
61 |
62 | export default Dither;
63 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-pages.yaml:
--------------------------------------------------------------------------------
1 | name: deploy-pages
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | concurrency:
9 | # Only run one at a time.
10 | group: ${{ github.workflow }}
11 | cancel-in-progress: true
12 |
13 | jobs:
14 | # Build job
15 | build:
16 | # actions/upload-pages-artifact
17 | runs-on: ubuntu-latest
18 | steps:
19 | # checkout the repository content to github runner
20 |
21 | - name: Checkout
22 | uses: actions/checkout@v2
23 |
24 | # setup pnpm
25 | - name: Setup pnpm
26 | uses: pnpm/action-setup@v2
27 | with:
28 | version: 8
29 |
30 | # setup nodejs environment
31 | - name: Setup Node.js environment
32 | uses: actions/setup-node@v4
33 | with:
34 | node-version: 20
35 | cache: 'pnpm'
36 |
37 | # install dependencies
38 | - name: Install dependencies
39 | run: pnpm install
40 |
41 | - name: Build
42 | run: pnpm run build
43 |
44 | - name: Upload Pages artifact
45 | uses: actions/upload-pages-artifact@v3
46 | with:
47 | path: build
48 |
49 | # Deploy job
50 | deploy:
51 | # Add a dependency to the build job
52 | needs: build
53 |
54 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment
55 | permissions:
56 | pages: write # to deploy to Pages
57 | id-token: write # to verify the deployment originates from an appropriate source
58 |
59 | # Deploy to the github-pages environment
60 | environment:
61 | name: github-pages
62 | url: ${{ steps.deployment.outputs.page_url }}
63 |
64 | # Specify runner + deployment step
65 | runs-on: ubuntu-latest
66 | steps:
67 | - name: Deploy to GitHub Pages
68 | id: deployment
69 | uses: actions/deploy-pages@v4
70 |
--------------------------------------------------------------------------------
/src/animations/minimal2D.tsx:
--------------------------------------------------------------------------------
1 | import { Animation, DrawArgs, MakeDrawFn, ParameterConfig } from 'lib/Animation';
2 | import Graphics from 'lib/graphics';
3 | import Utils from 'lib/utils';
4 |
5 | const Minimal2D = () => {
6 | const duration = 6;
7 | const canvasWidth = 768;
8 | const canvasHeight = 768;
9 |
10 | // New type-safe parameter definition
11 | const parameters = {
12 | r: {
13 | min: 0.005,
14 | max: 1,
15 | compute: Utils.makeTransitionFunction([
16 | {
17 | easing: 'smoothstep',
18 | startT: 1,
19 | endT: 3,
20 | startValue: 0.1,
21 | endValue: 1.0,
22 | },
23 | {
24 | easing: 'smoothstep',
25 | startT: 4,
26 | endT: 6,
27 | endValue: 0.1,
28 | },
29 | ]),
30 | step: 0.005,
31 | },
32 | } as const satisfies ParameterConfig;
33 |
34 | const makeDrawFn: MakeDrawFn = (canvas) => {
35 | const ctx = canvas.getContext('2d')!;
36 |
37 | const drawFn = ({ r }: DrawArgs) => {
38 |
39 | Graphics.draw(
40 | [
41 | Graphics.AbsoluteLineWidth(4),
42 | Graphics.Set({ strokeStyle: '#ffffff', fillStyle: '#005f5f' }),
43 | Graphics.Disk({ center: [0, 0], radius: r, fill: true, edge: true }),
44 | ],
45 |
46 | {
47 | xmin: -1.1,
48 | xmax: 1.1,
49 | ymin: -1.1,
50 | ymax: 1.1,
51 | backgroundColor: '#020115',
52 | },
53 | ctx,
54 | );
55 | };
56 |
57 | return drawFn;
58 | };
59 |
60 | return (
61 |
68 | );
69 | };
70 |
71 | export default Minimal2D;
72 |
--------------------------------------------------------------------------------
/src/animations/shaders/noiseDodecahedron.glsl:
--------------------------------------------------------------------------------
1 | uniform vec2 u_resolution;
2 | uniform float u_t;
3 | uniform float u_theta_1;
4 | uniform float u_theta_2;
5 | uniform float u_update;
6 | uniform sampler2D u_doubleBuffer0;
7 |
8 | // Tells GlslPipeline that we are using the double buffer:
9 | #ifdef DOUBLE_BUFFER_0
10 | #endif
11 |
12 | #define res 128.0
13 | #define rate 2
14 | #define TWOPI 6.28318530718
15 |
16 | vec3 vertices[20] = vec3[](vec3(0, 0, 1), vec3(.58, .33, .75), vec3(0, -.67, .75), vec3(-.58, .33, .75), vec3(.36, .87, .33), vec3(.93, -.13, .33), vec3(.58, -.75, .33), vec3(-.58, -.75, .33), vec3(-.93, -.13, .33), vec3(-.36, .87, .33), vec3(.58, .75, -.33), vec3(.93, .13, -.33), vec3(.36, -.87, -.33), vec3(-.36, -.87, -.33), vec3(-.93, .13, -.33), vec3(-.58, .75, -.33), vec3(0, .67, -.75), vec3(.58, -.33, -.75), vec3(-.58, -.33, -.75), vec3(0, 0, -1));
17 |
18 | int seg[] = int[](0, 2, 6, 5, 1 // 3 bottom
19 | , 0, 3, 8, 7, 2, 0, 1, 4, 9, 3, 2, 7, 13, 12, 6 // 6 crown
20 | , 8, 14, 18, 13, 7, 6, 12, 17, 11, 5, 3, 9, 15, 14, 8, 1, 5, 11, 10, 4, 4, 10, 16, 15, 9, 19, 18, 14, 15, 16 // 3 top
21 | , 19, 17, 12, 13, 18, 19, 16, 10, 11, 17);
22 |
23 | float hash12(vec2 p) {
24 | vec3 p3 = fract(vec3(p.xyx) * .1031);
25 | p3 += dot(p3, p3.yzx + 33.33);
26 | return fract((p3.x + p3.y) * p3.z);
27 | }
28 |
29 | float sdSegment(in vec2 p, in vec2 a, in vec2 b) {
30 | vec2 pa = p - a, ba = b - a;
31 | float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
32 | return length(pa - ba * h);
33 | }
34 |
35 | #define rot(a) mat2(cos(a+vec4(0,33,11,0)))
36 |
37 | vec3 T(vec3 p) {
38 | p.yz *= rot(TWOPI * u_theta_1 + 0.21);
39 | p.zx *= rot(TWOPI * u_theta_2 + 0.77);
40 | return p;
41 | }
42 |
43 | void main() {
44 | // out vec4 fragColor, in vec2 fragCoord
45 |
46 | vec2 uv = gl_FragCoord.xy / u_resolution.xy;
47 | vec2 uvI = floor(uv * res) / res;
48 |
49 | vec3 col = texture(u_doubleBuffer0, uv).rgb;
50 |
51 | if (u_t < 0.1) {
52 | col = vec3(step(0.5, hash12(u_resolution.xy * uvI)));
53 | }
54 |
55 | vec3 _P, P, P0;
56 | float dodecCol = 0.;
57 | uvI -= 0.5;
58 | uvI *= 2.;
59 | for (int i; i <= seg.length(); i++) {
60 | _P = P;
61 | P = T(vertices[seg[i % 60]]);
62 | // P *= exp(iFloat2);
63 | P /= P.z - 1.7;
64 | if (i > 0) {
65 | dodecCol += .5 * smoothstep(0.01, 0., sdSegment(uvI, _P.xy, (i % 5 > 0 ? P : P0).xy) - 0.0001);
66 | }
67 | if (i % 5 < 1) {
68 | P0 = P;
69 | }
70 | }
71 | dodecCol = step(0.5, dodecCol);
72 |
73 | col = mix(col, 1. - col, dodecCol * u_update);
74 |
75 | gl_FragColor = vec4(step(0.5, col), 1.0);
76 | }
77 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional stylelint cache
58 | .stylelintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variable files
76 | .env
77 | .env.development.local
78 | .env.test.local
79 | .env.production.local
80 | .env.local
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 | .parcel-cache
85 |
86 | # Next.js build output
87 | .next
88 | out
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the public line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # public
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # vuepress v2.x temp and cache directory
104 | .temp
105 | .cache
106 |
107 | # Docusaurus cache and generated files
108 | .docusaurus
109 |
110 | # Serverless directories
111 | .serverless/
112 |
113 | # FuseBox cache
114 | .fusebox/
115 |
116 | # DynamoDB Local files
117 | .dynamodb/
118 |
119 | # TernJS port file
120 | .tern-port
121 |
122 | # Stores VSCode versions used for testing VSCode extensions
123 | .vscode-test
124 |
125 | # yarn v2
126 | .yarn/cache
127 | .yarn/unplugged
128 | .yarn/build-state.yml
129 | .yarn/install-state.gz
130 | .pnp.*
131 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode } from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { FaArrowLeft } from 'react-icons/fa';
4 | import { Link, RouterProvider, createHashRouter } from 'react-router-dom';
5 |
6 | import './index.css';
7 |
8 | // Dynamically import all animation components from the 'animations' directory.
9 | const animationsContext = require.context('./animations', false, /\.(t|j)sx?$/);
10 |
11 | // Construct the animations list from file names
12 | const animations = animationsContext
13 | .keys()
14 | .filter((file) => !file.startsWith('animations/'))
15 | .map((file) => {
16 | const name = file.replace('./', '').replace(/\.tsx?$/, '');
17 | const component = React.lazy(() => import(`./animations/${name}`));
18 | return { name, component };
19 | });
20 |
21 | const AnimationList = () => {
22 | return (
23 |
24 |
Animations
25 |
26 | {animations.map((animation) => (
27 | -
28 |
29 | {animation.name}
30 |
31 |
32 | ))}
33 |
34 |
35 | );
36 | };
37 |
38 | const ViewAnimation = ({ children }: { children: ReactNode }) => {
39 | return (
40 |
41 |
42 |
43 | all animations
44 |
45 |
46 | {children}
47 |
48 | );
49 | };
50 |
51 | const router = createHashRouter([
52 | {
53 | path: '/',
54 | element: ,
55 | },
56 | ...animations.map((animation) => {
57 | return {
58 | path: `/${animation.name}`,
59 | element: (
60 |
61 | Loading...}>
62 |
63 |
64 |
65 | ),
66 | };
67 | }),
68 | ]);
69 |
70 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
71 | root.render(
72 |
73 |
74 |
75 |
76 |
,
77 | );
78 |
--------------------------------------------------------------------------------
/src/animations/noiseDodecahedron.tsx:
--------------------------------------------------------------------------------
1 | // @ts-ignore
2 | import { GlslPipeline } from 'glsl-pipeline';
3 | import { WebGLRenderer } from 'three';
4 |
5 | import { Animation, DrawArgs, MakeDrawFn, ParameterConfig } from 'lib/Animation';
6 | import Utils from 'lib/utils';
7 |
8 | import shader from './shaders/noiseDodecahedron.glsl';
9 |
10 | const NoiseDodecahedron = () => {
11 | const duration = 20;
12 |
13 | const canvasWidth = 768;
14 | const canvasHeight = 768;
15 |
16 | const parameters = {
17 | theta_1: {
18 | min: 0,
19 | max: 2 * Math.PI,
20 | default: 0,
21 | compute: Utils.makeTransitionFunction([
22 | {
23 | easing: 'linear',
24 | startT: 0,
25 | endT: 20,
26 | startValue: 0.0,
27 | endValue: 0.1,
28 | },
29 | ]),
30 | },
31 | theta_2: {
32 | min: 0,
33 | max: 2 * Math.PI,
34 | default: 0,
35 | compute: Utils.makeTransitionFunction([
36 | {
37 | easing: 'linear',
38 | startT: 0,
39 | endT: 20,
40 | startValue: 1.0,
41 | endValue: 3.0,
42 | },
43 | ]),
44 | },
45 | } as const satisfies ParameterConfig;
46 |
47 | const makeDrawFn: MakeDrawFn = (canvas) => {
48 | const renderer = new WebGLRenderer({
49 | canvas,
50 | });
51 | let pipeline = new GlslPipeline(renderer, {
52 | u_t: { value: 0 },
53 | u_theta_1: { value: 0 },
54 | u_theta_2: { value: 0 },
55 | u_update: { value: 0 },
56 | });
57 | pipeline.load(shader);
58 | pipeline.renderMain();
59 | let lastUpdate = -1;
60 |
61 | const drawFn = ({ t, theta_1, theta_2 }: DrawArgs) => {
62 | if (t == 0) {
63 | Utils.resetGlslPipeline(pipeline);
64 | lastUpdate = -1;
65 | }
66 | pipeline.uniforms.u_t.value = t;
67 | pipeline.uniforms.u_theta_1.value = theta_1;
68 | pipeline.uniforms.u_theta_2.value = theta_2;
69 | let update: number = 0;
70 | if (t - lastUpdate > 0.04) {
71 | update = 1;
72 | lastUpdate = t;
73 | }
74 | pipeline.uniforms.u_update.value = update;
75 | pipeline.renderMain();
76 | };
77 |
78 | return drawFn;
79 | };
80 |
81 | return (
82 |
89 | );
90 | };
91 |
92 | export default NoiseDodecahedron;
93 |
--------------------------------------------------------------------------------
/src/animations/shaders/anaglyph.glsl:
--------------------------------------------------------------------------------
1 | uniform float u_redness;
2 | uniform float u_noiseAmount;
3 | uniform float u_contrast;
4 | uniform float u_messageStrength;
5 |
6 |
7 | uniform vec2 u_resolution;
8 | uniform sampler2D u_tex0;
9 |
10 | mat2 rotationMatrix(float angle) {
11 | float sine = sin(angle), cosine = cos(angle);
12 | return mat2(cosine, -sine, sine, cosine);
13 | }
14 |
15 | vec3 hash32(vec2 p) {
16 | vec3 p3 = fract(vec3(p.xyx) * vec3(.1031, .1030, .0973));
17 | p3 += dot(p3, p3.yxz + 33.33);
18 | return fract((p3.xxy + p3.yzz) * p3.zyx);
19 | }
20 |
21 | float hash12(vec2 p) {
22 | vec3 p3 = fract(vec3(p.xyx) * .1031);
23 | p3 += dot(p3, p3.yzx + 33.33);
24 | return fract((p3.x + p3.y) * p3.z);
25 | }
26 |
27 | float butterflyShapeDist(vec2 uv) {
28 | uv -= 0.5;
29 | uv *= rotationMatrix(0.3);
30 | uv *= 3.;
31 | uv += vec2(0.2, 0.3);
32 | uv.x = max(uv.x, -uv.x);
33 | vec2 p = uv * 20.0;
34 | float r = length(p);
35 | float t = atan(p.y, p.x);
36 | float butterfly = 7. - 0.5 * sin(1. * t) + 2.5 * sin(3. * t) + 2.0 * sin(5. * t) - 1.7 * sin(7. * t) + 3.0 * cos(2. * t) - 2.0 * cos(4. * t) - 0.4 * cos(16. * t) - r;
37 | return butterfly;
38 | }
39 |
40 | void main() {
41 | vec2 pixel = 1.0 / u_resolution.xy;
42 |
43 | vec2 uv = gl_FragCoord.xy * pixel;
44 |
45 | float res = 100.0;
46 |
47 | // butterfly text texture is 42 by 7
48 | int y = int(floor(uv.y * res)) % 7;
49 | float rowIndex = floor(uv.y * res / 7.0);
50 | int x = int(floor(uv.x * res) + 20. * sin(rowIndex * 2.) + 100.0) % 42;
51 |
52 | float butterflyText = texelFetch(u_tex0, ivec2(x, y), 0).r;
53 |
54 | vec2 uvI = floor(uv * res) / res;
55 |
56 | float message = 0.;
57 |
58 | // Add the text
59 | message = mix(message, 1.0, smoothstep(0., pixel.x, 1. - butterflyText));
60 |
61 | // Add outside of butterfly
62 | message = mix(message, 1.0, smoothstep(0., pixel.x, butterflyShapeDist(uvI)));
63 |
64 | // Subtract inside of butterfly
65 | message = mix(0.0, message, smoothstep(0., pixel.x, 1.6 - butterflyShapeDist(uvI)));
66 |
67 | // Add noise
68 | message = message + u_noiseAmount * hash12(uvI * u_resolution.xy);
69 | message /= (1.0 + u_noiseAmount);
70 |
71 | message = u_messageStrength * message;
72 | message = 1. - message;
73 |
74 | // Message is now 0 for butterfly and text, 1 for outside.
75 |
76 | // Start as noise
77 | vec3 colorMoreRed = hash32(uvI * u_resolution.xy);
78 | colorMoreRed.r = max(colorMoreRed.r, 0.5 + 0.5 * u_contrast);
79 |
80 | vec3 colorLessRed = hash32(uvI * u_resolution.xy);
81 | colorLessRed.r = min(colorLessRed.r, 0.5 - 0.5 * u_contrast);
82 |
83 | // combine random color with the message.
84 | vec3 color = mix(
85 | colorMoreRed,
86 | colorLessRed,
87 | message
88 | );
89 |
90 |
91 | color = mix(color, vec3(1.0, 0, 0) * color, u_redness);
92 |
93 | color.g *= 0.5; // Reduce green.
94 |
95 |
96 | gl_FragColor = vec4(color, 1.0);
97 |
98 | }
99 |
--------------------------------------------------------------------------------
/src/animations/anaglyph.tsx:
--------------------------------------------------------------------------------
1 | // @ts-ignore
2 | import { GlslPipeline } from 'glsl-pipeline';
3 | import { RepeatWrapping, TextureLoader, WebGLRenderer } from 'three';
4 |
5 | import { Animation, DrawArgs, MakeDrawFn, ParameterConfig } from 'lib/Animation';
6 | import Utils from 'lib/utils';
7 |
8 | import shader from './shaders/anaglyph.glsl';
9 |
10 | const Anaglyph = () => {
11 | const duration = 6;
12 | const canvasWidth = 768;
13 | const canvasHeight = 768;
14 |
15 | const defaultNoiseAmount = 0.2;
16 | const defaultContrast = 0.3;
17 | const defaultMessageStrength = 0.9;
18 |
19 | const parameters = {
20 | redness: {
21 | min: 0,
22 | max: 1,
23 | default: 0,
24 | compute: Utils.makeTransitionFunction([
25 | {
26 | easing: 'smoothstep',
27 | startT: 0.5,
28 | endT: duration - 0.5,
29 | startValue: 0,
30 | endValue: 1,
31 | },
32 | ]),
33 | },
34 | noiseAmount: {
35 | min: 0,
36 | max: 2,
37 | default: defaultNoiseAmount,
38 | },
39 | contrast: {
40 | min: 0,
41 | max: 1,
42 | default: defaultContrast,
43 | },
44 | messageStrength: {
45 | min: 0,
46 | max: 1,
47 | default: defaultMessageStrength,
48 | },
49 | } as const satisfies ParameterConfig;
50 |
51 | const makeDrawFn: MakeDrawFn = (canvas) => {
52 | const texLoader = new TextureLoader();
53 | let u_tex0 = texLoader.load('/animations/images/butterfly-text.png', () => {
54 | drawFn({
55 | t: 0,
56 | redness: 0,
57 | noiseAmount: defaultNoiseAmount,
58 | contrast: defaultContrast,
59 | messageStrength: defaultMessageStrength,
60 | });
61 | });
62 | u_tex0.generateMipmaps = false;
63 | u_tex0.wrapS = RepeatWrapping;
64 | u_tex0.wrapT = RepeatWrapping;
65 | const renderer = new WebGLRenderer({
66 | canvas,
67 | });
68 | let pipeline = new GlslPipeline(renderer, {
69 | u_tex0: { type: 't', value: u_tex0 },
70 | u_redness: { value: 0 },
71 | u_noiseAmount: { value: defaultNoiseAmount },
72 | u_contrast: { value: defaultContrast },
73 | u_messageStrength: { value: defaultMessageStrength },
74 | });
75 | pipeline.load(shader);
76 | Utils.resetGlslPipeline(pipeline);
77 |
78 | const drawFn = ({ t, redness, contrast, noiseAmount, messageStrength }: DrawArgs) => {
79 | if (t == 0) {
80 | Utils.resetGlslPipeline(pipeline);
81 | }
82 | pipeline.uniforms.u_redness.value = redness;
83 | pipeline.uniforms.u_contrast.value = contrast;
84 | pipeline.uniforms.u_noiseAmount.value = noiseAmount;
85 | pipeline.uniforms.u_messageStrength.value = messageStrength;
86 | pipeline.renderMain();
87 | };
88 |
89 | return drawFn;
90 | };
91 |
92 | return (
93 |
100 | );
101 | };
102 |
103 | export default Anaglyph;
104 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 |
5 | ## Project Overview
6 |
7 | This is a React-based mathematical animations project that creates interactive visualizations. Each animation is implemented as a React component with parameter controls, playback, and export functionality.
8 |
9 | ## Key Commands
10 |
11 | ### Development
12 | - `pnpm install` - Install dependencies
13 | - `pnpm start` - Start development server on localhost:3000
14 | - `pnpm build` - Build for production
15 | - `pnpm new-animation` - Interactive script to create new animations
16 |
17 | ### Package Manager
18 | This project uses **pnpm** (not npm). Always use `pnpm` commands.
19 |
20 | ## Architecture
21 |
22 | ### Core Components
23 | - **Animation Component** (`src/lib/Animation.tsx`): Main wrapper providing playback controls, parameter sliders, canvas resizing, and export functionality
24 | - **Graphics Library** (`src/lib/graphics.ts`): Canvas 2D drawing utilities with coordinate system management and drawing primitives
25 | - **IMPORTANT**: When adding or editing functions in `graphics.ts`, always update the "Available Graphics Commands" section in `README.md` to keep the documentation in sync
26 | - **Utils** (`src/lib/utils.ts`): Shared utilities including transition functions
27 |
28 | ### Animation Structure
29 | Each animation in `src/animations/` follows this pattern:
30 | - Defines parameters with min/max values and optional computed values over time
31 | - Implements a `MakeDrawFn` that returns a drawing function
32 | - Uses either Canvas 2D (via Graphics library) or WebGL (via Three.js/shaders)
33 | - Exports as React component wrapped with Animation component
34 |
35 | ### Shader Support
36 | - GLSL shaders in `src/animations/shaders/` are imported as strings via custom webpack config
37 | - Use `glsl-pipeline` for WebGL shader management
38 | - Three.js for WebGL rendering when needed
39 |
40 | ### Styling
41 | - Uses Tailwind CSS with Dracula theme
42 | - Custom configuration in `tailwind.config.js`
43 | - Prettier with import sorting and Tailwind class sorting
44 |
45 | ### Parameter System
46 | Parameters can be:
47 | - Static: User-controlled sliders
48 | - Computed: Automatically animated over time using transition functions
49 | - Time parameter: Built-in animation time control
50 |
51 | ### Export Functionality
52 | - Uses CCapture.js for video export (WebM format, 60fps)
53 | - CCapture.js loaded from public directory, not npm
54 |
55 | ## Creating New Animations
56 |
57 | The animation generator supports both interactive and command-line modes. Claude should prefer using the command line mode.
58 |
59 | ```bash
60 | # Shader animation with parameters
61 | pnpm new-animation --name "My Animation" --type shader --params param1,param2,param3
62 |
63 | # 2D canvas animation with parameters
64 | pnpm new-animation --name "Circle Dance" --type 2d --params radius,speed
65 |
66 | # Minimal shader animation (no parameters)
67 | pnpm new-animation --name "Simple Shader" --type shader
68 |
69 | # Type aliases: shader|1, 2d|canvas|2
70 | pnpm new-animation --name "Test" --type 1 --params x,y
71 | ```
72 |
73 | This will generate:
74 | - TypeScript animation component in `src/animations/`
75 | - GLSL shader file (for shader animations) in `src/animations/shaders/`
76 | - Properly configured uniforms and parameter bindings
77 | - Template code with TODO comments for implementation
78 |
79 | ## File Structure
80 | - `src/animations/` - Individual animation components
81 | - `src/animations/shaders/` - GLSL shader files
82 | - `src/lib/` - Core libraries (Animation, Graphics, Utils)
83 | - `scripts/` - Development utilities (animation generator)
84 | - `public/images/` - Static image assets
85 | - Configuration files use standard React/TypeScript setup with custom webpack override for GLSL loading
86 |
--------------------------------------------------------------------------------
/src/animations/hypocycloids.tsx:
--------------------------------------------------------------------------------
1 | import { Animation, DrawArgs, MakeDrawFn, ParameterConfig } from 'lib/Animation';
2 | import Graphics from 'lib/graphics';
3 | import Utils from 'lib/utils';
4 |
5 | const Hypocycloids = () => {
6 | const duration = 12;
7 | const canvasWidth = 1024;
8 | const canvasHeight = 1024;
9 |
10 | const parameters = {
11 | theta: {
12 | min: 0,
13 | max: 4 * Math.PI,
14 | // Start and end at 4 * Math.PI, linear between 4 and duration - 4 seconds.
15 | compute: (t: number) =>
16 | (4 * Math.PI * (Utils.smoothstepI(t, 0, 4) - Utils.smoothstepI(t, duration - 4, duration))) /
17 | (duration - 4),
18 | step: 0.01,
19 | },
20 | n: {
21 | min: 2,
22 | max: 16,
23 | default: 3,
24 | step: 1,
25 | },
26 | } as const satisfies ParameterConfig;
27 |
28 | const makeDrawFn: MakeDrawFn = (canvas) => {
29 | const ctx = canvas.getContext('2d')!;
30 |
31 | const drawFn = ({ theta, n }: DrawArgs) => {
32 | theta = Math.min(theta, 4 * Math.PI - 1e-5);
33 | const r = 1 / n;
34 | const inFirstHalf = theta < 2 * Math.PI;
35 | const traceInFn = (th: number) => [
36 | Math.sin(th) * (1 - r) - Math.sin((th * (1 - r)) / r) * r,
37 | Math.cos(th) * (1 - r) + Math.cos((th * (1 - r)) / r) * r,
38 | ];
39 | const traceOutFn = (th: number) => [
40 | Math.sin(th) * (1 + r) - Math.sin((th * (1 + r)) / r) * r,
41 | Math.cos(th) * (1 + r) - Math.cos((th * (1 + r)) / r) * r,
42 | ];
43 | const plotRange = 1 + 2 * r + 0.1;
44 | Graphics.draw(
45 | [
46 | Graphics.AbsoluteLineWidth(4),
47 | Graphics.Set({ strokeStyle: '#ffffff' }),
48 | Graphics.Disk({ center: [0, 0], radius: 1, fill: false, edge: true }),
49 | [
50 | Graphics.Set({ strokeStyle: '#bbeafe' }),
51 | // Trace inside.
52 | Graphics.Line({
53 | pts: Utils.range(inFirstHalf ? 0 : 4 * Math.PI, theta, inFirstHalf ? 0.01 : -0.01).map(
54 | traceInFn,
55 | ),
56 | }),
57 | // Trace outside.
58 | Graphics.Line({
59 | pts: Utils.range(inFirstHalf ? 0 : 4 * Math.PI, theta, inFirstHalf ? 0.01 : -0.01).map(
60 | traceOutFn,
61 | ),
62 | }),
63 | ],
64 | [
65 | Graphics.Rotate({ angle: theta, center: [0, 0] }),
66 | // Rolling circles.
67 | Graphics.Set({ fillStyle: '#ffffff16' }),
68 | [
69 | // inside
70 | Graphics.Disk({ center: [0, 1 - r], radius: r, fill: true, edge: true }),
71 | Graphics.Rotate({ angle: -theta / r, center: [0, 1 - r] }),
72 | // Point on rolling circle.
73 | Graphics.Set({ fillStyle: '#f39034', strokeStyle: 'black' }),
74 | Graphics.Disk({
75 | center: [0, 1],
76 | radius: 16,
77 | fill: true,
78 | edge: true,
79 | radiusInPixels: true,
80 | }),
81 | ],
82 | [
83 | // outside
84 | Graphics.Disk({ center: [0, 1 + r], radius: r, fill: true, edge: true }),
85 | Graphics.Rotate({ angle: theta / r, center: [0, 1 + r] }),
86 | // Point on rolling circle.
87 | Graphics.Set({ fillStyle: '#f39034', strokeStyle: 'black' }),
88 | Graphics.Disk({
89 | center: [0, 1],
90 | radius: 16,
91 | fill: true,
92 | edge: true,
93 | radiusInPixels: true,
94 | }),
95 | ],
96 | ],
97 | ],
98 |
99 | {
100 | xmin: -plotRange,
101 | xmax: plotRange,
102 | ymin: -plotRange,
103 | ymax: plotRange,
104 | backgroundColor: '#020115',
105 | },
106 | ctx,
107 | );
108 | };
109 |
110 | return drawFn;
111 | };
112 |
113 | return (
114 |
121 | );
122 | };
123 |
124 | export default Hypocycloids;
125 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # matthen/animations
2 |
3 | [](https://github.com/matthen/animations/actions/workflows/deploy-pages.yaml)
4 |
5 | view live animations at: [matthen.github.io/animations](https://matthen.github.io/animations/).
6 |
7 | This is a collection of mathematical animations, written as small React components.
8 |
9 | - [animations](src/animations) - code for each animation
10 | - [lib/Animation.tsx](src/lib/Animation.tsx) - react component for rendering and exporting animations
11 | - [lib/graphics.ts](src/lib/graphics.ts) - library to simplify drawing to 2d canvas
12 |
13 | ## Creating an animation
14 |
15 | Use the interactive animation generator:
16 |
17 | ```bash
18 | pnpm new-animation
19 | ```
20 |
21 | This will prompt you to:
22 | 1. Choose animation name
23 | 2. Select type (shader or 2D canvas)
24 | 3. Define parameters (all default to 0-1 range)
25 |
26 | The script generates all necessary files with proper templates and TODO comments to guide implementation.
27 |
28 | ## Using `Graphics`
29 |
30 | The Graphics library (`src/lib/graphics.ts`) provides a declarative approach to Canvas 2D drawing, inspired by Mathematica's `Graphics[]` system. Instead of imperatively calling canvas methods, you build a nested structure of graphics objects that automatically manage drawing context.
31 |
32 | ### Coordinate System
33 |
34 | Graphics uses a mathematical coordinate system rather than pixel coordinates. You specify the drawing bounds with `xmin`, `xmax`, `ymin`, and `ymax` options, and the library automatically handles the canvas transformation. Coordinates work as expected mathematically:
35 | - **X-axis**: increases left to right (like pixels)
36 | - **Y-axis**: increases bottom to top (unlike pixels, which increase top to bottom)
37 |
38 | For example, with bounds `{xmin: -1, xmax: 1, ymin: -1, ymax: 1}`, the point `[0, 0]` is at the center, `[1, 1]` is top-right, and `[-1, -1]` is bottom-left.
39 |
40 | ### Core Concept: DrawCommands and Nested Structure
41 |
42 | Graphics uses `DrawCommand` functions organized in nested arrays. Each array level creates a new canvas context scope using `save()` and `restore()`, allowing transformations and style changes to be automatically contained within their scope.
43 |
44 | ```typescript
45 | Graphics.draw(
46 | [
47 | // Set canvas context properties at root level (ctx.save() called automatically)
48 | Graphics.Set({ strokeStyle: 'white', fillStyle: 'red' }),
49 | Graphics.AbsoluteLineWidth(4),
50 | // Red-filled disk with white outline
51 | Graphics.Disk({ center: [0, 0], radius: 0.5, fill: true, edge: true }),
52 | [
53 | // New context scope (ctx.save() called) - inherits white stroke + red fill
54 | // Override fill to blue while keeping white stroke inherited from parent
55 | Graphics.Set({ fillStyle: 'blue' }),
56 | // Blue-filled square with inherited white outline
57 | Graphics.Polygon({
58 | pts: [
59 | [0, 0],
60 | [1, 0],
61 | [1, 1],
62 | [0, 1],
63 | ],
64 | edge: true,
65 | fill: true,
66 | }),
67 | [
68 | // Deeper context scope (nested ctx.save()) - inherits white stroke + blue fill
69 | // Add rotation transform + change fill to white for text
70 | Graphics.Rotate({ angle: -Math.PI / 4, center: [0, 0] }),
71 | Graphics.Set({ font: '0.25px monospace', fillStyle: 'white' }),
72 | Graphics.Text({ at: [0, 0], text: 'hello' }),
73 | // When this scope ends: ctx.restore() removes rotation + white text fill
74 | ],
75 | // Back in blue-fill scope: rotation gone, fillStyle blue, strokeStyle white
76 | ],
77 | // Back in root scope (ctx.restore() called): fillStyle red, strokeStyle white, no transforms
78 | [
79 | // New sibling scope (ctx.save() called) - inherits red fill + white stroke
80 | // Add translation transform
81 | Graphics.Translate({ offset: [-1, -1] }),
82 | // Red-filled square at translated position with white outline
83 | Graphics.Polygon({
84 | pts: [
85 | [0, 0],
86 | [1, 0],
87 | [1, 1],
88 | [0, 1],
89 | ],
90 | edge: true,
91 | fill: true,
92 | }),
93 | // When this scope ends: ctx.restore() removes translation
94 | ],
95 | // Back in root scope: original red fill + white stroke, no transforms
96 | ],
97 | {
98 | xmin: -1.1,
99 | xmax: 1.1,
100 | ymin: -1.1,
101 | ymax: 1.1,
102 | },
103 | ctx,
104 | );
105 | ```
106 |
107 | ### Available Graphics Commands
108 |
109 | **Shapes:**
110 | - `Graphics.Disk()` - Circles and arcs with fill/edge options
111 | - `Graphics.Polygon()` - Filled/outlined polygons from point arrays
112 | - `Graphics.Line()` - Polylines from point arrays
113 | - `Graphics.Text()` - Text at specified coordinates
114 |
115 | **Transformations:**
116 | - `Graphics.Rotate()` - Rotate around a center point
117 | - `Graphics.Translate()` - Translate by offset
118 | - `Graphics.Scale()` - Scale around a center point
119 |
120 | **Styling:**
121 | - `Graphics.Set()` - Set canvas properties (colors, line width, etc.)
122 | - `Graphics.AbsoluteLineWidth()` - Line width in pixels regardless of zoom
123 |
--------------------------------------------------------------------------------
/src/lib/graphics.ts:
--------------------------------------------------------------------------------
1 | namespace Graphics {
2 | export type DrawCommand = (ctx: CanvasRenderingContext2D) => void;
3 |
4 | type CanvasState = {
5 | strokeStyle: string;
6 | fillStyle: string;
7 | globalAlpha: number;
8 | lineWidth: number;
9 | lineCap: 'butt' | 'round' | 'square';
10 | lineJoin: 'round' | 'bevel' | 'miter';
11 | miterLimit: number;
12 | lineDashOffset: number;
13 | shadowOffsetX: number;
14 | shadowOffsetY: number;
15 | shadowBlur: number;
16 | shadowColor: string;
17 | font: string;
18 | textAlign: 'left' | 'right' | 'center' | 'start' | 'end';
19 | textBaseline: 'top' | 'hanging' | 'middle' | 'alphabetic' | 'ideographic' | 'bottom';
20 | direction: 'ltr' | 'rtl' | 'inherit';
21 | imageSmoothingEnabled: boolean;
22 | };
23 |
24 | export const Set = (values: Partial): DrawCommand => {
25 | return (ctx) => {
26 | for (const key in values) {
27 | if (values.hasOwnProperty(key)) {
28 | // Check if the property exists in CanvasState before setting
29 | if (key in ctx) {
30 | // @ts-ignore
31 | ctx[key] = values[key];
32 | }
33 | }
34 | }
35 | };
36 | };
37 |
38 | export const AbsoluteLineWidth = (pixels: number): DrawCommand => {
39 | return (ctx) => {
40 | const transform = ctx.getTransform();
41 | ctx.lineWidth = pixels / Math.sqrt(transform.a * transform.a + transform.b * transform.b);
42 | };
43 | };
44 |
45 | export const Disk = ({
46 | center,
47 | radius,
48 | fill,
49 | edge,
50 | startAngle = 0,
51 | endAngle = 2 * Math.PI,
52 | sector = false,
53 | radiusInPixels = false,
54 | }: {
55 | center: number[];
56 | radius: number;
57 | fill: boolean;
58 | edge: boolean;
59 | startAngle?: number;
60 | endAngle?: number;
61 | sector?: boolean;
62 | radiusInPixels?: boolean;
63 | }): DrawCommand => {
64 | return (ctx) => {
65 | if (radiusInPixels) {
66 | const transform = ctx.getTransform();
67 | radius = radius / Math.sqrt(transform.a * transform.a + transform.b * transform.b);
68 | }
69 | if (!fill && !edge) {
70 | return;
71 | }
72 | const oldFillStyle = ctx.fillStyle;
73 | if (!fill) {
74 | ctx.fillStyle = 'rgba(0, 0, 0, 0)';
75 | }
76 | ctx.beginPath();
77 | if (sector) {
78 | ctx.moveTo(center[0], center[1]);
79 | }
80 | ctx.arc(center[0], -center[1], radius, startAngle, endAngle);
81 | if (sector) {
82 | ctx.moveTo(center[0], center[1]);
83 | }
84 | ctx.fill();
85 | if (edge) {
86 | ctx.stroke();
87 | }
88 | ctx.fillStyle = oldFillStyle;
89 | };
90 | };
91 |
92 | export const Polygon = ({ pts, edge, fill }: { pts: number[][]; edge: boolean; fill: boolean }): DrawCommand => {
93 | return (ctx) => {
94 | if (!fill && !edge) {
95 | return;
96 | }
97 | const oldFillStyle = ctx.fillStyle;
98 | if (!fill) {
99 | ctx.fillStyle = 'rgba(0, 0, 0, 0)';
100 | }
101 | ctx.beginPath();
102 | const [x0, y0] = pts[0];
103 | ctx.moveTo(x0, -y0);
104 | pts.forEach(([x, y]) => ctx.lineTo(x, -y));
105 | ctx.lineTo(x0, -y0);
106 | ctx.fill();
107 | if (edge) {
108 | ctx.stroke();
109 | }
110 | ctx.fillStyle = oldFillStyle;
111 | };
112 | };
113 |
114 | export const Line = ({ pts }: { pts: number[][] }): DrawCommand => {
115 | return (ctx) => {
116 | if (pts.length == 0) {
117 | return;
118 | }
119 | ctx.beginPath();
120 | const [x0, y0] = pts[0];
121 | ctx.moveTo(x0, -y0);
122 | pts.forEach(([x, y]) => ctx.lineTo(x, -y));
123 | ctx.stroke();
124 | };
125 | };
126 |
127 | export const Text = ({ at, text }: { at: number[]; text: string }): DrawCommand => {
128 | return (ctx) => {
129 | const [x, y] = at;
130 | ctx.fillText(text, x, -y);
131 | };
132 | };
133 |
134 | export const Rotate = ({ angle, center }: { angle: number; center: number[] }): DrawCommand => {
135 | // Rotate clockwise by angle radians around center.
136 | return (ctx) => {
137 | const [cx, cy] = center;
138 | ctx.translate(cx, -cy);
139 | ctx.rotate(angle);
140 | ctx.translate(-cx, cy);
141 | };
142 | };
143 |
144 | export const Translate = ({ offset }: { offset: number[] }): DrawCommand => {
145 | return (ctx) => {
146 | const [x, y] = offset;
147 | ctx.translate(x, -y);
148 | };
149 | };
150 |
151 | export const Scale = ({
152 | center,
153 | scaleX,
154 | scaleY,
155 | }: {
156 | center: number[];
157 | scaleX: number;
158 | scaleY: number;
159 | }): DrawCommand => {
160 | return (ctx) => {
161 | const [cx, cy] = center;
162 | ctx.translate(cx, -cy);
163 | ctx.scale(scaleX, scaleY);
164 | ctx.translate(-cx, cy);
165 | };
166 | };
167 |
168 | type DrawCommands = DrawCommand | DrawCommands[];
169 |
170 | type DrawOptions = {
171 | xmin: number;
172 | xmax: number;
173 | ymin: number;
174 | ymax: number;
175 | backgroundColor?: string;
176 | };
177 |
178 | export const draw = (
179 | commands: DrawCommands,
180 | options: DrawOptions,
181 | ctx: CanvasRenderingContext2D,
182 | depth?: number,
183 | ): void => {
184 | if (Array.isArray(commands)) {
185 | if (commands.length == 0) {
186 | return;
187 | }
188 | ctx.save();
189 | // compute scale and translation
190 | if (depth === undefined) {
191 | // Handle background color if specified
192 | if (options.backgroundColor) {
193 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
194 | const originalFillStyle = ctx.fillStyle;
195 | ctx.fillStyle = options.backgroundColor;
196 | ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
197 | ctx.fillStyle = originalFillStyle;
198 | }
199 | ctx.translate(ctx.canvas.width / 2, ctx.canvas.height / 2);
200 | ctx.scale(
201 | ctx.canvas.width / (options.xmax - options.xmin),
202 | ctx.canvas.height / (options.ymax - options.ymin),
203 | );
204 | // Default line width to 1 pixel.
205 | ctx.translate(-(options.xmin + options.xmax) / 2, (options.ymin + options.ymax) / 2);
206 | AbsoluteLineWidth(1)(ctx);
207 | }
208 | // then make sure all commands use negative y
209 |
210 | commands.forEach((command) => draw(command, options, ctx, depth === undefined ? 1 : depth + 1));
211 | ctx.restore();
212 | } else {
213 | commands(ctx);
214 | }
215 | };
216 | }
217 |
218 | export default Graphics;
219 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { Clock } from 'three';
2 |
3 | namespace Utils {
4 | export const range = (start: number, end: number, step: number = 1) => {
5 | const result: number[] = [];
6 | if (step === 0) {
7 | throw new Error('Step cannot be zero');
8 | }
9 | if (start < end && step < 0) {
10 | throw new Error('Step must be positive when start is less than end');
11 | }
12 | if (start > end && step > 0) {
13 | throw new Error('Step must be negative when start is greater than end');
14 | }
15 |
16 | for (let i = start; step > 0 ? i < end : i > end; i += step) {
17 | result.push(i);
18 | }
19 |
20 | return result;
21 | };
22 |
23 | export const circlePoints = ({
24 | num,
25 | center = [0, 0],
26 | radius = 1,
27 | offset = 0,
28 | }: {
29 | num: number;
30 | center?: number[];
31 | radius?: number;
32 | offset?: number;
33 | }) => {
34 | const a = (2 * Math.PI) / num;
35 | return range(0, num).map((i) => [
36 | center[0] + radius * Math.cos(offset + a * i),
37 | center[1] + radius * Math.sin(offset + a * i),
38 | ]);
39 | };
40 |
41 | export const smoothstep = (t: number, startT: number = 0, endT: number = 1): number => {
42 | const tt = (t - startT) / (endT - startT);
43 | if (tt <= 0) {
44 | return 0;
45 | }
46 | if (tt >= 1) {
47 | return 1;
48 | }
49 | return 6 * Math.pow(tt, 5) - 15 * Math.pow(tt, 4) + 10 * Math.pow(tt, 3);
50 | };
51 |
52 | export const smoothstepI = (t: number, startT: number = 0, endT: number = 1): number => {
53 | // Integral of smoothstep.
54 | if ((t - startT) * (startT - endT) > 0) {
55 | return 0.0;
56 | }
57 | if ((t - endT) * (endT - startT) > 0) {
58 | return 0.5 * (2 * (t - endT) + endT - startT);
59 | }
60 | return -(
61 | (Math.pow(t - startT, 4) *
62 | (2 * t * t + startT * startT + 2 * t * (startT - 3 * endT) - 4 * startT * endT + 5 * endT * endT)) /
63 | (2 * Math.pow(startT - endT, 5))
64 | );
65 | };
66 |
67 | export interface Transition {
68 | easing: 'smoothstep' | 'linear' | 'step';
69 | startT: number;
70 | endT: number;
71 | startValue?: number; // May be inferred from context.
72 | endValue: number;
73 | }
74 |
75 | const computeEasing = (easing: Transition['easing'], t: number, startT: number, endT: number): number => {
76 | if (easing == 'smoothstep') {
77 | return smoothstep(t, startT, endT);
78 | } else if (easing == 'linear') {
79 | return (t - startT) / (endT - startT);
80 | } else if (easing == 'step') {
81 | return t > (startT + endT) / 2 ? 1 : 0;
82 | }
83 | return 0.0; // unreachable
84 | };
85 |
86 | export const makeTransitionFunction = (transitions: Transition[]): ((t: number) => number) => {
87 | if (transitions.length == 0) {
88 | return (_t) => 0;
89 | }
90 | transitions.sort((tr1, tr2) => tr1.startT - tr2.startT);
91 |
92 | // Populate the undefined start values.
93 | const initialValue = transitions[0].startValue || 0.0;
94 | const finalValue = transitions[transitions.length - 1].endValue;
95 | let value = initialValue;
96 | transitions.forEach((tr) => {
97 | tr.startValue ??= value;
98 | value = tr.endValue;
99 | });
100 |
101 | return (t: number) => {
102 | let i = 0;
103 | while (i < transitions.length && transitions[i].endT < t) {
104 | // Could be a binary search but whatever.
105 | i += 1;
106 | }
107 | if (i == transitions.length) {
108 | return finalValue;
109 | }
110 | const tr = transitions[i];
111 | if (tr.startT < t && t < tr.endT) {
112 | // compute transition
113 | const easing = computeEasing(tr.easing, t, tr.startT, tr.endT);
114 | return tr.startValue! * (1 - easing) + tr.endValue * easing;
115 | } else if (i == 0) {
116 | return initialValue;
117 | } else {
118 | return transitions[i - 1].endValue;
119 | }
120 | };
121 | };
122 |
123 | export const frac = (t: number): number => {
124 | // Fractional part of t.
125 | return t - Math.floor(t);
126 | };
127 |
128 | export const piDigits =
129 | '3141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117067982148086513282306647093844609550582231725359408128481117450284102701938521105559644622948954930381964428810975665933446128475648233786783165271201909145648566923460348610454326648213393607260249141273724587006606315588174881520920962829254091715364367892590360011330530548820466521384146951941511609433057270365759591953092186117381932611793105118548074462379962749567351885752724891227938183011949129833673362440656643086021394946395224737190702179860943702770539217176293176752384674818467669405132000568127145263560827785771342757789609173637178721468440901224953430146549585371050792279689258923542019956112129021960864034418159813629774771309960518707211349999998372978049951059731732816096318595024459455346908302642522308253344685035261931188171010003137838752886587533208381420617177669147303598253490428755468731159562863882353787593751957781857780532171226806613001927876611195909216420198938095257201065485863278865936153381827968230301952035301852968995773622599413891249721775283479131515574857242454150695950829533116861727855889075098381754637464939319255060400927701671139009848824012858361603563707660104710181942955596198946767837449448255379774726847104047534646208046684259069491293313677028989152104752162056966024058038150193511253382430035587640247496473263914199272604269922796782354781636009341721641219924586315030286182974555706749838505494588586926995690927210797509302955321165344987202755960236480665499119881834797753566369807426542527862551818417574672890977772793800081647060016145249192173217214772350141441973568548161361157352552133475741849468438523323907394143334547762416862518983569485562099219222184272550254256887671790494601653466804988627232791786085784383827967976681454100953883786360950680064225125205117392984896084128488626945604241965285022210661186306744278622039194945047123713786960956364371917287467764657573962413890865832645995813390478027590099465764078951269468398352595709825822620522489407726719478268482601476990902640136394437455305068203496252451749399651431429809190659250937221696461515709858387410597885959772975498930161753928468138268683868942774155991855925245953959431049972524680845987273644695848653836736222626099124608051243884390451244136549762780797715691435997700129616089441694868555848406353422072225828488648158456028506016842739452267467678895252138522549954666727823986456596116354886230577456498035593634568174324112515076069479451096596094025228879710893145669136867228748940560101503308617928680920874760917824938589009714909675985261365549781893129784821682998948722658804857564014270477555132379641451523746234364542858444795265867821051141354735739523113427166102135969536231442952484937187110145765403590279934403742007310578539062198387447808478489683321445713868751943506430218453191048481005370614680674919278191197939952061419663428754440643745123718192179998391015919561814675142691239748940907186494231961567945208095146550225231603881930142093762137855956638937787083039069792077346722182562599661501421503068038447734549202605414665925201497442850732518666002132434088190710486331734649651453905796268561005508106658796998163574736384052571459102897064140110971206280439039759515677157700420337869936007230558763176359421873125147120532928191826186125867321579198414848829164470609575270695722091756711672291098169091528017350671274858322287183520935396572512108357915136988209144421006751033467110314126711136990865851639831501970165151168517143765761835155650884909989859982387345528331635507647918535893226185489632132933089857064204675259070915481416549859461637180270981994309924488957571282890592323326097299712084433573265489382391193259746366730583604142813883032038249037589852437441702913276561809377344403070746921120191302033038019762110110044929321516084244485963766983895228684783123552658213144957685726243344189303968642624341077322697802807318';
130 |
131 | export const resetGlslPipeline = (pipeline: any): void => {
132 | // Reset pipeline.
133 | // Not needed once https://github.com/patriciogonzalezvivo/glsl-pipeline/pull/2 is deployed.
134 | pipeline.clock = new Clock();
135 | pipeline.frame = 0;
136 | pipeline.lastTime = 0.0;
137 | pipeline.time = 0.0;
138 | pipeline.doubleBuffers.forEach((buffer: any) => {
139 | buffer.renderTargets.forEach((renderTarget: any) => {
140 | pipeline.renderer.setRenderTarget(renderTarget);
141 | pipeline.renderer.clear();
142 | });
143 | });
144 | };
145 | }
146 |
147 | export default Utils;
148 |
--------------------------------------------------------------------------------
/src/animations/piArcs.tsx:
--------------------------------------------------------------------------------
1 | import chroma from 'chroma-js';
2 |
3 | import { Animation, DrawArgs, MakeDrawFn, ParameterConfig } from 'lib/Animation';
4 | import Graphics from 'lib/graphics';
5 | import Utils from 'lib/utils';
6 |
7 | const PiArcs = () => {
8 | const duration = 56;
9 | const canvasWidth = 1024;
10 | const canvasHeight = 1024;
11 | const bgColor = '#020115';
12 |
13 | const tt = (t: number) =>
14 | 0.25 * t +
15 | 0.25 * Utils.smoothstepI(t, 2, 3) +
16 | Utils.smoothstepI(t, 10, 15) +
17 | 1.5 * Utils.smoothstepI(t, 15, 20) +
18 | 10 * Utils.smoothstepI(t, 20, 40) -
19 | 13 * Utils.smoothstepI(t, duration - 3.5, duration - 0.5);
20 |
21 | const parameters = {
22 | arc: {
23 | min: 0,
24 | max: 1,
25 | step: 0.01,
26 | compute: (t: number) => Utils.smoothstep(Utils.frac(tt(t)), 0, 0.5),
27 | },
28 | next: {
29 | min: 0,
30 | max: 1,
31 | step: 0.01,
32 | compute: (t: number) => Utils.smoothstep(Utils.frac(tt(t)), 0.5, 1.0),
33 | },
34 | index: { min: 0, max: Utils.piDigits.length - 1, step: 1, compute: (t: number) => Math.floor(tt(t)) },
35 |
36 | zoom: {
37 | min: 1,
38 | max: 100,
39 | step: 0.01,
40 | compute: (t: number) => 2.4 + 8 * Utils.smoothstep(tt(t), 1, 14) + tt(t) * 0.1,
41 | },
42 |
43 | centreX: { min: -20, max: 20, compute: (t: number) => 0.7 + 4 * Utils.smoothstep(tt(t), 1, 14) },
44 | centreY: {
45 | min: -20,
46 | max: 20,
47 | compute: (t: number) => -0.8 + 7 * Utils.smoothstep(tt(t), 1, 14) + 0.015 * Utils.smoothstepI(tt(t), 14, 30),
48 | },
49 | } as const satisfies ParameterConfig;
50 | const pyByFive = Math.PI / 5;
51 |
52 | const makeDrawFn: MakeDrawFn = (canvas) => {
53 | const ctx = canvas.getContext('2d')!;
54 |
55 | const drawFn = ({ t, arc, next, index, zoom, centreX, centreY }: DrawArgs) => {
56 |
57 | const digit = Number(Utils.piDigits[index]);
58 | const even = index % 2 == 0;
59 | const angle = pyByFive * (digit > 0 ? digit : 10);
60 | let oddOpacity = 0.0;
61 | let evenOpacity = 0.0;
62 | oddOpacity = Math.max(0, 1.0 - 2 * next);
63 | evenOpacity = Math.max(0, Math.min(1, -1 + 2 * next));
64 | if (!even) {
65 | [oddOpacity, evenOpacity] = [evenOpacity, oddOpacity];
66 | }
67 |
68 | let arcStyles = [
69 | Graphics.Set({ strokeStyle: '#ff36e8af' }),
70 | Graphics.Set({ lineWidth: 0.06 * Math.pow(zoom / 2.4, 0.6) }),
71 | ];
72 | let transformsAndPreviousArcs: Graphics.DrawCommand[] = [];
73 | for (let i = 0; i < index; i++) {
74 | const digitI = Number(Utils.piDigits[i]);
75 | const angleI = pyByFive * (digitI > 0 ? digitI : 9.999);
76 | transformsAndPreviousArcs.push(
77 | Graphics.Disk({
78 | center: [0, 0],
79 | radius: 1,
80 | fill: false,
81 | edge: true,
82 | startAngle: i % 2 == 0 ? -0.5 * Math.PI : -0.5 * Math.PI - angleI,
83 | endAngle: i % 2 == 0 ? -0.5 * Math.PI + angleI : -0.5 * Math.PI,
84 | sector: false,
85 | }),
86 | );
87 | if (i % 2 == 0) {
88 | transformsAndPreviousArcs.push(
89 | Graphics.Translate({ offset: [2 * Math.sin(angleI), 2 * Math.cos(angleI)] }),
90 | );
91 | transformsAndPreviousArcs.push(Graphics.Rotate({ center: [0, 0], angle: angleI - Math.PI }));
92 | } else {
93 | transformsAndPreviousArcs.push(
94 | Graphics.Translate({ offset: [2 * Math.sin(-angleI), 2 * Math.cos(-angleI)] }),
95 | );
96 | transformsAndPreviousArcs.push(Graphics.Rotate({ center: [0, 0], angle: Math.PI - angleI }));
97 | }
98 | }
99 |
100 | let piStrings = ['3.'];
101 | for (let i = 1; i <= index; i++) {
102 | piStrings[piStrings.length - 1] += Utils.piDigits[i];
103 | if (i % 54 == 0) {
104 | piStrings.push(' ');
105 | }
106 | }
107 |
108 | Graphics.draw(
109 | [
110 | Graphics.Set({
111 | font: '28px Courier',
112 | fillStyle: '#a3a3ae',
113 | textAlign: 'left',
114 | textBaseline: 'top',
115 | }),
116 | piStrings.map((str, i) => Graphics.Text({ at: [24, 230 - i * 28], text: str })),
117 | ],
118 | { xmin: 0, ymin: 0, xmax: canvasWidth, ymax: canvasHeight, backgroundColor: bgColor },
119 | ctx,
120 | );
121 |
122 | Graphics.draw(
123 | [
124 | [
125 | // Move it using translates and rotates.
126 | ...arcStyles,
127 | ...transformsAndPreviousArcs,
128 | Graphics.Set({ lineWidth: 0.02 }),
129 |
130 | [
131 | // Filled in arc
132 | Graphics.Set({
133 | fillStyle: chroma('#178585')
134 | .alpha(1 - next)
135 | .css(),
136 | }),
137 | Graphics.Disk({
138 | center: [0, 0],
139 | radius: 1,
140 | fill: true,
141 | edge: false,
142 | startAngle: even ? -0.5 * Math.PI : -0.5 * Math.PI - angle * arc,
143 | endAngle: even ? -0.5 * Math.PI + angle * arc : -0.5 * Math.PI,
144 | sector: true,
145 | }),
146 | ],
147 | [
148 | // The 'clock'
149 | even
150 | ? Graphics.Translate({
151 | offset: [next * 2 * Math.sin(angle), next * 2 * Math.cos(angle)],
152 | })
153 | : Graphics.Translate({
154 | offset: [next * 2 * Math.sin(-angle), next * 2 * Math.cos(-angle)],
155 | }),
156 | even
157 | ? Graphics.Rotate({
158 | center: [0, 0],
159 | angle: next > 0.5 ? angle - Math.PI : 0,
160 | })
161 | : Graphics.Rotate({
162 | center: [0, 0],
163 | angle: next > 0.5 ? Math.PI - angle : 0,
164 | }),
165 | Graphics.Set({
166 | strokeStyle: 'white',
167 | fillStyle: 'white',
168 | font: '0.2px serif',
169 | textAlign: 'center',
170 | textBaseline: 'middle',
171 | }),
172 |
173 | Utils.range(-0.5 * Math.PI, 1.5 * Math.PI, pyByFive).map((th, i) => [
174 | Graphics.Disk({
175 | center: [0, 0],
176 | radius: 1,
177 | fill: false,
178 | edge: true,
179 | startAngle: th,
180 | endAngle: th + pyByFive,
181 | sector: true,
182 | }),
183 | [
184 | [
185 | Graphics.Set({
186 | fillStyle: chroma('white').alpha(oddOpacity).css(),
187 | }),
188 | Graphics.Text({
189 | at: [0.7 * Math.cos(th + pyByFive / 2), -0.7 * Math.sin(th + pyByFive / 2)],
190 | text: `${(i + 1) % 10}`,
191 | }),
192 | ],
193 | [
194 | Graphics.Set({
195 | fillStyle: chroma('white').alpha(evenOpacity).css(),
196 | }),
197 | Graphics.Text({
198 | at: [0.7 * Math.cos(th + pyByFive / 2), -0.7 * Math.sin(th + pyByFive / 2)],
199 | text: `${(10 - i) % 10}`,
200 | }),
201 | ],
202 | ],
203 | ]),
204 | ],
205 | [
206 | // New arc
207 | ...arcStyles,
208 | Graphics.Disk({
209 | center: [0, 0],
210 | radius: 1,
211 | fill: false,
212 | edge: true,
213 | startAngle: even ? -0.5 * Math.PI : -0.5 * Math.PI - angle * arc,
214 | endAngle: even ? -0.5 * Math.PI + angle * arc : -0.5 * Math.PI,
215 | sector: false,
216 | }),
217 | ],
218 | ],
219 | ],
220 | {
221 | xmin: centreX - zoom,
222 | xmax: centreX + zoom,
223 | ymin: centreY - zoom,
224 | ymax: centreY + zoom,
225 | backgroundColor: bgColor,
226 | },
227 | ctx,
228 | );
229 | };
230 |
231 | return drawFn;
232 | };
233 |
234 | return (
235 |
243 | );
244 | };
245 |
246 | export default PiArcs;
247 |
--------------------------------------------------------------------------------
/scripts/new-animation.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Interactive Animation Generator
5 | *
6 | * Creates new animation components with templates for either:
7 | * - Shader animations (WebGL/GLSL)
8 | * - 2D Canvas animations (using Graphics library)
9 | *
10 | * Usage:
11 | * pnpm new-animation # Interactive mode
12 | * pnpm new-animation --name "My Animation" --type shader --params param1,param2
13 | *
14 | * The script will prompt for animation name, type, and parameters,
15 | * then generate all necessary files with proper templates.
16 | */
17 |
18 | const fs = require('fs');
19 | const path = require('path');
20 | const { Command } = require('commander');
21 | const inquirer = require('inquirer').default;
22 |
23 | function toPascalCase(str) {
24 | return str.replace(/(?:^|\s)(\w)/g, (_, char) => char.toUpperCase()).replace(/\s/g, '');
25 | }
26 |
27 | function toCamelCase(str) {
28 | const pascal = toPascalCase(str);
29 | return pascal.charAt(0).toLowerCase() + pascal.slice(1);
30 | }
31 |
32 | function createShaderTemplate(name, parameters) {
33 | const templatePath = path.join(__dirname, 'templates', 'shader.glsl');
34 | let template = fs.readFileSync(templatePath, 'utf8');
35 |
36 | const uniformDeclarations = parameters.map((param) => `uniform float u_${param};`).join('\n');
37 | const uniformComments = parameters.length > 0 ? `, ${parameters.map((p) => `u_${p}`).join(', ')}` : '';
38 |
39 | template = template.replace('{{UNIFORM_DECLARATIONS}}', uniformDeclarations);
40 | template = template.replace('{{UNIFORM_COMMENTS}}', uniformComments);
41 |
42 | return template;
43 | }
44 |
45 | function createShaderAnimationTemplate(name, parameters) {
46 | const templatePath = path.join(__dirname, 'templates', 'shader-animation.tsx');
47 | let template = fs.readFileSync(templatePath, 'utf8');
48 |
49 | const pascalName = toPascalCase(name);
50 | const camelName = toCamelCase(name);
51 |
52 | const parameterDefinitions = parameters
53 | .map((param) => ` ${param}: { min: 0, max: 1, default: 0.5 },`)
54 | .join('\n');
55 | const uniformsObject = `{ u_t: { value: 0 }${parameters.length > 0 ? ', ' : ''}${parameters
56 | .map((p) => `u_${p}: { value: 0 }`)
57 | .join(', ')} }`;
58 | const uniformAssignments = parameters
59 | .map((param) => ` pipeline.uniforms.u_${param}.value = ${param};`)
60 | .join('\n');
61 | const drawArgsType = `{ t${parameters.length > 0 ? ', ' : ''}${parameters.join(', ')} }`;
62 |
63 | template = template.replace(/{{PASCAL_NAME}}/g, pascalName);
64 | template = template.replace(/{{CAMEL_NAME}}/g, camelName);
65 | template = template.replace('{{PARAMETER_DEFINITIONS}}', parameterDefinitions);
66 | template = template.replace('{{UNIFORMS_OBJECT}}', uniformsObject);
67 | template = template.replace('{{UNIFORM_ASSIGNMENTS}}', uniformAssignments);
68 | template = template.replace('{{DRAW_ARGS_TYPE}}', drawArgsType);
69 |
70 | return template;
71 | }
72 |
73 | function create2DAnimationTemplate(name, parameters) {
74 | const templatePath = path.join(__dirname, 'templates', '2d-animation.tsx');
75 | let template = fs.readFileSync(templatePath, 'utf8');
76 |
77 | const pascalName = toPascalCase(name);
78 |
79 | const parameterDefinitions = parameters
80 | .map((param) => ` ${param}: { min: 0, max: 1, default: 0.5 },`)
81 | .join('\n');
82 | const drawArgsType = `{ ${parameters.join(', ')} }`;
83 | const parameterComments = parameters.join(', ');
84 |
85 | template = template.replace(/{{PASCAL_NAME}}/g, pascalName);
86 | template = template.replace('{{PARAMETER_DEFINITIONS}}', parameterDefinitions);
87 | template = template.replace('{{DRAW_ARGS_TYPE}}', drawArgsType);
88 | template = template.replace('{{PARAMETER_COMMENTS}}', parameterComments);
89 |
90 | return template;
91 | }
92 |
93 | async function createAnimation({ name, type, params }) {
94 | // Sanitize inputs
95 | const finalName = name.replace(/[-_]/g, ' ');
96 | const isShader = type === 'shader';
97 | const parameters = params || [];
98 |
99 | // Validate parameter names
100 | for (const param of parameters) {
101 | if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(param)) {
102 | throw new Error(`Parameter name "${param}" must be alphanumeric and start with a letter`);
103 | }
104 | }
105 |
106 | console.log(
107 | `\n📝 Creating ${isShader ? 'shader' : '2D canvas'} animation "${finalName}" with parameters: [${
108 | parameters.join(', ') || 'none'
109 | }]`,
110 | );
111 |
112 | // Create files
113 | const pascalName = toPascalCase(finalName);
114 | const camelName = toCamelCase(finalName);
115 | const animationDir = path.join(__dirname, '..', 'src', 'animations');
116 | const shaderDir = path.join(animationDir, 'shaders');
117 |
118 | // Create animation file
119 | const animationFile = path.join(animationDir, `${camelName}.tsx`);
120 | if (fs.existsSync(animationFile)) {
121 | throw new Error(`File ${camelName}.tsx already exists`);
122 | }
123 |
124 | const animationContent = isShader
125 | ? createShaderAnimationTemplate(finalName, parameters)
126 | : create2DAnimationTemplate(finalName, parameters);
127 |
128 | fs.writeFileSync(animationFile, animationContent);
129 | console.log(`✅ Created ${animationFile}`);
130 |
131 | // Create shader file if needed
132 | if (isShader) {
133 | const shaderFile = path.join(shaderDir, `${camelName}.glsl`);
134 | if (fs.existsSync(shaderFile)) {
135 | throw new Error(`File ${camelName}.glsl already exists`);
136 | }
137 |
138 | const shaderContent = createShaderTemplate(finalName, parameters);
139 | fs.writeFileSync(shaderFile, shaderContent);
140 | console.log(`✅ Created ${shaderFile}`);
141 | }
142 |
143 | console.log('\n🎉 Animation created successfully!');
144 | console.log('\nNext steps:');
145 | console.log(`1. Import and add ${pascalName} to your animation list`);
146 | console.log(
147 | `2. ${
148 | isShader
149 | ? 'Edit the shader logic in the .glsl file'
150 | : 'Add your drawing commands to the Graphics.draw array'
151 | }`,
152 | );
153 | console.log('3. Customize parameters and add transition functions if needed');
154 | console.log('4. Run `pnpm start` to see your animation');
155 | }
156 |
157 | async function promptForMissing(options) {
158 | const questions = [];
159 |
160 | // Prompt for name if not provided
161 | if (!options.name) {
162 | questions.push({
163 | type: 'input',
164 | name: 'name',
165 | message: 'Animation name:',
166 | default: () => {
167 | const randomHash = Math.random().toString(36).substring(2, 6);
168 | return `new animation ${randomHash}`;
169 | },
170 | });
171 | }
172 |
173 | // Prompt for type if not provided
174 | if (!options.type) {
175 | questions.push({
176 | type: 'list',
177 | name: 'type',
178 | message: 'Select animation type:',
179 | choices: [
180 | { name: 'Shader animation (WebGL/GLSL)', value: 'shader' },
181 | { name: '2D Canvas animation', value: '2d' },
182 | ],
183 | });
184 | }
185 |
186 | // Prompt for params if not provided
187 | if (!options.params) {
188 | questions.push({
189 | type: 'input',
190 | name: 'paramInput',
191 | message: 'Parameters (comma-separated, or press Enter for none):',
192 | default: '',
193 | });
194 | }
195 |
196 | // Only prompt if there are questions to ask
197 | if (questions.length > 0) {
198 | console.log('🎨 Animation Generator');
199 | console.log('===================\n');
200 |
201 | const answers = await inquirer.prompt(questions);
202 |
203 | // Merge answers with existing options
204 | return {
205 | name: options.name || answers.name,
206 | type: options.type || answers.type,
207 | params: options.params || (answers.paramInput
208 | ? answers.paramInput.split(',').map(p => p.trim()).filter(p => p)
209 | : []),
210 | };
211 | }
212 |
213 | return options;
214 | }
215 |
216 | async function main() {
217 | const program = new Command();
218 |
219 | program
220 | .name('new-animation')
221 | .description('Create new animation components')
222 | .option('-n, --name ', 'animation name')
223 | .option('-t, --type ', 'animation type (shader, 2d, canvas, 1, 2)')
224 | .option('-p, --params ', 'comma-separated parameter names')
225 | .parse();
226 |
227 | let options = program.opts();
228 |
229 | // Parse and validate type if provided
230 | if (options.type) {
231 | const typeMap = {
232 | 'shader': 'shader',
233 | '1': 'shader',
234 | '2d': '2d',
235 | 'canvas': '2d',
236 | '2': '2d'
237 | };
238 |
239 | const type = typeMap[options.type.toLowerCase()];
240 | if (!type) {
241 | console.error('❌ Invalid type. Use "shader", "2d", "canvas", "1", or "2"');
242 | process.exit(1);
243 | }
244 | options.type = type;
245 | }
246 |
247 | // Parse parameters if provided
248 | if (options.params) {
249 | options.params = options.params.split(',').map(p => p.trim()).filter(p => p);
250 | }
251 |
252 | // Prompt for any missing options
253 | options = await promptForMissing(options);
254 |
255 | // Check for existing files and handle overwrite
256 | const camelName = toCamelCase(options.name);
257 | const animationFile = path.join(__dirname, '..', 'src', 'animations', `${camelName}.tsx`);
258 | const shaderFile = path.join(__dirname, '..', 'src', 'animations', 'shaders', `${camelName}.glsl`);
259 |
260 | const existingFiles = [];
261 | if (fs.existsSync(animationFile)) existingFiles.push(`${camelName}.tsx`);
262 | if (options.type === 'shader' && fs.existsSync(shaderFile)) existingFiles.push(`${camelName}.glsl`);
263 |
264 | if (existingFiles.length > 0) {
265 | const { overwrite } = await inquirer.prompt([{
266 | type: 'confirm',
267 | name: 'overwrite',
268 | message: `Files already exist: ${existingFiles.join(', ')}. Overwrite?`,
269 | default: false,
270 | }]);
271 |
272 | if (!overwrite) {
273 | console.log('❌ Cancelled');
274 | process.exit(0);
275 | }
276 |
277 | // Remove existing files
278 | existingFiles.forEach(file => {
279 | const fullPath = file.endsWith('.glsl') ? shaderFile : animationFile;
280 | if (fs.existsSync(fullPath)) {
281 | fs.unlinkSync(fullPath);
282 | }
283 | });
284 | }
285 |
286 | // Create the animation
287 | try {
288 | await createAnimation({
289 | name: options.name,
290 | type: options.type,
291 | params: options.params,
292 | });
293 | } catch (error) {
294 | console.error('❌ Error:', error.message);
295 | process.exit(1);
296 | }
297 | }
298 |
299 | main().catch(console.error);
--------------------------------------------------------------------------------
/src/lib/Animation.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useCallback, useEffect, useRef, useState } from 'react';
3 | import { IconContext } from 'react-icons';
4 | import { FaLock, FaLockOpen, FaPause, FaPlay, FaStepBackward } from 'react-icons/fa';
5 | import { useDebouncedCallback } from 'use-debounce';
6 |
7 | // Type-safe parameter system
8 | export interface ParameterDef {
9 | min: number;
10 | max: number;
11 | default?: number;
12 | step?: number;
13 | compute?: (t: number) => number;
14 | }
15 |
16 | export type ParameterConfig = Record;
17 |
18 | // Infer DrawArgs type from parameter config
19 | export type DrawArgs = {
20 | t: number;
21 | } & {
22 | [K in keyof T]: number;
23 | };
24 |
25 | // Type-safe function types
26 | export type DrawFn = (args: DrawArgs) => void;
27 | export type MakeDrawFn = (canvas: HTMLCanvasElement) => DrawFn;
28 |
29 | export interface AnimationOptions {
30 | duration: number;
31 | initialCanvasWidth: number;
32 | initialCanvasHeight: number;
33 | pixelRatio?: number;
34 | makeDrawFn: MakeDrawFn;
35 | parameters: T;
36 | enableTimeControl?: boolean;
37 | }
38 |
39 | interface CanvasDims {
40 | width: number;
41 | height: number;
42 | arLocked: boolean; // Whether the aspect ratio is locked.
43 | }
44 |
45 | export const Animation = (props: AnimationOptions) => {
46 | const enableTimeControl = props.enableTimeControl === undefined ? true : props.enableTimeControl;
47 | const [canvasDims, setCanvasDims] = useState({
48 | width: props.initialCanvasWidth,
49 | height: props.initialCanvasHeight,
50 | arLocked: true,
51 | });
52 | const [drawFn, setDrawFn] = useState | null>(null);
53 | const [controlMode, setControlMode] = useState('user' as 'playing' | 'user' | 'recording');
54 | const computeParamValues = (t: number): Record =>
55 | Object.fromEntries(
56 | Object.entries(props.parameters)
57 | .filter(([, def]) => def.compute !== undefined)
58 | .map(([name, def]) => [name, def.compute!(t)]),
59 | );
60 | const initialDrawArgs: DrawArgs = {
61 | t: 0,
62 | ...computeParamValues(0),
63 | ...Object.fromEntries(
64 | Object.entries(props.parameters)
65 | .filter(([, def]) => def.compute === undefined)
66 | .map(([name, def]) => [name, def.default !== undefined ? def.default : def.min]),
67 | ),
68 | } as DrawArgs;
69 | const drawArgs = useRef>(initialDrawArgs);
70 | const lastDrawArgs = useRef | null>(null);
71 | const requestAnimationRef = useRef(0);
72 | const prevWindowTimeRef = useRef(null);
73 | const frameTimes = useRef([]); // Record how long frames are taking to draw for fps computations.
74 | const [fps, setFps] = useState(0.0);
75 | const [drawArgsUI, setDrawArgsUI] = useState>(initialDrawArgs);
76 | const canvasElement = useRef();
77 | const CCaptureObj = useRef();
78 |
79 | const setupCanvas = useCallback((canvas: HTMLCanvasElement | null) => {
80 | if (!canvas) {
81 | return;
82 | }
83 | canvasElement.current = canvas;
84 | setDrawFn(() => props.makeDrawFn(canvas));
85 | }, []);
86 |
87 | const updateUI = useCallback(() => {
88 | const sumFrameTime = frameTimes.current.reduce((acc, time) => acc + time, 0);
89 | if (sumFrameTime < 30) {
90 | return;
91 | }
92 | const averageFrameTime = sumFrameTime / frameTimes.current.length;
93 | const fps = 1000 / averageFrameTime;
94 | while (frameTimes.current.length > 0) {
95 | frameTimes.current.pop();
96 | }
97 | setDrawArgsUI({ ...drawArgs.current });
98 | setFps(fps);
99 | }, []);
100 |
101 | useEffect(() => {
102 | if (controlMode == 'user') {
103 | drawArgs.current = { ...drawArgsUI };
104 | }
105 | }, [drawArgsUI, controlMode]);
106 |
107 | useEffect(() => {
108 | if (!drawFn) {
109 | return;
110 | }
111 |
112 | if (controlMode == 'recording' && canvasElement.current) {
113 | // @ts-ignore
114 | CCaptureObj.current = new CCapture({ format: 'webm', framerate: 60, name: 'export' });
115 | CCaptureObj.current.start();
116 | drawArgs.current.t = 0.0;
117 | prevWindowTimeRef.current = null;
118 | }
119 |
120 | const animationFrame = (windowTime: number) => {
121 | if (controlMode == 'playing' || controlMode == 'recording') {
122 | if (prevWindowTimeRef.current) {
123 | const deltaTime = windowTime - prevWindowTimeRef.current;
124 | frameTimes.current.push(deltaTime);
125 | updateUI();
126 | drawArgs.current.t += deltaTime / 1000;
127 | if (drawArgs.current.t > props.duration) {
128 | if (controlMode == 'recording' && CCaptureObj.current) {
129 | CCaptureObj.current.stop();
130 | CCaptureObj.current.save();
131 | CCaptureObj.current = null;
132 | setControlMode('user');
133 | }
134 | drawArgs.current.t = 0;
135 | }
136 | }
137 | const t = drawArgs.current.t;
138 | drawArgs.current = {
139 | ...drawArgs.current,
140 | t,
141 | ...computeParamValues(t),
142 | };
143 | }
144 | prevWindowTimeRef.current = windowTime;
145 | if (
146 | controlMode == 'recording' ||
147 | lastDrawArgs.current === null ||
148 | !areDrawArgsEqual(lastDrawArgs.current, drawArgs.current)
149 | ) {
150 | drawFn(drawArgs.current);
151 |
152 | if (controlMode == 'recording' && CCaptureObj.current) {
153 | CCaptureObj.current.capture(canvasElement.current);
154 | }
155 | }
156 | lastDrawArgs.current = { ...drawArgs.current };
157 | requestAnimationRef.current = requestAnimationFrame(animationFrame);
158 | };
159 |
160 | prevWindowTimeRef.current = null;
161 | animationFrame(0);
162 |
163 | return () => {
164 | cancelAnimationFrame(requestAnimationRef.current);
165 | };
166 | }, [drawFn, controlMode]);
167 |
168 | const onClickPlayPause = useCallback(() => {
169 | if (controlMode == 'playing') {
170 | setControlMode('user');
171 | } else {
172 | setControlMode('playing');
173 | }
174 | }, [controlMode]);
175 |
176 | const onClickReset = useCallback(() => {
177 | if (controlMode == 'playing') {
178 | setControlMode('user');
179 | }
180 | setDrawArgsUI((old) => ({ ...old, t: 0.0, ...computeParamValues(0) }));
181 | }, [controlMode]);
182 |
183 | const onClickRecord = useCallback(() => {
184 | setControlMode('recording');
185 | }, []);
186 |
187 | const onClickCancelRecord = useCallback(() => {
188 | drawArgs.current.t = props.duration;
189 | }, []);
190 |
191 | const resizeCanvas = useDebouncedCallback(() => {
192 | if (canvasElement.current === undefined) {
193 | return;
194 | }
195 | if (canvasElement.current.width == canvasDims.width && canvasElement.current.height == canvasDims.height) {
196 | return;
197 | }
198 | canvasElement.current.width = canvasDims.width;
199 | canvasElement.current.height = canvasDims.height;
200 | lastDrawArgs.current = null;
201 | setDrawFn(() => props.makeDrawFn(canvasElement.current!));
202 | }, 500);
203 |
204 | useEffect(() => {
205 | resizeCanvas();
206 | }, [canvasDims]);
207 |
208 | const pixelRatio = props.pixelRatio || window.devicePixelRatio;
209 |
210 | const setParam = (value: number, param: { name: string; def: ParameterDef }): void => {
211 | if (param.def.compute || controlMode == 'user') {
212 | setDrawArgsUI((old) => {
213 | return {
214 | ...old,
215 | ...Object.fromEntries([[param.name, value]]),
216 | };
217 | });
218 | } else {
219 | (drawArgs.current as any)[param.name] = value;
220 | }
221 | };
222 |
223 | const timeParameterDef: ParameterDef = {
224 | compute: (t) => t,
225 | default: 0.0,
226 | min: 0.0,
227 | max: props.duration,
228 | step: 0.01,
229 | };
230 |
231 | return (
232 |
233 |
234 | {/* canvas */}
235 |
236 |
237 |
238 |
248 |
249 |
250 | {/* controls */}
251 |
252 |
253 |
254 |
255 |
256 |
257 | {controlMode != 'recording' ? (
258 |
264 | ) : (
265 |
271 | )}
272 |
273 | {fps.toFixed(1)} fps
274 |
275 |
276 |
277 |
282 | setDrawArgsUI((old) => {
283 | return {
284 | ...old,
285 | t: value,
286 | ...computeParamValues(value),
287 | };
288 | })
289 | }
290 | disabled={controlMode != 'user' || !enableTimeControl}
291 | >
292 |
293 |
300 |
307 |
308 |
309 | {Object.entries(props.parameters).map(([name, def]) => (
310 |
]}
314 | disabled={def.compute !== undefined && controlMode != 'user'}
315 | onChange={(value) => setParam(value, { name, def })}
316 | key={name}
317 | >
318 |
321 |
322 | ))}
323 |
324 |
325 |
326 |
327 | );
328 | };
329 |
330 | const CanvasDimControls = ({
331 | canvasDims,
332 | setCanvasDims,
333 | }: {
334 | canvasDims: CanvasDims;
335 | setCanvasDims: React.Dispatch>;
336 | }) => {
337 | const updateCanvasHeight = useCallback(
338 | (value: string) => {
339 | const newHeight = Number(value);
340 | let newWidth = canvasDims.width;
341 | if (canvasDims.arLocked) {
342 | const ar = canvasDims.width / canvasDims.height;
343 | newWidth = Math.round(newHeight * ar);
344 | }
345 | setCanvasDims((old) => ({
346 | ...old,
347 | width: newWidth,
348 | height: newHeight,
349 | }));
350 | },
351 | [canvasDims],
352 | );
353 |
354 | const updateCanvasWidth = useCallback(
355 | (value: string) => {
356 | const newWidth = Number(value);
357 | let newHeight = canvasDims.height;
358 | if (canvasDims.arLocked) {
359 | const arInv = canvasDims.height / canvasDims.width;
360 | newHeight = Math.round(newWidth * arInv);
361 | }
362 | setCanvasDims((old) => ({
363 | ...old,
364 | width: newWidth,
365 | height: newHeight,
366 | }));
367 | },
368 | [canvasDims],
369 | );
370 |
371 | const toggleArLocked = useCallback(() => {
372 | setCanvasDims((old) => ({
373 | ...old,
374 | arLocked: !canvasDims.arLocked,
375 | }));
376 | }, [canvasDims]);
377 |
378 | return (
379 |
380 | updateCanvasWidth(e.target.value)}
386 | />
387 | ×
388 | updateCanvasHeight(e.target.value)}
394 | />
395 |
398 |
399 | );
400 | };
401 |
402 | const ParamController = ({
403 | children,
404 | name,
405 | def,
406 | value,
407 | disabled,
408 | onChange,
409 | }: {
410 | children: React.ReactNode;
411 | name: string;
412 | def: ParameterDef;
413 | value: number;
414 | disabled: boolean;
415 | onChange: (value: number) => void;
416 | }) => {
417 | return (
418 |
419 | {children}
420 |
421 | onChange(Number(e.target.value))}
430 | />
431 |
432 |
433 | onChange(Number(e.target.value))}
442 | />
443 |
444 |
445 | );
446 | };
447 |
448 | const areDrawArgsEqual = (args1: DrawArgs, args2: DrawArgs): boolean => {
449 | const keys = Object.keys(args1);
450 | for (const key of keys) {
451 | if (Math.abs(args1[key as keyof DrawArgs] - args2[key as keyof DrawArgs]) > 1e-5) {
452 | return false;
453 | }
454 | }
455 | return true;
456 | };
457 |
--------------------------------------------------------------------------------
/public/CCapture.all.min.js:
--------------------------------------------------------------------------------
1 | "use strict";function download(t,e,i){function n(t){var e=t.split(/[:;,]/),i=e[1],n="base64"==e[2]?atob:decodeURIComponent,r=n(e.pop()),o=r.length,a=0,s=new Uint8Array(o);for(a;a>8,this.data[this.pos++]=t},t.prototype.writeDoubleBE=function(t){for(var e=new Uint8Array(new Float64Array([t]).buffer),i=e.length-1;i>=0;i--)this.writeByte(e[i])},t.prototype.writeFloatBE=function(t){for(var e=new Uint8Array(new Float32Array([t]).buffer),i=e.length-1;i>=0;i--)this.writeByte(e[i])},t.prototype.writeString=function(t){for(var e=0;e>8),this.writeU8(t);break;case 3:this.writeU8(32|t>>16),this.writeU8(t>>8),this.writeU8(t);break;case 4:this.writeU8(16|t>>24),this.writeU8(t>>16),this.writeU8(t>>8),this.writeU8(t);break;case 5:this.writeU8(8|t/4294967296&7),this.writeU8(t>>24),this.writeU8(t>>16),this.writeU8(t>>8),this.writeU8(t);break;default:throw new RuntimeException("Bad EBML VINT size "+e)}},t.prototype.measureEBMLVarInt=function(t){if(t<127)return 1;if(t<16383)return 2;if(t<2097151)return 3;if(t<268435455)return 4;if(t<34359738367)return 5;throw new RuntimeException("EBML VINT size not supported "+t)},t.prototype.writeEBMLVarInt=function(t){this.writeEBMLVarIntWidth(t,this.measureEBMLVarInt(t))},t.prototype.writeUnsignedIntBE=function(t,e){switch(void 0===e&&(e=this.measureUnsignedInt(t)),e){case 5:this.writeU8(Math.floor(t/4294967296));case 4:this.writeU8(t>>24);case 3:this.writeU8(t>>16);case 2:this.writeU8(t>>8);case 1:this.writeU8(t);break;default:throw new RuntimeException("Bad UINT size "+e)}},t.prototype.measureUnsignedInt=function(t){return t<256?1:t<65536?2:t<1<<24?3:t<4294967296?4:5},t.prototype.getAsDataArray=function(){if(this.posthis.length)throw"Seeking beyond the end of file is not allowed";this.pos=t},this.write=function(e){var i={offset:this.pos,data:e,length:r(e)},f=i.offset>=this.length;this.pos+=i.length,this.length=Math.max(this.length,this.pos),a=a.then(function(){if(h)return new Promise(function(e,r){n(i.data).then(function(n){var r=0,o=Buffer.from(n.buffer),a=function(n,o,s){r+=o,r>=s.length?e():t.write(h,s,r,s.length-r,i.offset+r,a)};t.write(h,o,0,o.length,i.offset,a)})});if(s)return new Promise(function(t,e){s.onwriteend=t,s.seek(i.offset),s.write(new Blob([i.data]))});if(!f)for(var e=0;e=r.offset+r.length)){if(i.offsetr.offset+r.length)throw new Error("Overwrite crosses blob boundaries");return i.offset==r.offset&&i.length==r.length?void(r.data=i.data):n(r.data).then(function(t){return r.data=t,n(i.data)}).then(function(t){i.data=t,r.data.set(i.data,i.offset-r.offset)})}}o.push(i)})},this.complete=function(t){return a=h||s?a.then(function(){return null}):a.then(function(){for(var e=[],i=0;i0&&e.trackNumber<127))throw"TrackNumber must be > 0 and < 127";return i.writeEBMLVarInt(e.trackNumber),i.writeU16BE(e.timecode),i.writeByte(128),{id:163,data:[i.getAsDataArray(),e.frame]}}function l(t){return{id:524531317,data:[{id:231,data:Math.round(t.timecode)}]}}function c(t,e,i){_.push({id:187,data:[{id:179,data:e},{id:183,data:[{id:247,data:t},{id:241,data:a(i)}]}]})}function p(){var e={id:475249515,data:_},i=new t(16+32*_.length);h(i,S.pos,e),S.write(i.getAsDataArray()),D.Cues.positionEBML.data=a(e.offset)}function m(){if(0!=T.length){for(var e=0,i=0;i=E&&m()}function y(){var e=new t(x.size),i=S.pos;h(e,x.dataOffset,x.data),S.seek(x.dataOffset),S.write(e.getAsDataArray()),S.seek(i)}function v(){var e=new t(8),i=S.pos;e.writeDoubleBE(U),S.seek(M.dataOffset),S.write(e.getAsDataArray()),S.seek(i)}var b,k,B,x,E=5e3,A=1,L=!1,T=[],U=0,F=0,I={quality:.95,fileWriter:null,fd:null,frameDuration:null,frameRate:null},D={Cues:{id:new Uint8Array([28,83,187,107]),positionEBML:null},SegmentInfo:{id:new Uint8Array([21,73,169,102]),positionEBML:null},Tracks:{id:new Uint8Array([22,84,174,107]),positionEBML:null}},M={id:17545,data:new s(0)},_=[],S=new e(n.fileWriter||n.fd);this.addFrame=function(t){if(L){if(t.width!=b||t.height!=k)throw"Frame size differs from previous frames"}else b=t.width,k=t.height,u(),L=!0;var e=r(t,{quality:n.quality});if(!e)throw"Couldn't decode WebP frame, does the browser support WebP?";g({frame:o(e),duration:n.frameDuration})},this.complete=function(){return m(),p(),y(),v(),S.complete("video/webm")},this.getWrittenSize=function(){return S.length},n=i(I,n||{}),w()}};"undefined"!=typeof module&&"undefined"!=typeof module.exports?module.exports=t(require("./ArrayBufferDataStream"),require("./BlobBuffer")):window.WebMWriter=t(ArrayBufferDataStream,BlobBuffer)}(),function(){function t(t){var e,i=new Uint8Array(t);for(e=0;e>18&63]+o[t>>12&63]+o[t>>6&63]+o[63&t]}var i,n,r,a=t.length%3,s="";for(i=0,r=t.length-a;in&&(e.push({blocks:o,length:i}),o=[],i=0),o.push(t),i+=t.headerLength+t.inputLength}),e.push({blocks:o,length:i}),e.forEach(function(e){var i=new Uint8Array(e.length),n=0;e.blocks.forEach(function(t){i.set(t.header,n),n+=t.headerLength,i.set(t.input,n),n+=t.inputLength}),t.push(i)}),t.push(new Uint8Array(2*r)),new Blob(t,{type:"octet/stream"})},t.prototype.clear=function(){this.written=0,this.out=n.clean(e)},window.Tar=t}(),function(t){function e(t,i){if({}.hasOwnProperty.call(e.cache,t))return e.cache[t];var n=e.resolve(t);if(!n)throw new Error("Failed to resolve module "+t);var r={id:t,require:e,filename:t,exports:{},loaded:!1,parent:i,children:[]};i&&i.children.push(r);var o=t.slice(0,t.lastIndexOf("/")+1);return e.cache[t]=r.exports,n.call(r.exports,r,r.exports,o,t),r.loaded=!0,e.cache[t]=r.exports}e.modules={},e.cache={},e.resolve=function(t){return{}.hasOwnProperty.call(e.modules,t)?e.modules[t]:void 0},e.define=function(t,i){e.modules[t]=i};var i=function(e){return e="/",{title:"browser",version:"v0.10.26",browser:!0,env:{},argv:[],nextTick:t.setImmediate||function(t){setTimeout(t,0)},cwd:function(){return e},chdir:function(t){e=t}}}();e.define("/gif.coffee",function(t,i,n,r){function o(t,e){return{}.hasOwnProperty.call(t,e)}function a(t,e){for(var i=0,n=e.length;ithis.frames.length;0<=this.frames.length?++e:--e)t.push(e);return t}.apply(this,arguments),n=0,r=i.length;ne;0<=e?++i:--i)t.push(i);return t}.apply(this,arguments),n=0,r=i.length;nt;this.freeWorkers.length<=t?++i:--i)e.push(i);return e}.apply(this,arguments).forEach(function(t){return function(e){var i;return console.log("spawning worker "+e),i=new Worker(t.options.workerScript),i.onmessage=function(t){return function(e){return t.activeWorkers.splice(t.activeWorkers.indexOf(i),1),t.freeWorkers.push(i),t.frameFinished(e.data)}}(t),t.freeWorkers.push(i)}}(this)),t},e.prototype.frameFinished=function(t){return console.log("frame "+t.index+" finished - "+this.activeWorkers.length+" active"),this.finishedFrames++,this.emit("progress",this.finishedFrames/this.frames.length),this.imageParts[t.index]=t,a(null,this.imageParts)?this.renderNextFrame():this.finishRendering()},e.prototype.finishRendering=function(){var t,e,i,n,r,o,a;r=0;for(var s=0,h=this.imageParts.length;s=this.frames.length?void 0:(t=this.frames[this.nextFrame++],i=this.freeWorkers.shift(),e=this.getTask(t),console.log("starting frame "+(e.index+1)+" of "+this.frames.length),this.activeWorkers.push(i),i.postMessage(e))},e.prototype.getContextData=function(t){return t.getImageData(0,0,this.options.width,this.options.height).data},e.prototype.getImageData=function(t){var e;return null!=this._canvas||(this._canvas=document.createElement("canvas"),this._canvas.width=this.options.width,this._canvas.height=this.options.height),e=this._canvas.getContext("2d"),e.setFill=this.options.background,e.fillRect(0,0,this.options.width,this.options.height),e.drawImage(t,0,0),this.getContextData(e)},e.prototype.getTask=function(t){var e,i;if(e=this.frames.indexOf(t),i={index:e,last:e===this.frames.length-1,delay:t.delay,transparent:t.transparent,width:this.options.width,height:this.options.height,quality:this.options.quality,repeat:this.options.repeat,canTransfer:"chrome"===h.name},null!=t.data)i.data=t.data;else if(null!=t.context)i.data=this.getContextData(t.context);else{if(null==t.image)throw new Error("Invalid frame");i.data=this.getImageData(t.image)}return i},e}(u),t.exports=l}),e.define("/browser.coffee",function(t,e,i,n){var r,o,a,s,h;s=navigator.userAgent.toLowerCase(),a=navigator.platform.toLowerCase(),h=s.match(/(opera|ie|firefox|chrome|version)[\s\/:]([\w\d\.]+)?.*?(safari|version[\s\/:]([\w\d\.]+)|$)/)||[null,"unknown",0],o="ie"===h[1]&&document.documentMode,r={name:"version"===h[1]?h[3]:h[1],version:o||parseFloat("opera"===h[1]&&h[4]?h[4]:h[2]),platform:{name:s.match(/ip(?:ad|od|hone)/)?"ios":(s.match(/(?:webos|android)/)||a.match(/mac|win|linux/)||["other"])[0]}},r[r.name]=!0,r[r.name+parseInt(r.version,10)]=!0,r.platform[r.platform.name]=!0,t.exports=r}),e.define("events",function(t,e,n,r){i.EventEmitter||(i.EventEmitter=function(){});var o=e.EventEmitter=i.EventEmitter,a="function"==typeof Array.isArray?Array.isArray:function(t){return"[object Array]"===Object.prototype.toString.call(t)},s=10;o.prototype.setMaxListeners=function(t){this._events||(this._events={}),this._events.maxListeners=t},o.prototype.emit=function(t){if("error"===t&&(!this._events||!this._events.error||a(this._events.error)&&!this._events.error.length))throw arguments[1]instanceof Error?arguments[1]:new Error("Uncaught, unspecified 'error' event.");if(!this._events)return!1;var e=this._events[t];if(!e)return!1;if("function"!=typeof e){if(a(e)){for(var i=Array.prototype.slice.call(arguments,1),n=e.slice(),r=0,o=n.length;r0&&this._events[t].length>i&&(this._events[t].warned=!0,console.error("(node) warning: possible EventEmitter memory leak detected. %d listeners added. Use emitter.setMaxListeners() to increase limit.",this._events[t].length),console.trace())}this._events[t].push(e)}else this._events[t]=[this._events[t],e];else this._events[t]=e;return this},o.prototype.on=o.prototype.addListener,o.prototype.once=function(t,e){var i=this;return i.on(t,function n(){i.removeListener(t,n),e.apply(this,arguments)}),this},o.prototype.removeListener=function(t,e){if("function"!=typeof e)throw new Error("removeListener only takes instances of Function");if(!this._events||!this._events[t])return this;var i=this._events[t];if(a(i)){var n=i.indexOf(e);if(n<0)return this;i.splice(n,1),0==i.length&&delete this._events[t]}else this._events[t]===e&&delete this._events[t];return this},o.prototype.removeAllListeners=function(t){return t&&this._events&&this._events[t]&&(this._events[t]=null),this},o.prototype.listeners=function(t){return this._events||(this._events={}),this._events[t]||(this._events[t]=[]),a(this._events[t])||(this._events[t]=[this._events[t]]),this._events[t]}}),t.GIF=e("/gif.coffee")}.call(this,this),function(){function t(t){return t&&t.Object===Object?t:null}function e(t){return String("0000000"+t).slice(-7)}function i(){function t(){return Math.floor(65536*(1+Math.random())).toString(16).substring(1)}return t()+t()+"-"+t()+"-"+t()+"-"+t()+"-"+t()+t()+t()}function n(t){var e={};this.settings=t,this.on=function(t,i){e[t]=i},this.emit=function(t){var i=e[t];i&&i.apply(null,Array.prototype.slice.call(arguments,1))},this.filename=t.name||i(),this.extension="",this.mimeType=""}function r(t){n.call(this,t),this.extension=".tar",this.mimeType="application/x-tar",this.fileExtension="",this.baseFilename=this.filename,this.tape=null,this.count=0,this.part=1,this.frames=0}function o(t){r.call(this,t),this.type="image/png",this.fileExtension=".png"}function a(t){r.call(this,t),this.type="image/jpeg",this.fileExtension=".jpg",this.quality=t.quality/100||.8}function s(t){var e=document.createElement("canvas");"image/webp"!==e.toDataURL("image/webp").substr(5,10)&&console.log("WebP not supported - try another export format"),n.call(this,t),this.quality=t.quality/100||.8,this.extension=".webm",this.mimeType="video/webm",this.baseFilename=this.filename,this.framerate=t.framerate,this.frames=0,this.part=1,this.videoWriter=new WebMWriter({quality:this.quality,fileWriter:null,fd:null,frameRate:this.framerate})}function h(t){n.call(this,t),t.quality=t.quality/100||.8,this.encoder=new FFMpegServer.Video(t),this.encoder.on("process",function(){this.emit("process")}.bind(this)),this.encoder.on("finished",function(t,e){var i=this.callback;i&&(this.callback=void 0,i(t,e))}.bind(this)),this.encoder.on("progress",function(t){this.settings.onProgress&&this.settings.onProgress(t)}.bind(this)),this.encoder.on("error",function(t){alert(JSON.stringify(t,null,2))}.bind(this))}function f(t){n.call(this,t),this.framerate=this.settings.framerate,this.type="video/webm",this.extension=".webm",this.stream=null,this.mediaRecorder=null,this.chunks=[]}function u(t){n.call(this,t),t.quality=31-(30*t.quality/100||10),t.workers=t.workers||4,this.extension=".gif",this.mimeType="image/gif",this.canvas=document.createElement("canvas"),this.ctx=this.canvas.getContext("2d"),this.sizeSet=!1,this.encoder=new GIF({workers:t.workers,quality:t.quality,workerScript:t.workersPath+"gif.worker.js"}),this.encoder.on("progress",function(t){this.settings.onProgress&&this.settings.onProgress(t)}.bind(this)),this.encoder.on("finished",function(t){var e=this.callback;e&&(this.callback=void 0,e(t))}.bind(this))}function d(t){function e(){function t(){return this._hooked||(this._hooked=!0,this._hookedTime=this.currentTime||0,this.pause(),it.push(this)),this._hookedTime+M.startTime}b("Capturer start"),U=window.Date.now(),T=U+M.startTime,I=window.performance.now(),F=I+M.startTime,window.Date.prototype.getTime=function(){return T},window.Date.now=function(){return T},window.setTimeout=function(t,e){var i={callback:t,time:e,triggerTime:T+e};return _.push(i),b("Timeout set to "+i.time),i},window.clearTimeout=function(t){for(var e=0;e<_.length;e++)_[e]!=t||(_.splice(e,1),b("Timeout cleared"))},window.setInterval=function(t,e){var i={callback:t,time:e,triggerTime:T+e};return S.push(i),b("Interval set to "+i.time),i},window.clearInterval=function(t){return b("clear Interval"),null},window.requestAnimationFrame=function(t){W.push(t)},window.performance.now=function(){return F};try{Object.defineProperty(HTMLVideoElement.prototype,"currentTime",{get:t}),Object.defineProperty(HTMLAudioElement.prototype,"currentTime",{get:t})}catch(t){b(t)}}function i(){e(),D.start(),R=!0}function n(){R=!1,D.stop(),l()}function r(t,e){Z(t,0,e)}function d(){r(y)}function l(){b("Capturer stop"),window.setTimeout=Z,window.setInterval=J,window.clearInterval=Y,window.clearTimeout=$,window.requestAnimationFrame=Q,window.Date.prototype.getTime=et,window.Date.now=X,window.performance.now=tt}function c(){var t=C/M.framerate;(M.frameLimit&&C>=M.frameLimit||M.timeLimit&&t>=M.timeLimit)&&(n(),v());var e=new Date(null);e.setSeconds(t),M.motionBlurFrames>2?j.textContent="CCapture "+M.format+" | "+C+" frames ("+O+" inter) | "+e.toISOString().substr(11,8):j.textContent="CCapture "+M.format+" | "+C+" frames | "+e.toISOString().substr(11,8)}function p(t){N.width===t.width&&N.height===t.height||(N.width=t.width,N.height=t.height,z=new Uint16Array(N.height*N.width*4),V.fillStyle="#0",V.fillRect(0,0,N.width,N.height))}function m(t){V.drawImage(t,0,0),q=V.getImageData(0,0,N.width,N.height);for(var e=0;e2?(p(t),m(t),O>=.5*M.motionBlurFrames?w():d()):(D.add(t),C++,b("Full Frame! "+C)))}function y(){var t=1e3/M.framerate,e=(C+O/M.motionBlurFrames)*t;T=U+e,F=I+e,it.forEach(function(t){t._hookedTime=e/1e3}),c(),b("Frame: "+C+" "+O);for(var i=0;i<_.length;i++)T>=_[i].triggerTime&&(r(_[i].callback),_.splice(i,1));for(var i=0;i=S[i].triggerTime&&(r(S[i].callback),S[i].triggerTime+=S[i].time);W.forEach(function(t){r(t,T-k)}),W=[]}function v(t){t||(t=function(t){return download(t,D.filename+D.extension,D.mimeType),!1}),D.save(t)}function b(t){A&&console.log(t)}function B(t,e){P[t]=e}function x(t){var e=P[t];e&&e.apply(null,Array.prototype.slice.call(arguments,1))}function E(t){x("progress",t)}var A,L,T,U,F,I,d,D,M=t||{},_=(new Date,[]),S=[],C=0,O=0,W=[],R=!1,P={};M.framerate=M.framerate||60,M.motionBlurFrames=2*(M.motionBlurFrames||1),A=M.verbose||!1,L=M.display||!1,M.step=1e3/M.framerate,M.timeLimit=M.timeLimit||0,M.frameLimit=M.frameLimit||0,M.startTime=M.startTime||0;var j=document.createElement("div");j.style.position="absolute",j.style.left=j.style.top=0,j.style.backgroundColor="black",j.style.fontFamily="monospace",j.style.fontSize="11px",j.style.padding="5px",j.style.color="red",j.style.zIndex=1e5,M.display&&document.body.appendChild(j);var z,q,N=document.createElement("canvas"),V=N.getContext("2d");b("Step is set to "+M.step+"ms");var G={gif:u,webm:s,ffmpegserver:h,png:o,jpg:a,"webm-mediarecorder":f},H=G[M.format];if(!H)throw"Error: Incorrect or missing format: Valid formats are "+Object.keys(G).join(", ");if(D=new H(M),D.step=d,D.on("process",y),D.on("progress",E),"performance"in window==0&&(window.performance={}),Date.now=Date.now||function(){return(new Date).getTime()},"now"in window.performance==0){var K=Date.now();performance.timing&&performance.timing.navigationStart&&(K=performance.timing.navigationStart),window.performance.now=function(){return Date.now()-K}}var Z=window.setTimeout,J=window.setInterval,Y=window.clearInterval,$=window.clearTimeout,Q=window.requestAnimationFrame,X=window.Date.now,tt=window.performance.now,et=window.Date.prototype.getTime,it=[];return{start:i,capture:g,stop:n,save:v,on:B}}var l={function:!0,object:!0},c=(parseFloat,parseInt,l[typeof exports]&&exports&&!exports.nodeType?exports:void 0),p=l[typeof module]&&module&&!module.nodeType?module:void 0,m=p&&p.exports===c?c:void 0,w=t(c&&p&&"object"==typeof global&&global),g=t(l[typeof self]&&self),y=t(l[typeof window]&&window),v=t(l[typeof this]&&this),b=w||y!==(v&&v.window)&&y||g||v||Function("return this")();"gc"in window||(window.gc=function(){}),HTMLCanvasElement.prototype.toBlob||Object.defineProperty(HTMLCanvasElement.prototype,"toBlob",{value:function(t,e,i){for(var n=atob(this.toDataURL(e,i).split(",")[1]),r=n.length,o=new Uint8Array(r),a=0;a0&&this.frames/this.settings.framerate>=this.settings.autoSaveTime?this.save(function(t){this.filename=this.baseFilename+"-part-"+e(this.part),download(t,this.filename+this.extension,this.mimeType);var i=this.count;this.dispose(),this.count=i+1,this.part++,this.filename=this.baseFilename+"-part-"+e(this.part),this.frames=0,this.step()}.bind(this)):(this.count++,this.frames++,this.step())}.bind(this),i.readAsArrayBuffer(t)},r.prototype.save=function(t){t(this.tape.save())},r.prototype.dispose=function(){this.tape=new Tar,this.count=0},o.prototype=Object.create(r.prototype),o.prototype.add=function(t){t.toBlob(function(t){r.prototype.add.call(this,t)}.bind(this),this.type)},a.prototype=Object.create(r.prototype),a.prototype.add=function(t){t.toBlob(function(t){r.prototype.add.call(this,t)}.bind(this),this.type,this.quality)},s.prototype=Object.create(n.prototype),s.prototype.start=function(t){this.dispose()},s.prototype.add=function(t){this.videoWriter.addFrame(t),this.settings.autoSaveTime>0&&this.frames/this.settings.framerate>=this.settings.autoSaveTime?this.save(function(t){this.filename=this.baseFilename+"-part-"+e(this.part),download(t,this.filename+this.extension,this.mimeType),this.dispose(),this.part++,this.filename=this.baseFilename+"-part-"+e(this.part),this.step()}.bind(this)):(this.frames++,this.step())},s.prototype.save=function(t){this.videoWriter.complete().then(t)},s.prototype.dispose=function(t){this.frames=0,this.videoWriter=new WebMWriter({quality:this.quality,fileWriter:null,fd:null,frameRate:this.framerate})},h.prototype=Object.create(n.prototype),h.prototype.start=function(){this.encoder.start(this.settings)},h.prototype.add=function(t){this.encoder.add(t)},h.prototype.save=function(t){this.callback=t,this.encoder.end()},h.prototype.safeToProceed=function(){return this.encoder.safeToProceed()},f.prototype=Object.create(n.prototype),f.prototype.add=function(t){this.stream||(this.stream=t.captureStream(this.framerate),this.mediaRecorder=new MediaRecorder(this.stream),this.mediaRecorder.start(),this.mediaRecorder.ondataavailable=function(t){this.chunks.push(t.data)}.bind(this)),this.step()},f.prototype.save=function(t){this.mediaRecorder.onstop=function(e){var i=new Blob(this.chunks,{type:"video/webm"});this.chunks=[],t(i)}.bind(this),this.mediaRecorder.stop()},u.prototype=Object.create(n.prototype),u.prototype.add=function(t){this.sizeSet||(this.encoder.setOption("width",t.width),this.encoder.setOption("height",t.height),this.sizeSet=!0),this.canvas.width=t.width,this.canvas.height=t.height,this.ctx.drawImage(t,0,0),this.encoder.addFrame(this.ctx,{copy:!0,delay:this.settings.step}),this.step()},u.prototype.save=function(t){this.callback=t,this.encoder.render()},(y||g||{}).CCapture=d,"function"==typeof define&&"object"==typeof define.amd&&define.amd?define(function(){return d}):c&&p?(m&&((p.exports=d).CCapture=d),c.CCapture=d):b.CCapture=d}();
--------------------------------------------------------------------------------