├── 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 | [![deploy-pages](https://github.com/matthen/animations/actions/workflows/deploy-pages.yaml/badge.svg?branch=main)](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 |
319 |

{name}

320 |
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}(); --------------------------------------------------------------------------------