commit c6863f2bfcdf7a00e6f3a34235e3a9fa2d64c59c Author: th Date: Sun Apr 19 17:53:36 2026 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89f1a15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +dist +dist-ssr +*.local +.vite + +.DS_Store +*.log diff --git a/animatr_reference/Screenshot from 2026-04-19 15-34-54.png b/animatr_reference/Screenshot from 2026-04-19 15-34-54.png new file mode 100644 index 0000000..5c54f27 Binary files /dev/null and b/animatr_reference/Screenshot from 2026-04-19 15-34-54.png differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7da796f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + animatr-render: + build: ./server + ports: + - "6000:6000" + environment: + - NODE_ENV=production + - PORT=6000 + shm_size: "2gb" + restart: unless-stopped diff --git a/index.html b/index.html new file mode 100644 index 0000000..17c27fe --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Animatr + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..408a060 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1636 @@ +{ + "name": "animatr", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "animatr", + "version": "0.0.0", + "dependencies": { + "@phosphor-icons/vue": "^2.2.1", + "konva": "^9.3.16", + "pinia": "^2.2.6", + "vue": "^3.5.13", + "vue-konva": "^3.1.2" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.6.3", + "vite": "^6.0.3", + "vue-tsc": "^2.1.10" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@phosphor-icons/vue": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@phosphor-icons/vue/-/vue-2.2.1.tgz", + "integrity": "sha512-3RNg1utc2Z5RwPKWFkW3eXI/0BfQAwXgtFxPUPeSzi55jGYUq16b+UqcgbKLazWFlwg5R92OCLKjDiJjeiJcnA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "vue": ">=3.2.39" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/konva": { + "version": "9.3.22", + "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.22.tgz", + "integrity": "sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-konva": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/vue-konva/-/vue-konva-3.4.0.tgz", + "integrity": "sha512-l7yqAPmc6gPbdXzxoukTiXzA6EdqTtPhzQp1pTdppwWLiimPCgFHcqp8nApXQN5dqF11p2OejwjKv150cf9ncA==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT", + "engines": { + "node": ">= 4.0.0", + "npm": ">= 3.0.0" + }, + "peerDependencies": { + "konva": ">7", + "vue": "^3" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6fbdecd --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "animatr", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "type-check": "vue-tsc --noEmit" + }, + "dependencies": { + "@phosphor-icons/vue": "^2.2.1", + "konva": "^9.3.16", + "pinia": "^2.2.6", + "vue": "^3.5.13", + "vue-konva": "^3.1.2" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.6.3", + "vite": "^6.0.3", + "vue-tsc": "^2.1.10" + } +} diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..54e5f26 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,4 @@ +node_modules +npm-debug.log +.env +*.mp4 diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..638c64b --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-slim + +RUN apt-get update && apt-get install -y \ + chromium \ + ffmpeg \ + fonts-liberation \ + fonts-noto-color-emoji \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium +ENV PORT=6000 + +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev +COPY . . + +EXPOSE 6000 +CMD ["node", "server.js"] diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..dade8f8 --- /dev/null +++ b/server/package.json @@ -0,0 +1,16 @@ +{ + "name": "animatr-server", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.21.2", + "puppeteer-core": "^23.11.1" + } +} diff --git a/server/render.html b/server/render.html new file mode 100644 index 0000000..4c1dd90 --- /dev/null +++ b/server/render.html @@ -0,0 +1,144 @@ + + + + + + + + + + + diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..44dda60 --- /dev/null +++ b/server/server.js @@ -0,0 +1,198 @@ +import express from 'express' +import cors from 'cors' +import path from 'node:path' +import fs from 'node:fs/promises' +import os from 'node:os' +import { randomUUID } from 'node:crypto' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import puppeteer from 'puppeteer-core' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const PORT = Number(process.env.PORT || 6000) +const CHROME_PATH = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium' +const RENDER_HTML = path.join(__dirname, 'render.html') + +const app = express() +app.use(cors()) +app.use(express.json({ limit: '200mb' })) + +/** @type {Map} */ +const jobs = new Map() + +app.get('/health', (_req, res) => res.json({ ok: true })) + +app.post('/render', (req, res) => { + const { scene, options } = req.body || {} + if (!scene || !scene.canvas || !Array.isArray(scene.layers)) { + return res.status(400).json({ error: 'invalid scene' }) + } + const job = { + id: randomUUID(), + progress: 0, + done: false, + error: null, + filePath: null, + subscribers: new Set(), + abort: new AbortController(), + } + jobs.set(job.id, job) + res.json({ jobId: job.id }) + runJob(job, scene, options || {}).catch((err) => { + job.error = err?.message || String(err) + job.done = true + notify(job) + }) +}) + +app.get('/render/:id/events', (req, res) => { + const job = jobs.get(req.params.id) + if (!job) return res.status(404).end() + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + const send = () => { + const payload = { + progress: job.progress, + done: job.done, + error: job.error, + url: job.done && !job.error ? `/render/${job.id}/file` : null, + } + res.write(`data: ${JSON.stringify(payload)}\n\n`) + } + job.subscribers.add(send) + send() + const keepalive = setInterval(() => res.write(': keepalive\n\n'), 15000) + req.on('close', () => { + job.subscribers.delete(send) + clearInterval(keepalive) + }) +}) + +app.get('/render/:id/file', (req, res) => { + const job = jobs.get(req.params.id) + if (!job || !job.filePath || job.error) return res.status(404).end() + res.download(job.filePath, 'animation.mp4') +}) + +app.delete('/render/:id', (req, res) => { + const job = jobs.get(req.params.id) + if (!job) return res.status(404).end() + job.abort.abort() + res.json({ ok: true }) +}) + +function notify(job) { + for (const sub of Array.from(job.subscribers)) { + try { sub() } catch { /* noop */ } + } +} + +function effectiveEnd(scene) { + let max = 0 + for (const layer of scene.layers) { + for (const kf of layer.keyframes) { + if (kf.time > max) max = kf.time + } + } + if (max <= 0) return scene.duration + return Math.min(max, scene.duration) +} + +async function runJob(job, scene, options) { + const fps = Number(options.fps || scene.fps || 60) + const sceneEnd = effectiveEnd(scene) + const duration = Number(options.duration || sceneEnd) + const totalFrames = Math.max(1, Math.round(duration * fps)) + const crf = String(options.crf ?? 18) + const preset = String(options.preset ?? 'fast') + + const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'animatr-')) + const outPath = path.join(workDir, 'out.mp4') + + const browser = await puppeteer.launch({ + executablePath: CHROME_PATH, + headless: 'new', + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--hide-scrollbars', + ], + defaultViewport: { + width: scene.canvas.width, + height: scene.canvas.height, + deviceScaleFactor: 1, + }, + }) + + const ffmpeg = spawn('ffmpeg', [ + '-y', + '-f', 'image2pipe', + '-framerate', String(fps), + '-i', '-', + '-c:v', 'libx264', + '-pix_fmt', 'yuv420p', + '-preset', preset, + '-crf', crf, + '-movflags', '+faststart', + outPath, + ]) + let ffmpegErr = '' + ffmpeg.stderr.on('data', (d) => { ffmpegErr += d.toString() }) + const ffmpegDone = new Promise((resolve, reject) => { + ffmpeg.on('exit', (code) => { + if (code === 0) resolve() + else reject(new Error(`ffmpeg exited with code ${code}: ${ffmpegErr.slice(-500)}`)) + }) + ffmpeg.on('error', reject) + }) + + try { + const page = await browser.newPage() + await page.goto('file://' + RENDER_HTML, { waitUntil: 'domcontentloaded' }) + await page.evaluate((s) => window.__animatrInit(s), scene) + + for (let i = 0; i < totalFrames; i++) { + if (job.abort.signal.aborted) { + try { ffmpeg.kill('SIGKILL') } catch { /* noop */ } + throw new Error('aborted') + } + const t = Math.min(i / fps, duration) + await page.evaluate((time) => window.__animatrSeek(time), t) + const buf = await page.screenshot({ + type: 'png', + omitBackground: false, + clip: { x: 0, y: 0, width: scene.canvas.width, height: scene.canvas.height }, + }) + if (!ffmpeg.stdin.write(buf)) { + await new Promise((resolve) => ffmpeg.stdin.once('drain', resolve)) + } + job.progress = (i + 1) / totalFrames + notify(job) + } + ffmpeg.stdin.end() + await ffmpegDone + job.filePath = outPath + job.done = true + notify(job) + + setTimeout(() => cleanupJob(job.id, workDir).catch(() => {}), 10 * 60 * 1000) + } finally { + await browser.close().catch(() => {}) + } +} + +async function cleanupJob(jobId, workDir) { + jobs.delete(jobId) + await fs.rm(workDir, { recursive: true, force: true }) +} + +app.listen(PORT, () => { + console.log(`animatr render server listening on :${PORT}`) + console.log(`chromium: ${CHROME_PATH}`) +}) diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..b8c7670 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/src/components/AssetSidebar.vue b/src/components/AssetSidebar.vue new file mode 100644 index 0000000..9c8dad1 --- /dev/null +++ b/src/components/AssetSidebar.vue @@ -0,0 +1,275 @@ + + + + + diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue new file mode 100644 index 0000000..75e6d60 --- /dev/null +++ b/src/components/CanvasStage.vue @@ -0,0 +1,581 @@ + + + + + diff --git a/src/components/DocumentSettings.vue b/src/components/DocumentSettings.vue new file mode 100644 index 0000000..ca3366a --- /dev/null +++ b/src/components/DocumentSettings.vue @@ -0,0 +1,540 @@ + + + + + diff --git a/src/components/ExportDialog.vue b/src/components/ExportDialog.vue new file mode 100644 index 0000000..9db7e12 --- /dev/null +++ b/src/components/ExportDialog.vue @@ -0,0 +1,460 @@ + + + + + diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue new file mode 100644 index 0000000..b888286 --- /dev/null +++ b/src/components/PropertiesPanel.vue @@ -0,0 +1,288 @@ + + + + + diff --git a/src/components/RangeSlider.vue b/src/components/RangeSlider.vue new file mode 100644 index 0000000..70f5717 --- /dev/null +++ b/src/components/RangeSlider.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue new file mode 100644 index 0000000..227adf0 --- /dev/null +++ b/src/components/Timeline.vue @@ -0,0 +1,839 @@ + + + + + diff --git a/src/composables/usePlaybackLoop.ts b/src/composables/usePlaybackLoop.ts new file mode 100644 index 0000000..c7cf500 --- /dev/null +++ b/src/composables/usePlaybackLoop.ts @@ -0,0 +1,23 @@ +import { onBeforeUnmount, onMounted } from 'vue' +import { useSceneStore } from '@/stores/scene' + +export function usePlaybackLoop() { + const store = useSceneStore() + let raf = 0 + let last = 0 + + const loop = (now: number) => { + const dt = last === 0 ? 0 : (now - last) / 1000 + last = now + if (store.playing) store.tick(dt) + raf = requestAnimationFrame(loop) + } + + onMounted(() => { + raf = requestAnimationFrame(loop) + }) + + onBeforeUnmount(() => { + cancelAnimationFrame(raf) + }) +} diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts new file mode 100644 index 0000000..c436d3a --- /dev/null +++ b/src/composables/useTheme.ts @@ -0,0 +1,35 @@ +import { ref, watch } from 'vue' + +export type Theme = 'dark' | 'light' + +const STORAGE_KEY = 'animatr.theme' + +function readInitial(): Theme { + if (typeof localStorage === 'undefined') return 'dark' + const v = localStorage.getItem(STORAGE_KEY) + return v === 'light' ? 'light' : 'dark' +} + +const theme = ref(readInitial()) + +function apply(t: Theme) { + if (typeof document === 'undefined') return + document.documentElement.dataset.theme = t + try { + localStorage.setItem(STORAGE_KEY, t) + } catch { + /* ignore quota */ + } +} + +apply(theme.value) +watch(theme, (t) => apply(t)) + +export function useTheme() { + return { + theme, + setTheme(t: Theme) { + theme.value = t + }, + } +} diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts new file mode 100644 index 0000000..759e02e --- /dev/null +++ b/src/lib/exportPlayer.ts @@ -0,0 +1,326 @@ +import type { Scene } from '@/types/scene' + +const RUNTIME_SOURCE = `(function(){ + var SCENE = __SCENE__; + var OPTIONS = __OPTIONS__; + + function easing(name, t){ + if(name==='easeIn')return t*t; + if(name==='easeOut')return 1-(1-t)*(1-t); + if(name==='easeInOut')return t<0.5?2*t*t:1-Math.pow(-2*t+2,2)/2; + return t; + } + function merge(base,partial){ + var o={};for(var k in base)o[k]=base[k]; + if(partial)for(var k2 in partial)o[k2]=partial[k2]; + return o; + } + function lerp(a,b,t){return a+(b-a)*t;} + function parseHex(hex){ + if(!hex)return null; + var h=String(hex).trim().replace('#',''); + if(h.length===3)h=h[0]+h[0]+h[1]+h[1]+h[2]+h[2]; + if(h.length<6)return null; + var n=parseInt(h.slice(0,6),16); + if(isNaN(n))return null; + return [(n>>16)&255,(n>>8)&255,n&255]; + } + function formatHex(r,g,b){ + function c(v){v=Math.round(v);if(v<0)v=0;if(v>255)v=255;var s=v.toString(16);return s.length<2?'0'+s:s;} + return '#'+c(r)+c(g)+c(b); + } + function lerpColor(a,b,t){ + var pa=parseHex(a),pb=parseHex(b); + if(!pa||!pb)return t<0.5?a:b; + return formatHex(lerp(pa[0],pb[0],t),lerp(pa[1],pb[1],t),lerp(pa[2],pb[2],t)); + } + function lerpProps(a,b,t){ + var out={ + x:lerp(a.x,b.x,t), + y:lerp(a.y,b.y,t), + scaleX:lerp(a.scaleX,b.scaleX,t), + scaleY:lerp(a.scaleY,b.scaleY,t), + rotation:lerp(a.rotation,b.rotation,t), + opacity:lerp(a.opacity,b.opacity,t) + }; + if(a.fill&&b.fill)out.fill=lerpColor(a.fill,b.fill,t); + else if(b.fill)out.fill=b.fill; + else if(a.fill)out.fill=a.fill; + return out; + } + function sample(layer,time){ + var kfs=layer.keyframes.slice().sort(function(a,b){return a.time-b.time;}); + if(!kfs.length)return merge(layer.base); + if(time<=kfs[0].time)return merge(layer.base,kfs[0].props); + var last=kfs[kfs.length-1]; + if(time>=last.time)return merge(layer.base,last.props); + for(var i=0;i=a.time&&time<=b.time){ + var raw=(time-a.time)/(b.time-a.time); + var t=easing(b.easing,raw); + return lerpProps(merge(layer.base,a.props),merge(layer.base,b.props),t); + } + } + return merge(layer.base); + } + + function loadImages(assets){ + var imgs={}; + var list=assets.filter(function(a){return a.kind==='image';}); + return Promise.all(list.map(function(a){ + return new Promise(function(resolve){ + var img=new Image(); + img.onload=function(){imgs[a.id]=img;resolve();}; + img.onerror=function(){resolve();}; + img.src=a.src; + }); + })).then(function(){return imgs;}); + } + + function effectiveEnd(scene){ + var max=0; + for(var i=0;imax) max=kfs[j].time; } + } + if(max<=0) return scene.duration; + return Math.min(max,scene.duration); + } + function mount(container,scene,opts){ + if(!container)return; + opts=opts||{}; + var loop=opts.loop!==false; + var sceneEnd=effectiveEnd(scene); + var cs=getComputedStyle(container); + if(cs.position==='static')container.style.position='relative'; + var canvas=document.createElement('canvas'); + canvas.style.cssText='display:block;width:100%;height:100%;'; + container.appendChild(canvas); + var ctx=canvas.getContext('2d'); + var dpr=window.devicePixelRatio||1; + var size={w:0,h:0}; + function resize(){ + var r=container.getBoundingClientRect(); + size.w=r.width;size.h=r.height; + canvas.width=Math.max(1,Math.round(r.width*dpr)); + canvas.height=Math.max(1,Math.round(r.height*dpr)); + } + var ro=new ResizeObserver(resize); + ro.observe(container); + resize(); + var assetMap={}; + scene.assets.forEach(function(a){assetMap[a.id]=a;}); + var imgs={}; + var start=0; + function frame(now){ + if(!start)start=now; + var t=(now-start)/1000; + if(sceneEnd<=0){ draw(0); requestAnimationFrame(frame); return; } + if(loop) t=t%sceneEnd; + else if(t>sceneEnd) t=sceneEnd; + draw(t); + requestAnimationFrame(frame); + } + function draw(time){ + var lw=scene.canvas.width,lh=scene.canvas.height; + var cw=size.w,ch=size.h; + if(!cw||!ch)return; + var s=Math.min(cw/lw,ch/lh); + var ox=(cw-lw*s)/2; + var oy=(ch-lh*s)/2; + ctx.setTransform(dpr,0,0,dpr,0,0); + ctx.clearRect(0,0,cw,ch); + ctx.save(); + ctx.translate(ox,oy); + ctx.scale(s,s); + ctx.beginPath(); + ctx.rect(0,0,lw,lh); + ctx.clip(); + ctx.fillStyle=scene.canvas.background; + ctx.fillRect(0,0,lw,lh); + for(var i=0;i + + + + +Animatr — Beispiel + + + +

Animatr Export

+

Diese HTML-Datei enthält Runtime + Szene inline — einfach im Browser öffnen.

+ +
+ +
+ +
+

Einbinden in eine eigene Seite

+
<div style="width: 800px; height: 450px;">
+  <script src="animation.js"></script>
+</div>
+
+ + +` +} + +export function downloadExampleHtml(scene: Scene, filename = 'animation-example.html') { + const source = generateExampleHtml(scene) + triggerDownload(source, filename, 'text/html') +} + +function triggerDownload(source: string, filename: string, mime: string) { + const blob = new Blob([source], { type: mime }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + setTimeout(() => URL.revokeObjectURL(url), 1000) +} diff --git a/src/lib/fileLoader.ts b/src/lib/fileLoader.ts new file mode 100644 index 0000000..a81c708 --- /dev/null +++ b/src/lib/fileLoader.ts @@ -0,0 +1,53 @@ +export function readAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(file) + }) +} + +export function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = () => resolve(img) + img.onerror = () => reject(new Error(`failed to load image: ${src.slice(0, 60)}…`)) + img.src = src + }) +} + +export interface Dimensions { + width: number + height: number +} + +export function parseSvgDimensions(svgText: string): Dimensions | null { + const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml') + const svg = doc.documentElement + if (!svg || svg.nodeName !== 'svg') return null + + const parseLen = (attr: string | null) => { + if (!attr) return 0 + const num = parseFloat(attr) + return Number.isFinite(num) ? num : 0 + } + + let w = parseLen(svg.getAttribute('width')) + let h = parseLen(svg.getAttribute('height')) + + if (!w || !h) { + const vb = svg.getAttribute('viewBox')?.trim().split(/[\s,]+/).map(Number) + if (vb && vb.length === 4 && vb.every((n) => Number.isFinite(n))) { + if (!w) w = vb[2] + if (!h) h = vb[3] + } + } + + if (!w || !h) return null + return { width: w, height: h } +} + +export function isSvgFile(file: File): boolean { + return file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg') +} diff --git a/src/lib/interpolate.ts b/src/lib/interpolate.ts new file mode 100644 index 0000000..e43714a --- /dev/null +++ b/src/lib/interpolate.ts @@ -0,0 +1,75 @@ +import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene' + +const easings: Record number> = { + linear: (t) => t, + easeIn: (t) => t * t, + easeOut: (t) => 1 - (1 - t) ** 2, + easeInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2), +} + +function merge(base: AnimatableProps, partial: Partial): AnimatableProps { + return { ...base, ...partial } +} + +function lerp(a: number, b: number, t: number) { + return a + (b - a) * t +} + +function parseHex(hex: string): [number, number, number] | null { + const h = hex.trim().replace('#', '') + const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h + if (full.length < 6) return null + const n = parseInt(full.slice(0, 6), 16) + if (Number.isNaN(n)) return null + return [(n >> 16) & 255, (n >> 8) & 255, n & 255] +} + +function formatHex(r: number, g: number, b: number): string { + const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v))) + const h = (v: number) => clamp(v).toString(16).padStart(2, '0') + return `#${h(r)}${h(g)}${h(b)}` +} + +export function lerpColor(a: string, b: string, t: number): string { + const pa = parseHex(a) + const pb = parseHex(b) + if (!pa || !pb) return t < 0.5 ? a : b + return formatHex(lerp(pa[0], pb[0], t), lerp(pa[1], pb[1], t), lerp(pa[2], pb[2], t)) +} + +function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): AnimatableProps { + const out: AnimatableProps = { + x: lerp(a.x, b.x, t), + y: lerp(a.y, b.y, t), + scaleX: lerp(a.scaleX, b.scaleX, t), + scaleY: lerp(a.scaleY, b.scaleY, t), + rotation: lerp(a.rotation, b.rotation, t), + opacity: lerp(a.opacity, b.opacity, t), + } + if (a.fill && b.fill) out.fill = lerpColor(a.fill, b.fill, t) + else if (b.fill) out.fill = b.fill + else if (a.fill) out.fill = a.fill + return out +} + +export function sortedKeyframes(layer: Layer): Keyframe[] { + return [...layer.keyframes].sort((a, b) => a.time - b.time) +} + +export function sampleLayer(layer: Layer, time: number): AnimatableProps { + const kfs = sortedKeyframes(layer) + if (!kfs.length) return { ...layer.base } + if (time <= kfs[0].time) return merge(layer.base, kfs[0].props) + const last = kfs[kfs.length - 1] + if (time >= last.time) return merge(layer.base, last.props) + for (let i = 0; i < kfs.length - 1; i++) { + const a = kfs[i] + const b = kfs[i + 1] + if (time >= a.time && time <= b.time) { + const raw = (time - a.time) / (b.time - a.time) + const t = easings[b.easing](raw) + return lerpProps(merge(layer.base, a.props), merge(layer.base, b.props), t) + } + } + return { ...layer.base } +} diff --git a/src/lib/render.ts b/src/lib/render.ts new file mode 100644 index 0000000..d33dfd8 --- /dev/null +++ b/src/lib/render.ts @@ -0,0 +1,86 @@ +import type { Asset, Scene } from '@/types/scene' +import { sampleLayer } from './interpolate' + +export async function loadSceneImages(scene: Scene): Promise> { + const result: Record = {} + const images = scene.assets.filter( + (a): a is Extract => a.kind === 'image', + ) + await Promise.all( + images.map( + (a) => + new Promise((resolve) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = () => { + result[a.id] = img + resolve() + } + img.onerror = () => resolve() + img.src = a.src + }), + ), + ) + return result +} + +export function drawSceneFrame( + ctx: CanvasRenderingContext2D, + scene: Scene, + time: number, + images: Record, +) { + const lw = scene.canvas.width + const lh = scene.canvas.height + + ctx.save() + ctx.beginPath() + ctx.rect(0, 0, lw, lh) + ctx.clip() + ctx.fillStyle = scene.canvas.background + ctx.fillRect(0, 0, lw, lh) + + const assetMap = new Map() + for (const a of scene.assets) assetMap.set(a.id, a) + + for (const layer of scene.layers) { + if (!layer.visible) continue + const asset = assetMap.get(layer.assetId) + if (!asset) continue + const p = sampleLayer(layer, time) + + ctx.save() + ctx.globalAlpha *= p.opacity + ctx.translate(p.x, p.y) + ctx.rotate((p.rotation * Math.PI) / 180) + ctx.scale(p.scaleX, p.scaleY) + + if (asset.kind === 'rect') { + const offX = layer.offsetX ?? asset.width / 2 + const offY = layer.offsetY ?? asset.height / 2 + ctx.fillStyle = p.fill ?? asset.fill + ctx.fillRect(-offX, -offY, asset.width, asset.height) + } else if (asset.kind === 'circle') { + ctx.fillStyle = p.fill ?? asset.fill + ctx.beginPath() + ctx.arc(0, 0, asset.radius, 0, Math.PI * 2) + ctx.fill() + } else if (asset.kind === 'text') { + ctx.fillStyle = p.fill ?? asset.fill + ctx.font = `${asset.fontSize}px sans-serif` + ctx.textBaseline = 'top' + ctx.fillText(asset.text, 0, 0) + } else if (asset.kind === 'image') { + const img = images[asset.id] + if (img) { + const offX = layer.offsetX ?? asset.width / 2 + const offY = layer.offsetY ?? asset.height / 2 + ctx.drawImage(img, -offX, -offY, asset.width, asset.height) + } + } + + ctx.restore() + } + + ctx.restore() +} diff --git a/src/lib/serverExport.ts b/src/lib/serverExport.ts new file mode 100644 index 0000000..13d1f86 --- /dev/null +++ b/src/lib/serverExport.ts @@ -0,0 +1,119 @@ +import type { Scene } from '@/types/scene' + +const DEFAULT_SERVER = 'http://localhost:6000' +const SERVER_URL = (import.meta.env.VITE_RENDER_SERVER as string | undefined) ?? DEFAULT_SERVER + +export interface ServerRenderOptions { + onProgress?: (progress: number) => void + signal?: AbortSignal + fps?: number + crf?: number + preset?: string +} + +export interface ServerRenderResult { + blob: Blob + extension: string +} + +export function getServerUrl(): string { + return SERVER_URL +} + +export async function renderOnServer( + scene: Scene, + opts: ServerRenderOptions = {}, +): Promise { + const baseUrl = SERVER_URL + const startRes = await fetch(`${baseUrl}/render`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + scene, + options: { + fps: opts.fps, + crf: opts.crf, + preset: opts.preset, + }, + }), + signal: opts.signal, + }) + if (!startRes.ok) { + const body = await startRes.text().catch(() => '') + throw new Error(`Server start fehlgeschlagen (${startRes.status}): ${body.slice(0, 200)}`) + } + const { jobId } = (await startRes.json()) as { jobId: string } + if (!jobId) throw new Error('Server lieferte keine jobId') + + const downloadUrl = await new Promise((resolve, reject) => { + const es = new EventSource(`${baseUrl}/render/${jobId}/events`) + const onAbort = () => { + try { + es.close() + } catch { + /* noop */ + } + fetch(`${baseUrl}/render/${jobId}`, { method: 'DELETE' }).catch(() => {}) + reject(new DOMException('aborted', 'AbortError')) + } + if (opts.signal) { + if (opts.signal.aborted) return onAbort() + opts.signal.addEventListener('abort', onAbort, { once: true }) + } + es.onmessage = (evt) => { + let data: { + progress?: number + done?: boolean + error?: string | null + url?: string | null + } + try { + data = JSON.parse(evt.data) + } catch { + return + } + if (typeof data.progress === 'number') opts.onProgress?.(data.progress) + if (data.done) { + es.close() + opts.signal?.removeEventListener('abort', onAbort) + if (data.error) reject(new Error(data.error)) + else if (data.url) resolve(`${baseUrl}${data.url}`) + else reject(new Error('Server meldete fertig ohne Download-URL')) + } + } + es.onerror = () => { + es.close() + opts.signal?.removeEventListener('abort', onAbort) + reject(new Error('Verbindung zum Render-Server verloren')) + } + }) + + const fileRes = await fetch(downloadUrl, { signal: opts.signal }) + if (!fileRes.ok) throw new Error(`Download fehlgeschlagen (${fileRes.status})`) + const blob = await fileRes.blob() + return { blob, extension: 'mp4' } +} + +export async function downloadFromServer( + scene: Scene, + opts: ServerRenderOptions = {}, +): Promise { + const { blob, extension } = await renderOnServer(scene, opts) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `animation.${extension}` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + setTimeout(() => URL.revokeObjectURL(url), 1000) +} + +export async function isServerReachable(): Promise { + try { + const res = await fetch(`${SERVER_URL}/health`, { method: 'GET' }) + return res.ok + } catch { + return false + } +} diff --git a/src/lib/videoExport.ts b/src/lib/videoExport.ts new file mode 100644 index 0000000..72eaf59 --- /dev/null +++ b/src/lib/videoExport.ts @@ -0,0 +1,135 @@ +import type { Scene } from '@/types/scene' +import { drawSceneFrame, loadSceneImages } from './render' + +function effectiveEnd(scene: Scene): number { + let max = 0 + for (const layer of scene.layers) { + for (const kf of layer.keyframes) { + if (kf.time > max) max = kf.time + } + } + if (max <= 0) return scene.duration + return Math.min(max, scene.duration) +} + +export interface VideoExportResult { + blob: Blob + mimeType: string + extension: string +} + +export interface VideoExportOptions { + onProgress?: (progress: number) => void + signal?: AbortSignal + bitsPerSecond?: number +} + +const MIME_CANDIDATES = [ + 'video/webm;codecs=vp9', + 'video/webm;codecs=vp8', + 'video/webm', + 'video/mp4;codecs=h264', + 'video/mp4', +] + +export function pickMimeType(): string | null { + if (typeof MediaRecorder === 'undefined') return null + for (const mime of MIME_CANDIDATES) { + if (MediaRecorder.isTypeSupported?.(mime)) return mime + } + return null +} + +export async function renderSceneToVideo( + scene: Scene, + opts: VideoExportOptions = {}, +): Promise { + const mimeType = pickMimeType() + if (!mimeType) { + throw new Error('MediaRecorder wird von diesem Browser nicht unterstützt.') + } + + const canvas = document.createElement('canvas') + canvas.width = scene.canvas.width + canvas.height = scene.canvas.height + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('2D context unavailable') + + const images = await loadSceneImages(scene) + + drawSceneFrame(ctx, scene, 0, images) + + const stream = canvas.captureStream(scene.fps) + const recorder = new MediaRecorder(stream, { + mimeType, + videoBitsPerSecond: opts.bitsPerSecond ?? 8_000_000, + }) + + const chunks: Blob[] = [] + recorder.ondataavailable = (e) => { + if (e.data && e.data.size > 0) chunks.push(e.data) + } + + const stopped = new Promise((resolve) => { + recorder.onstop = () => resolve() + }) + + recorder.start() + const startedAt = performance.now() + const renderDuration = effectiveEnd(scene) + + const finished = new Promise((resolve, reject) => { + let raf = 0 + const step = () => { + if (opts.signal?.aborted) { + cancelAnimationFrame(raf) + try { + recorder.stop() + } catch { + /* ignore */ + } + stream.getTracks().forEach((t) => t.stop()) + reject(new DOMException('aborted', 'AbortError')) + return + } + const elapsed = (performance.now() - startedAt) / 1000 + const t = Math.min(elapsed, renderDuration) + drawSceneFrame(ctx, scene, t, images) + opts.onProgress?.(renderDuration > 0 ? Math.min(1, t / renderDuration) : 1) + if (elapsed >= renderDuration) { + try { + recorder.stop() + } catch { + /* ignore */ + } + stream.getTracks().forEach((t) => t.stop()) + resolve() + return + } + raf = requestAnimationFrame(step) + } + raf = requestAnimationFrame(step) + }) + + await finished + await stopped + + const blob = new Blob(chunks, { type: mimeType }) + const extension = mimeType.includes('mp4') ? 'mp4' : 'webm' + return { blob, mimeType, extension } +} + +export async function downloadSceneAsVideo( + scene: Scene, + opts: VideoExportOptions = {}, +): Promise { + const { blob, extension } = await renderSceneToVideo(scene, opts) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `animation.${extension}` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + setTimeout(() => URL.revokeObjectURL(url), 1000) +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..a73c994 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,11 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import VueKonva from 'vue-konva' +import App from './App.vue' +import './composables/useTheme' +import './styles/global.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(VueKonva) +app.mount('#app') diff --git a/src/stores/scene.ts b/src/stores/scene.ts new file mode 100644 index 0000000..d05b072 --- /dev/null +++ b/src/stores/scene.ts @@ -0,0 +1,227 @@ +import { defineStore } from 'pinia' +import type { AnimatableProps, Asset, Easing, Keyframe, Layer, Scene } from '@/types/scene' +import { DEFAULT_BASE_PROPS } from '@/types/scene' +import { sampleLayer } from '@/lib/interpolate' + +const uid = () => Math.random().toString(36).slice(2, 10) + +type AssetInit = Asset extends infer A ? (A extends Asset ? Omit : never) : never + +const KEYFRAME_EPSILON = 1e-3 + +interface State { + scene: Scene + selectedLayerId: string | null + playhead: number + playing: boolean +} + +export const useSceneStore = defineStore('scene', { + state: (): State => ({ + scene: { + version: 1, + canvas: { width: 1280, height: 720, background: '#ffffff' }, + duration: 10, + fps: 60, + assets: [], + layers: [], + }, + selectedLayerId: null, + playhead: 0, + playing: false, + }), + getters: { + lastKeyframeTime(state): number { + let max = 0 + for (const layer of state.scene.layers) { + for (const kf of layer.keyframes) { + if (kf.time > max) max = kf.time + } + } + return Math.min(state.scene.duration, max) + }, + effectiveEnd(state): number { + let max = 0 + for (const layer of state.scene.layers) { + for (const kf of layer.keyframes) { + if (kf.time > max) max = kf.time + } + } + if (max <= 0) return state.scene.duration + return Math.min(max, state.scene.duration) + }, + }, + actions: { + addAsset(asset: AssetInit): Asset { + const created = { ...asset, id: uid() } as Asset + this.scene.assets.push(created) + return created + }, + addLayer(assetId: string, name?: string): Layer { + const asset = this.scene.assets.find((a) => a.id === assetId) + const base: AnimatableProps = { + ...DEFAULT_BASE_PROPS, + x: this.scene.canvas.width / 2, + y: this.scene.canvas.height / 2, + } + if (asset && 'fill' in asset) base.fill = asset.fill + const layer: Layer = { + id: uid(), + name: name ?? asset?.name ?? 'Layer', + assetId, + visible: true, + base, + keyframes: [ + { + id: uid(), + time: 0, + props: { ...base }, + easing: 'linear', + }, + ], + } + if (asset && (asset.kind === 'rect' || asset.kind === 'image')) { + layer.offsetX = asset.width / 2 + layer.offsetY = asset.height / 2 + } + this.scene.layers.push(layer) + this.selectedLayerId = layer.id + return layer + }, + removeLayer(id: string) { + this.scene.layers = this.scene.layers.filter((l) => l.id !== id) + if (this.selectedLayerId === id) this.selectedLayerId = null + }, + selectLayer(id: string | null) { + this.selectedLayerId = id + }, + setPlayhead(t: number) { + this.playhead = Math.max(0, Math.min(this.scene.duration, t)) + }, + setDuration(seconds: number) { + this.scene.duration = Math.max(0.1, seconds) + if (this.playhead > this.scene.duration) this.playhead = this.scene.duration + }, + setFps(fps: number) { + this.scene.fps = Math.max(1, Math.round(fps)) + }, + setCanvasSize(width: number, height: number) { + this.scene.canvas.width = Math.max(1, Math.round(width)) + this.scene.canvas.height = Math.max(1, Math.round(height)) + }, + setCanvasBackground(color: string) { + this.scene.canvas.background = color + }, + upsertKeyframe(layerId: string, time: number, props: Partial) { + const layer = this.scene.layers.find((l) => l.id === layerId) + if (!layer) return + const existing = layer.keyframes.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON) + if (existing) { + existing.props = { ...existing.props, ...props } + return + } + const snapshot = sampleLayer(layer, time) + const kf: Keyframe = { + id: uid(), + time, + props: { ...snapshot, ...props }, + easing: 'linear', + } + layer.keyframes.push(kf) + layer.keyframes.sort((a, b) => a.time - b.time) + }, + removeKeyframeAt(layerId: string, time: number) { + const layer = this.scene.layers.find((l) => l.id === layerId) + if (!layer) return + layer.keyframes = layer.keyframes.filter((k) => Math.abs(k.time - time) >= KEYFRAME_EPSILON) + }, + setKeyframeEasing(layerId: string, time: number, easing: Easing) { + const layer = this.scene.layers.find((l) => l.id === layerId) + if (!layer) return + const kf = layer.keyframes.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON) + if (kf) kf.easing = easing + }, + setKeyframeTime(layerId: string, keyframeId: string, newTime: number) { + const layer = this.scene.layers.find((l) => l.id === layerId) + if (!layer) return + const kf = layer.keyframes.find((k) => k.id === keyframeId) + if (!kf) return + kf.time = Math.max(0, Math.min(this.scene.duration, newTime)) + layer.keyframes.sort((a, b) => a.time - b.time) + }, + shiftLayerPivot( + layerId: string, + dWorldX: number, + dWorldY: number, + dLocalX: number, + dLocalY: number, + ) { + const layer = this.scene.layers.find((l) => l.id === layerId) + if (!layer) return + layer.offsetX = (layer.offsetX ?? 0) + dLocalX + layer.offsetY = (layer.offsetY ?? 0) + dLocalY + layer.base.x += dWorldX + layer.base.y += dWorldY + for (const kf of layer.keyframes) { + if (kf.props.x !== undefined) kf.props.x += dWorldX + if (kf.props.y !== undefined) kf.props.y += dWorldY + } + }, + duplicateKeyframe(layerId: string, keyframeId: string): string | null { + const layer = this.scene.layers.find((l) => l.id === layerId) + if (!layer) return null + const source = layer.keyframes.find((k) => k.id === keyframeId) + if (!source) return null + const fps = this.scene.fps > 0 ? this.scene.fps : 60 + const offset = Math.max(3 / fps, 0.1) + const duration = this.scene.duration + const occupied = (t: number) => + layer.keyframes.some((k) => Math.abs(k.time - t) < KEYFRAME_EPSILON) + let target = source.time + offset + while (occupied(target) && target < duration) target += offset + if (target > duration) target = duration + if (occupied(target)) { + target = source.time - offset + while (occupied(target) && target > 0) target -= offset + if (target < 0) target = 0 + if (occupied(target)) return null + } + const newKf: Keyframe = { + id: uid(), + time: target, + props: { ...source.props }, + easing: source.easing, + } + layer.keyframes.push(newKf) + layer.keyframes.sort((a, b) => a.time - b.time) + return newKf.id + }, + play() { + if (this.playing) return + this.playing = true + }, + pause() { + this.playing = false + }, + togglePlay() { + if (this.playing) this.pause() + else { + const end = this.effectiveEnd + if (end <= 0) return + if (this.playhead >= end) this.playhead = 0 + this.play() + } + }, + tick(deltaSeconds: number) { + if (!this.playing) return + const end = this.effectiveEnd + const next = this.playhead + deltaSeconds + if (next >= end) { + this.playhead = end + this.playing = false + } else { + this.playhead = next + } + }, + }, +}) diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..dc79f20 --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,140 @@ +:root { + --bg: #17171b; + --panel: #1f1f24; + --panel-alt: #272730; + --panel-hover: #2f2f3a; + --border: #2e2e37; + --border-strong: #3a3a46; + --text: #ececf1; + --text-dim: #9a9aa6; + --text-dim-strong: #c7c7d1; + --accent: #6ea8ff; + --accent-soft: rgba(110, 168, 255, 0.14); + --accent-soft-strong: rgba(110, 168, 255, 0.22); + --danger: #ff7a7a; + --danger-soft: rgba(255, 122, 122, 0.12); + --radius: 6px; + --radius-sm: 4px; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 18px rgba(0, 0, 0, 0.35); + --ease: cubic-bezier(0.2, 0.8, 0.2, 1); + color-scheme: dark; +} + +:root[data-theme='light'] { + --bg: #f3f3f6; + --panel: #ffffff; + --panel-alt: #f5f5f8; + --panel-hover: #eaeaf0; + --border: #e2e2e7; + --border-strong: #c9c9d0; + --text: #1c1c20; + --text-dim: #72727a; + --text-dim-strong: #3c3c42; + --accent: #2b6af5; + --accent-soft: rgba(43, 106, 245, 0.1); + --accent-soft-strong: rgba(43, 106, 245, 0.18); + --danger: #d63c3c; + --danger-soft: rgba(214, 60, 60, 0.09); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); + --shadow-md: 0 8px 24px rgba(20, 25, 40, 0.12); + color-scheme: light; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#app { + margin: 0; + padding: 0; + height: 100%; + width: 100%; + background: var(--bg); + color: var(--text); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow: hidden; + user-select: none; + -webkit-user-select: none; + transition: background 180ms var(--ease), color 180ms var(--ease); +} + +input, +textarea, +[contenteditable='true'] { + user-select: text; + -webkit-user-select: text; +} + +button { + font: inherit; + color: inherit; + background: var(--panel-alt); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 5px 10px; + cursor: pointer; + transition: background 140ms var(--ease), border-color 140ms var(--ease), + color 140ms var(--ease), transform 120ms var(--ease); + display: inline-flex; + align-items: center; + gap: 6px; +} + +button:hover:not(:disabled) { + background: var(--panel-hover); + border-color: var(--border-strong); +} + +button:active:not(:disabled) { + transform: translateY(1px); +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +input, +select, +textarea { + transition: border-color 140ms var(--ease), background 140ms var(--ease); +} + +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--accent) !important; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 10px; + border: 2px solid var(--panel); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--border-strong); +} + +::selection { + background: var(--accent-soft-strong); + color: var(--text); +} diff --git a/src/types/scene.ts b/src/types/scene.ts new file mode 100644 index 0000000..2b7bccc --- /dev/null +++ b/src/types/scene.ts @@ -0,0 +1,90 @@ +export type Easing = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' + +export interface AnimatableProps { + x: number + y: number + scaleX: number + scaleY: number + rotation: number + opacity: number + fill?: string +} + +export interface Keyframe { + id: string + time: number + props: Partial + easing: Easing +} + +export type AssetKind = 'image' | 'rect' | 'circle' | 'text' + +export interface ImageAsset { + id: string + kind: 'image' + name: string + src: string + width: number + height: number +} + +export interface RectAsset { + id: string + kind: 'rect' + name: string + width: number + height: number + fill: string +} + +export interface CircleAsset { + id: string + kind: 'circle' + name: string + radius: number + fill: string +} + +export interface TextAsset { + id: string + kind: 'text' + name: string + text: string + fontSize: number + fill: string +} + +export type Asset = ImageAsset | RectAsset | CircleAsset | TextAsset + +export interface Layer { + id: string + name: string + assetId: string + visible: boolean + base: AnimatableProps + keyframes: Keyframe[] + offsetX?: number + offsetY?: number +} + +export interface Scene { + version: 1 + canvas: { + width: number + height: number + background: string + } + duration: number + fps: number + assets: Asset[] + layers: Layer[] +} + +export const DEFAULT_BASE_PROPS: AnimatableProps = { + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + rotation: 0, + opacity: 1, +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1f94304 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..a52797c --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, +})