feat: narrow PR to zoom transitions and motion blur
This commit is contained in:
Generated
+61
-36
@@ -36,6 +36,7 @@
|
||||
"mediabunny": "^1.25.1",
|
||||
"motion": "^12.23.24",
|
||||
"mp4box": "^2.2.0",
|
||||
"pixi-filters": "^6.1.5",
|
||||
"pixi.js": "^8.14.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -116,7 +117,6 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -345,7 +345,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -1320,6 +1319,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1341,6 +1341,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1357,6 +1358,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1371,6 +1373,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -2054,7 +2057,6 @@
|
||||
"integrity": "sha512-LTATglVUPGkPf15zX1wTMlZ0+AU7cGEGF6ekVF1crA8eHUWsGjrYTB+Ht4E3HTrCok8weQG+K01rJndCp/l4XA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/core": "^0.16.13"
|
||||
@@ -2097,7 +2099,6 @@
|
||||
"integrity": "sha512-8Z1k96ZFxlhK2bgrY1JNWNwvaBeI/bciLM0yDOni2+aZwfIIiC7Y6PeWHTAvjHNjphz+XCt01WQmOYWCn0ML6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/utils": "^0.16.13"
|
||||
@@ -2112,7 +2113,6 @@
|
||||
"integrity": "sha512-PvLrfa8vkej3qinlebyhLpksJgCF5aiysDMSVhOZqwH5nQLLtDE9WYbnsofGw4r0VVpyw3H/ANCIzYTyCtP9Cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/utils": "^0.16.13"
|
||||
@@ -2141,7 +2141,6 @@
|
||||
"integrity": "sha512-xW+9BtEvoIkkH/Wde9ql4nAFbYLkVINhpgAE7VcBUsuuB34WUbcBl/taOuUYQrPEFQJ4jfXiAJZ2H/rvKjCVnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/utils": "^0.16.13",
|
||||
@@ -2191,7 +2190,6 @@
|
||||
"integrity": "sha512-WEl2tPVYwzYL8OKme6Go2xqiWgKsgxlMwyHabdAU4tXaRwOCnOI7v4021gCcBb9zn/oWwguHuKHmK30Fw2Z/PA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/utils": "^0.16.13"
|
||||
@@ -2335,7 +2333,6 @@
|
||||
"integrity": "sha512-qoqtN8LDknm3fJm9nuPygJv30O3vGhSBD2TxrsCnhtOsxKAqVPJtFVdGd/qVuZ8nqQANQmTlfqTiK9mVWQ7MiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/utils": "^0.16.13"
|
||||
@@ -2350,7 +2347,6 @@
|
||||
"integrity": "sha512-Ev+Jjmj1nHYw897z9C3R9dYsPv7S2/nxdgfFb/h8hOwK0Ovd1k/+yYS46A0uj/JCKK0pQk8wOslYBkPwdnLorw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/utils": "^0.16.13"
|
||||
@@ -2368,7 +2364,6 @@
|
||||
"integrity": "sha512-05POQaEJVucjTiSGMoH68ZiELc7QqpIpuQlZ2JBbhCV+WCbPFUBcGSmE7w4Jd0E2GvCho/NoMODLwgcVGQA97A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/utils": "^0.16.13"
|
||||
@@ -2795,6 +2790,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz",
|
||||
"integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/colord": "^2.9.6"
|
||||
}
|
||||
@@ -2809,7 +2805,8 @@
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz",
|
||||
"integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/core": {
|
||||
"version": "7.4.3",
|
||||
@@ -2836,7 +2833,8 @@
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz",
|
||||
"integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/filter-drop-shadow": {
|
||||
"version": "5.2.0",
|
||||
@@ -2863,19 +2861,22 @@
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz",
|
||||
"integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/runner": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz",
|
||||
"integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/settings": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz",
|
||||
"integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/constants": "7.4.3",
|
||||
"@types/css-font-loading-module": "^0.0.12",
|
||||
@@ -2887,6 +2888,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz",
|
||||
"integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/extensions": "7.4.3",
|
||||
"@pixi/settings": "7.4.3",
|
||||
@@ -2898,6 +2900,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz",
|
||||
"integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/color": "7.4.3",
|
||||
"@pixi/constants": "7.4.3",
|
||||
@@ -2912,19 +2915,22 @@
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz",
|
||||
"integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/utils/node_modules/earcut": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
|
||||
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/utils/node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
@@ -4380,6 +4386,12 @@
|
||||
"@types/events": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/gradient-parser": {
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/gradient-parser/-/gradient-parser-0.1.5.tgz",
|
||||
"integrity": "sha512-r7K3NkJz3A95WkVVmjs0NcchhHstC2C/VIYNX4JC6tieviUNo774FFeOHjThr3Vw/WCeMP9kAT77MKbIRlO/4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-cache-semantics": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
|
||||
@@ -4439,7 +4451,6 @@
|
||||
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -4451,7 +4462,6 @@
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@@ -4760,7 +4770,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -5581,7 +5590,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
@@ -5894,6 +5902,7 @@
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
@@ -6343,7 +6352,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
@@ -6639,7 +6649,6 @@
|
||||
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.7.0",
|
||||
"builder-util": "26.4.1",
|
||||
@@ -7066,6 +7075,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -7086,6 +7096,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -8768,7 +8779,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -10331,6 +10341,7 @@
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -10846,6 +10857,18 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pixi-filters": {
|
||||
"version": "6.1.5",
|
||||
"resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-6.1.5.tgz",
|
||||
"integrity": "sha512-Ewb/J+kxAbaNN+0/ATJbglAJG+skGJfh7BIDP3ILIDdD6wWk1p0pGa25pVf1T8hGBOQSUNVAmwwJBwkj+cyLLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/gradient-parser": "^0.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pixi.js": ">=8.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/pixi.js": {
|
||||
"version": "8.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.14.0.tgz",
|
||||
@@ -10920,7 +10943,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -11065,6 +11087,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -11082,6 +11105,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -11230,6 +11254,7 @@
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
@@ -11288,7 +11313,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -11301,7 +11325,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -12092,6 +12115,7 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
@@ -12111,6 +12135,7 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
@@ -12127,6 +12152,7 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -12145,6 +12171,7 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -12792,7 +12819,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
|
||||
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -12865,6 +12891,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -12928,6 +12955,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -12942,6 +12970,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -12955,7 +12984,6 @@
|
||||
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
@@ -13108,7 +13136,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -13342,6 +13369,7 @@
|
||||
"resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz",
|
||||
"integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"punycode": "^1.4.1",
|
||||
"qs": "^6.12.3"
|
||||
@@ -13354,7 +13382,8 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
|
||||
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
@@ -13468,7 +13497,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -13543,8 +13571,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
|
||||
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.0.16",
|
||||
@@ -14108,7 +14135,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -14122,7 +14148,6 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"mediabunny": "^1.25.1",
|
||||
"motion": "^12.23.24",
|
||||
"mp4box": "^2.2.0",
|
||||
"pixi-filters": "^6.1.5",
|
||||
"pixi.js": "^8.14.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Texture,
|
||||
VideoSource,
|
||||
} from "pixi.js";
|
||||
import { MotionBlurFilter } from "pixi-filters/motion-blur";
|
||||
import type React from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
@@ -29,14 +30,24 @@ import {
|
||||
type ZoomFocus,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
import { DEFAULT_FOCUS, MIN_DELTA, SMOOTHING_FACTOR } from "./videoPlayback/constants";
|
||||
import {
|
||||
DEFAULT_FOCUS,
|
||||
ZOOM_SCALE_DEADZONE,
|
||||
ZOOM_TRANSLATION_DEADZONE_PX,
|
||||
} from "./videoPlayback/constants";
|
||||
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
|
||||
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
|
||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
|
||||
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
|
||||
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
|
||||
import { applyZoomTransform } from "./videoPlayback/zoomTransform";
|
||||
import {
|
||||
applyZoomTransform,
|
||||
computeFocusFromTransform,
|
||||
computeZoomTransform,
|
||||
createMotionBlurState,
|
||||
type MotionBlurState,
|
||||
} from "./videoPlayback/zoomTransform";
|
||||
|
||||
interface VideoPlaybackProps {
|
||||
videoPath: string;
|
||||
@@ -113,6 +124,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const ZOOM_MOTION_BLUR_AMOUNT = 0.35;
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const appRef = useRef<Application | null>(null);
|
||||
@@ -131,8 +143,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
scale: 1,
|
||||
focusX: DEFAULT_FOCUS.cx,
|
||||
focusY: DEFAULT_FOCUS.cy,
|
||||
progress: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
appliedScale: 1,
|
||||
});
|
||||
const blurFilterRef = useRef<BlurFilter | null>(null);
|
||||
const motionBlurFilterRef = useRef<MotionBlurFilter | null>(null);
|
||||
const isDraggingFocusRef = useRef(false);
|
||||
const stageSizeRef = useRef({ width: 0, height: 0 });
|
||||
const videoSizeRef = useRef({ width: 0, height: 0 });
|
||||
@@ -149,6 +166,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const trimRegionsRef = useRef<TrimRegion[]>([]);
|
||||
const speedRegionsRef = useRef<SpeedRegion[]>([]);
|
||||
const motionBlurEnabledRef = useRef(motionBlurEnabled);
|
||||
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
|
||||
const onTimeUpdateRef = useRef(onTimeUpdate);
|
||||
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
||||
const videoReadyRafRef = useRef<number | null>(null);
|
||||
@@ -412,8 +430,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
scale: 1,
|
||||
focusX: DEFAULT_FOCUS.cx,
|
||||
focusY: DEFAULT_FOCUS.cy,
|
||||
progress: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
appliedScale: 1,
|
||||
};
|
||||
|
||||
// Reset motion blur state for clean transitions
|
||||
motionBlurStateRef.current = createMotionBlurState();
|
||||
|
||||
if (blurFilterRef.current) {
|
||||
blurFilterRef.current.blur = 0;
|
||||
}
|
||||
@@ -446,7 +471,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
focusY: DEFAULT_FOCUS.cy,
|
||||
motionIntensity: 0,
|
||||
isPlaying: false,
|
||||
motionBlurEnabled: motionBlurEnabledRef.current,
|
||||
motionBlurAmount: motionBlurEnabledRef.current ? ZOOM_MOTION_BLUR_AMOUNT : 0,
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
@@ -605,14 +630,20 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
scale: 1,
|
||||
focusX: DEFAULT_FOCUS.cx,
|
||||
focusY: DEFAULT_FOCUS.cy,
|
||||
progress: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
appliedScale: 1,
|
||||
};
|
||||
|
||||
const blurFilter = new BlurFilter();
|
||||
blurFilter.quality = 3;
|
||||
blurFilter.resolution = app.renderer.resolution;
|
||||
blurFilter.blur = 0;
|
||||
videoContainer.filters = [blurFilter];
|
||||
const motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
|
||||
videoContainer.filters = [blurFilter, motionBlurFilter];
|
||||
blurFilterRef.current = blurFilter;
|
||||
motionBlurFilterRef.current = motionBlurFilter;
|
||||
|
||||
layoutVideoContentRef.current?.();
|
||||
video.pause();
|
||||
@@ -662,6 +693,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
blurFilterRef.current.destroy();
|
||||
blurFilterRef.current = null;
|
||||
}
|
||||
if (motionBlurFilterRef.current) {
|
||||
motionBlurFilterRef.current.destroy();
|
||||
motionBlurFilterRef.current = null;
|
||||
}
|
||||
videoTexture.destroy(true);
|
||||
|
||||
videoSpriteRef.current = null;
|
||||
@@ -676,97 +711,154 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const videoContainer = videoContainerRef.current;
|
||||
if (!app || !videoSprite || !videoContainer) return;
|
||||
|
||||
const applyTransform = (motionIntensity: number) => {
|
||||
const applyTransformFn = (
|
||||
transform: { scale: number; x: number; y: number },
|
||||
targetFocus: ZoomFocus,
|
||||
motionIntensity: number,
|
||||
motionVector: { x: number; y: number },
|
||||
) => {
|
||||
const cameraContainer = cameraContainerRef.current;
|
||||
if (!cameraContainer) return;
|
||||
|
||||
const state = animationStateRef.current;
|
||||
|
||||
applyZoomTransform({
|
||||
const appliedTransform = applyZoomTransform({
|
||||
cameraContainer,
|
||||
blurFilter: blurFilterRef.current,
|
||||
motionBlurFilter: motionBlurFilterRef.current,
|
||||
stageSize: stageSizeRef.current,
|
||||
baseMask: baseMaskRef.current,
|
||||
zoomScale: state.scale,
|
||||
focusX: state.focusX,
|
||||
focusY: state.focusY,
|
||||
zoomProgress: state.progress,
|
||||
focusX: targetFocus.cx,
|
||||
focusY: targetFocus.cy,
|
||||
motionIntensity,
|
||||
motionVector,
|
||||
isPlaying: isPlayingRef.current,
|
||||
motionBlurEnabled: motionBlurEnabledRef.current,
|
||||
motionBlurAmount: motionBlurEnabledRef.current ? ZOOM_MOTION_BLUR_AMOUNT : 0,
|
||||
transformOverride: transform,
|
||||
motionBlurState: motionBlurStateRef.current,
|
||||
frameTimeMs: performance.now(),
|
||||
});
|
||||
|
||||
state.x = appliedTransform.x;
|
||||
state.y = appliedTransform.y;
|
||||
state.appliedScale = appliedTransform.scale;
|
||||
};
|
||||
|
||||
const ticker = () => {
|
||||
const { region, strength } = findDominantRegion(
|
||||
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||
zoomRegionsRef.current,
|
||||
currentTimeRef.current,
|
||||
{ connectZooms: true },
|
||||
);
|
||||
|
||||
const defaultFocus = DEFAULT_FOCUS;
|
||||
let targetScaleFactor = 1;
|
||||
let targetFocus = defaultFocus;
|
||||
let targetProgress = 0;
|
||||
|
||||
// If a zoom is selected but video is not playing, show default unzoomed view
|
||||
// (the overlay will show where the zoom will be)
|
||||
const selectedId = selectedZoomIdRef.current;
|
||||
const hasSelectedZoom = selectedId !== null;
|
||||
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
|
||||
|
||||
if (region && strength > 0 && !shouldShowUnzoomedView) {
|
||||
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
|
||||
const regionFocus = clampFocusToStage(region.focus, region.depth);
|
||||
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
|
||||
const regionFocus = region.focus;
|
||||
|
||||
// Interpolate scale and focus based on region strength
|
||||
targetScaleFactor = 1 + (zoomScale - 1) * strength;
|
||||
targetFocus = {
|
||||
cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength,
|
||||
cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * strength,
|
||||
};
|
||||
targetScaleFactor = zoomScale;
|
||||
targetFocus = regionFocus;
|
||||
targetProgress = strength;
|
||||
|
||||
// Handle connected zoom transitions (pan between adjacent zoom regions)
|
||||
if (transition) {
|
||||
const startTransform = computeZoomTransform({
|
||||
stageSize: stageSizeRef.current,
|
||||
baseMask: baseMaskRef.current,
|
||||
zoomScale: transition.startScale,
|
||||
zoomProgress: 1,
|
||||
focusX: transition.startFocus.cx,
|
||||
focusY: transition.startFocus.cy,
|
||||
});
|
||||
const endTransform = computeZoomTransform({
|
||||
stageSize: stageSizeRef.current,
|
||||
baseMask: baseMaskRef.current,
|
||||
zoomScale: transition.endScale,
|
||||
zoomProgress: 1,
|
||||
focusX: transition.endFocus.cx,
|
||||
focusY: transition.endFocus.cy,
|
||||
});
|
||||
|
||||
const interpolatedTransform = {
|
||||
scale:
|
||||
startTransform.scale +
|
||||
(endTransform.scale - startTransform.scale) * transition.progress,
|
||||
x: startTransform.x + (endTransform.x - startTransform.x) * transition.progress,
|
||||
y: startTransform.y + (endTransform.y - startTransform.y) * transition.progress,
|
||||
};
|
||||
|
||||
targetScaleFactor = interpolatedTransform.scale;
|
||||
targetFocus = computeFocusFromTransform({
|
||||
stageSize: stageSizeRef.current,
|
||||
baseMask: baseMaskRef.current,
|
||||
zoomScale: interpolatedTransform.scale,
|
||||
x: interpolatedTransform.x,
|
||||
y: interpolatedTransform.y,
|
||||
});
|
||||
targetProgress = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const state = animationStateRef.current;
|
||||
const prevScale = state.appliedScale;
|
||||
const prevX = state.x;
|
||||
const prevY = state.y;
|
||||
|
||||
const prevScale = state.scale;
|
||||
const prevFocusX = state.focusX;
|
||||
const prevFocusY = state.focusY;
|
||||
state.scale = targetScaleFactor;
|
||||
state.focusX = targetFocus.cx;
|
||||
state.focusY = targetFocus.cy;
|
||||
state.progress = targetProgress;
|
||||
|
||||
const scaleDelta = targetScaleFactor - state.scale;
|
||||
const focusXDelta = targetFocus.cx - state.focusX;
|
||||
const focusYDelta = targetFocus.cy - state.focusY;
|
||||
const projectedTransform = computeZoomTransform({
|
||||
stageSize: stageSizeRef.current,
|
||||
baseMask: baseMaskRef.current,
|
||||
zoomScale: state.scale,
|
||||
zoomProgress: state.progress,
|
||||
focusX: state.focusX,
|
||||
focusY: state.focusY,
|
||||
});
|
||||
|
||||
let nextScale = prevScale;
|
||||
let nextFocusX = prevFocusX;
|
||||
let nextFocusY = prevFocusY;
|
||||
|
||||
if (Math.abs(scaleDelta) > MIN_DELTA) {
|
||||
nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR;
|
||||
} else {
|
||||
nextScale = targetScaleFactor;
|
||||
}
|
||||
|
||||
if (Math.abs(focusXDelta) > MIN_DELTA) {
|
||||
nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR;
|
||||
} else {
|
||||
nextFocusX = targetFocus.cx;
|
||||
}
|
||||
|
||||
if (Math.abs(focusYDelta) > MIN_DELTA) {
|
||||
nextFocusY = prevFocusY + focusYDelta * SMOOTHING_FACTOR;
|
||||
} else {
|
||||
nextFocusY = targetFocus.cy;
|
||||
}
|
||||
|
||||
state.scale = nextScale;
|
||||
state.focusX = nextFocusX;
|
||||
state.focusY = nextFocusY;
|
||||
const appliedScale =
|
||||
Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE
|
||||
? projectedTransform.scale
|
||||
: projectedTransform.scale;
|
||||
const appliedX =
|
||||
Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX
|
||||
? projectedTransform.x
|
||||
: projectedTransform.x;
|
||||
const appliedY =
|
||||
Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX
|
||||
? projectedTransform.y
|
||||
: projectedTransform.y;
|
||||
|
||||
const motionIntensity = Math.max(
|
||||
Math.abs(nextScale - prevScale),
|
||||
Math.abs(nextFocusX - prevFocusX),
|
||||
Math.abs(nextFocusY - prevFocusY),
|
||||
Math.abs(appliedScale - prevScale),
|
||||
Math.abs(appliedX - prevX) / Math.max(1, stageSizeRef.current.width),
|
||||
Math.abs(appliedY - prevY) / Math.max(1, stageSizeRef.current.height),
|
||||
);
|
||||
|
||||
applyTransform(motionIntensity);
|
||||
const motionVector = {
|
||||
x: appliedX - prevX,
|
||||
y: appliedY - prevY,
|
||||
};
|
||||
|
||||
applyTransformFn(
|
||||
{ scale: appliedScale, x: appliedX, y: appliedY },
|
||||
targetFocus,
|
||||
motionIntensity,
|
||||
motionVector,
|
||||
);
|
||||
};
|
||||
|
||||
app.ticker.add(ticker);
|
||||
@@ -775,7 +867,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
app.ticker.remove(ticker);
|
||||
}
|
||||
};
|
||||
}, [pixiReady, videoReady, clampFocusToStage]);
|
||||
}, [pixiReady, videoReady]);
|
||||
|
||||
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||
const video = e.currentTarget;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ZoomFocus } from "../types";
|
||||
|
||||
export const DEFAULT_FOCUS: ZoomFocus = { cx: 0.5, cy: 0.5 };
|
||||
export const TRANSITION_WINDOW_MS = 320;
|
||||
export const SMOOTHING_FACTOR = 0.12;
|
||||
export const TRANSITION_WINDOW_MS = 1015.05;
|
||||
export const ZOOM_IN_TRANSITION_WINDOW_MS = TRANSITION_WINDOW_MS * 1.5;
|
||||
export const MIN_DELTA = 0.0001;
|
||||
export const VIEWPORT_SCALE = 0.8;
|
||||
export const SMOOTHING_FACTOR = 0.12;
|
||||
export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25;
|
||||
export const ZOOM_SCALE_DEADZONE = 0.002;
|
||||
|
||||
@@ -5,28 +5,93 @@ interface StageSize {
|
||||
height: number;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function easeIntoBoundary(normalized: number) {
|
||||
const t = clamp(normalized, 0, 1);
|
||||
return -t * t * t + 2 * t * t;
|
||||
}
|
||||
|
||||
function softClampToRange(value: number, min: number, max: number, softness: number) {
|
||||
const clamped = clamp(value, min, max);
|
||||
|
||||
if (softness <= 0 || max <= min) {
|
||||
return clamped;
|
||||
}
|
||||
|
||||
if (clamped < min + softness) {
|
||||
const normalized = (clamped - min) / softness;
|
||||
return min + softness * easeIntoBoundary(normalized);
|
||||
}
|
||||
|
||||
if (clamped > max - softness) {
|
||||
const normalized = (max - clamped) / softness;
|
||||
return max - softness * easeIntoBoundary(normalized);
|
||||
}
|
||||
|
||||
return clamped;
|
||||
}
|
||||
|
||||
function getFocusBounds(depth: ZoomDepth) {
|
||||
const zoomScale = ZOOM_DEPTH_SCALES[depth];
|
||||
return getFocusBoundsForScale(zoomScale);
|
||||
}
|
||||
|
||||
function getFocusBoundsForScale(zoomScale: number) {
|
||||
const marginX = 1 / (2 * zoomScale);
|
||||
const marginY = 1 / (2 * zoomScale);
|
||||
|
||||
return {
|
||||
minX: marginX,
|
||||
maxX: 1 - marginX,
|
||||
minY: marginY,
|
||||
maxY: 1 - marginY,
|
||||
};
|
||||
}
|
||||
|
||||
export function clampFocusToStage(
|
||||
focus: ZoomFocus,
|
||||
depth: ZoomDepth,
|
||||
stageSize: StageSize,
|
||||
_stageSize: StageSize,
|
||||
): ZoomFocus {
|
||||
if (!stageSize.width || !stageSize.height) {
|
||||
return clampFocusToDepth(focus, depth);
|
||||
}
|
||||
|
||||
const zoomScale = ZOOM_DEPTH_SCALES[depth];
|
||||
|
||||
const windowWidth = stageSize.width / zoomScale;
|
||||
const windowHeight = stageSize.height / zoomScale;
|
||||
|
||||
const marginX = windowWidth / (2 * stageSize.width);
|
||||
const marginY = windowHeight / (2 * stageSize.height);
|
||||
|
||||
const baseFocus = clampFocusToDepth(focus, depth);
|
||||
const bounds = getFocusBounds(depth);
|
||||
|
||||
return {
|
||||
cx: Math.max(marginX, Math.min(1 - marginX, baseFocus.cx)),
|
||||
cy: Math.max(marginY, Math.min(1 - marginY, baseFocus.cy)),
|
||||
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
|
||||
cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY),
|
||||
};
|
||||
}
|
||||
|
||||
export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
|
||||
const baseFocus = {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
cy: clamp(focus.cy, 0, 1),
|
||||
};
|
||||
const bounds = getFocusBoundsForScale(zoomScale);
|
||||
|
||||
return {
|
||||
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
|
||||
cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY),
|
||||
};
|
||||
}
|
||||
|
||||
export function softenFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
|
||||
const baseFocus = {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
cy: clamp(focus.cy, 0, 1),
|
||||
};
|
||||
const bounds = getFocusBoundsForScale(zoomScale);
|
||||
const horizontalRange = bounds.maxX - bounds.minX;
|
||||
const verticalRange = bounds.maxY - bounds.minY;
|
||||
const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35);
|
||||
const verticalSoftness = Math.min(0.12, verticalRange * 0.35);
|
||||
|
||||
return {
|
||||
cx: softClampToRange(baseFocus.cx, bounds.minX, bounds.maxX, horizontalSoftness),
|
||||
cy: softClampToRange(baseFocus.cy, bounds.minY, bounds.maxY, verticalSoftness),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,85 @@ export function clamp01(value: number) {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function sampleCubicBezier(a1: number, a2: number, t: number) {
|
||||
const oneMinusT = 1 - t;
|
||||
return 3 * a1 * oneMinusT * oneMinusT * t + 3 * a2 * oneMinusT * t * t + t * t * t;
|
||||
}
|
||||
|
||||
function sampleCubicBezierDerivative(a1: number, a2: number, t: number) {
|
||||
const oneMinusT = 1 - t;
|
||||
return 3 * a1 * oneMinusT * oneMinusT + 6 * (a2 - a1) * oneMinusT * t + 3 * (1 - a2) * t * t;
|
||||
}
|
||||
|
||||
export function cubicBezier(x1: number, y1: number, x2: number, y2: number, t: number) {
|
||||
const targetX = clamp01(t);
|
||||
let solvedT = targetX;
|
||||
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const currentX = sampleCubicBezier(x1, x2, solvedT) - targetX;
|
||||
const currentDerivative = sampleCubicBezierDerivative(x1, x2, solvedT);
|
||||
|
||||
if (Math.abs(currentX) < 1e-6 || Math.abs(currentDerivative) < 1e-6) {
|
||||
break;
|
||||
}
|
||||
|
||||
solvedT -= currentX / currentDerivative;
|
||||
}
|
||||
|
||||
let lower = 0;
|
||||
let upper = 1;
|
||||
solvedT = clamp01(solvedT);
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const currentX = sampleCubicBezier(x1, x2, solvedT);
|
||||
if (Math.abs(currentX - targetX) < 1e-6) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentX < targetX) {
|
||||
lower = solvedT;
|
||||
} else {
|
||||
upper = solvedT;
|
||||
}
|
||||
|
||||
solvedT = (lower + upper) / 2;
|
||||
}
|
||||
|
||||
return sampleCubicBezier(y1, y2, solvedT);
|
||||
}
|
||||
|
||||
export function easeOutExpo(t: number) {
|
||||
const clamped = clamp01(t);
|
||||
if (clamped === 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 1 - Math.pow(2, -7 * clamped);
|
||||
}
|
||||
|
||||
export function easeOutScreenStudio(t: number) {
|
||||
return cubicBezier(0.16, 1, 0.3, 1, t);
|
||||
}
|
||||
|
||||
export function smoothStep(t: number) {
|
||||
const clamped = clamp01(t);
|
||||
return clamped * clamped * (3 - 2 * clamped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gentle ease-in-out cubic — slow start, smooth middle, gentle landing.
|
||||
* Used for zoom-in transitions.
|
||||
*/
|
||||
export function easeInOutCubic(t: number) {
|
||||
const x = clamp01(t);
|
||||
return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ease-out cubic — starts at speed, then decelerates to a gentle stop.
|
||||
* Used for zoom-out transitions so strength eases smoothly to zero.
|
||||
*/
|
||||
export function easeOutCubic(t: number) {
|
||||
const x = clamp01(t);
|
||||
return 1 - Math.pow(1 - x, 3);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,224 @@
|
||||
import type { ZoomRegion } from "../types";
|
||||
import { TRANSITION_WINDOW_MS } from "./constants";
|
||||
import { smoothStep } from "./mathUtils";
|
||||
import type { ZoomFocus, ZoomRegion } from "../types";
|
||||
import { ZOOM_DEPTH_SCALES } from "../types";
|
||||
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
|
||||
import { clampFocusToScale } from "./focusUtils";
|
||||
import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
|
||||
|
||||
const CHAINED_ZOOM_PAN_GAP_MS = 1500;
|
||||
const CONNECTED_ZOOM_PAN_DURATION_MS = 1000;
|
||||
const ZOOM_IN_OVERLAP_MS = 500;
|
||||
|
||||
type DominantRegionOptions = {
|
||||
connectZooms?: boolean;
|
||||
};
|
||||
|
||||
type ConnectedRegionPair = {
|
||||
currentRegion: ZoomRegion;
|
||||
nextRegion: ZoomRegion;
|
||||
transitionStart: number;
|
||||
transitionEnd: number;
|
||||
};
|
||||
|
||||
type ConnectedPanTransition = {
|
||||
progress: number;
|
||||
startFocus: ZoomFocus;
|
||||
endFocus: ZoomFocus;
|
||||
startScale: number;
|
||||
endScale: number;
|
||||
};
|
||||
|
||||
function lerp(start: number, end: number, amount: number) {
|
||||
return start + (end - start) * amount;
|
||||
}
|
||||
|
||||
function easeConnectedPan(value: number) {
|
||||
return cubicBezier(0.1, 0.0, 0.2, 1.0, value);
|
||||
}
|
||||
|
||||
export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
|
||||
const leadInStart = region.startMs - TRANSITION_WINDOW_MS;
|
||||
const zoomInEnd = region.startMs + ZOOM_IN_OVERLAP_MS;
|
||||
const leadInStart = zoomInEnd - ZOOM_IN_TRANSITION_WINDOW_MS;
|
||||
const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS;
|
||||
|
||||
if (timeMs < leadInStart || timeMs > leadOutEnd) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const fadeIn = smoothStep((timeMs - leadInStart) / TRANSITION_WINDOW_MS);
|
||||
const fadeOut = smoothStep((leadOutEnd - timeMs) / TRANSITION_WINDOW_MS);
|
||||
return Math.min(fadeIn, fadeOut);
|
||||
if (timeMs < zoomInEnd) {
|
||||
const progress = (timeMs - leadInStart) / ZOOM_IN_TRANSITION_WINDOW_MS;
|
||||
return easeOutScreenStudio(progress);
|
||||
}
|
||||
|
||||
if (timeMs <= region.endMs) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const progress = clamp01((timeMs - region.endMs) / TRANSITION_WINDOW_MS);
|
||||
return 1 - easeOutScreenStudio(progress);
|
||||
}
|
||||
|
||||
export function findDominantRegion(regions: ZoomRegion[], timeMs: number) {
|
||||
let bestRegion: ZoomRegion | null = null;
|
||||
let bestStrength = 0;
|
||||
function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus {
|
||||
return {
|
||||
cx: lerp(start.cx, end.cx, amount),
|
||||
cy: lerp(start.cy, end.cy, amount),
|
||||
};
|
||||
}
|
||||
|
||||
for (const region of regions) {
|
||||
const strength = computeRegionStrength(region, timeMs);
|
||||
if (strength > bestStrength) {
|
||||
bestStrength = strength;
|
||||
bestRegion = region;
|
||||
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
|
||||
return clampFocusToScale(region.focus, zoomScale);
|
||||
}
|
||||
|
||||
function getConnectedRegionPairs(regions: ZoomRegion[]) {
|
||||
const sortedRegions = [...regions].sort((a, b) => a.startMs - b.startMs);
|
||||
const pairs: ConnectedRegionPair[] = [];
|
||||
|
||||
for (let index = 0; index < sortedRegions.length - 1; index += 1) {
|
||||
const currentRegion = sortedRegions[index];
|
||||
const nextRegion = sortedRegions[index + 1];
|
||||
const gapMs = nextRegion.startMs - currentRegion.endMs;
|
||||
|
||||
if (gapMs > CHAINED_ZOOM_PAN_GAP_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pairs.push({
|
||||
currentRegion,
|
||||
nextRegion,
|
||||
transitionStart: currentRegion.endMs,
|
||||
transitionEnd: currentRegion.endMs + CONNECTED_ZOOM_PAN_DURATION_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function getActiveRegion(
|
||||
regions: ZoomRegion[],
|
||||
timeMs: number,
|
||||
connectedPairs: ConnectedRegionPair[],
|
||||
) {
|
||||
const activeRegions = regions
|
||||
.map((region) => {
|
||||
const outgoingPair = connectedPairs.find((pair) => pair.currentRegion.id === region.id);
|
||||
if (outgoingPair && timeMs > outgoingPair.currentRegion.endMs) {
|
||||
return { region, strength: 0 };
|
||||
}
|
||||
|
||||
const incomingPair = connectedPairs.find((pair) => pair.nextRegion.id === region.id);
|
||||
if (incomingPair && timeMs < incomingPair.transitionEnd) {
|
||||
return { region, strength: 0 };
|
||||
}
|
||||
|
||||
return { region, strength: computeRegionStrength(region, timeMs) };
|
||||
})
|
||||
.filter((entry) => entry.strength > 0)
|
||||
.sort((left, right) => {
|
||||
if (right.strength !== left.strength) {
|
||||
return right.strength - left.strength;
|
||||
}
|
||||
|
||||
return right.region.startMs - left.region.startMs;
|
||||
});
|
||||
|
||||
if (activeRegions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeRegion = activeRegions[0].region;
|
||||
const activeScale = ZOOM_DEPTH_SCALES[activeRegion.depth];
|
||||
|
||||
return {
|
||||
region: {
|
||||
...activeRegion,
|
||||
focus: getResolvedFocus(activeRegion, activeScale),
|
||||
},
|
||||
strength: activeRegions[0].strength,
|
||||
blendedScale: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) {
|
||||
for (const pair of connectedPairs) {
|
||||
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
|
||||
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
|
||||
return {
|
||||
region: {
|
||||
...pair.nextRegion,
|
||||
focus: getResolvedFocus(pair.nextRegion, nextScale),
|
||||
},
|
||||
strength: 1,
|
||||
blendedScale: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { region: bestRegion, strength: bestStrength };
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) {
|
||||
for (const pair of connectedPairs) {
|
||||
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
|
||||
|
||||
if (timeMs < transitionStart || timeMs > transitionEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transitionProgress = easeConnectedPan(
|
||||
clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)),
|
||||
);
|
||||
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
|
||||
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
|
||||
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
|
||||
const currentFocus = getResolvedFocus(currentRegion, currentScale);
|
||||
const nextFocus = getResolvedFocus(nextRegion, nextScale);
|
||||
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
|
||||
|
||||
return {
|
||||
region: {
|
||||
...nextRegion,
|
||||
focus: transitionFocus,
|
||||
},
|
||||
strength: 1,
|
||||
blendedScale: transitionScale,
|
||||
transition: {
|
||||
progress: transitionProgress,
|
||||
startFocus: currentFocus,
|
||||
endFocus: nextFocus,
|
||||
startScale: currentScale,
|
||||
endScale: nextScale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findDominantRegion(
|
||||
regions: ZoomRegion[],
|
||||
timeMs: number,
|
||||
options: DominantRegionOptions = {},
|
||||
): {
|
||||
region: ZoomRegion | null;
|
||||
strength: number;
|
||||
blendedScale: number | null;
|
||||
transition: ConnectedPanTransition | null;
|
||||
} {
|
||||
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
|
||||
|
||||
if (options.connectZooms) {
|
||||
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs);
|
||||
if (connectedTransition) {
|
||||
return connectedTransition;
|
||||
}
|
||||
|
||||
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs);
|
||||
if (connectedHold) {
|
||||
return { ...connectedHold, transition: null };
|
||||
}
|
||||
}
|
||||
|
||||
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs);
|
||||
return activeRegion
|
||||
? { ...activeRegion, transition: null }
|
||||
: { region: null, strength: 0, blendedScale: null, transition: null };
|
||||
}
|
||||
|
||||
@@ -1,61 +1,243 @@
|
||||
import { BlurFilter, Container } from "pixi.js";
|
||||
import { MotionBlurFilter } from "pixi-filters/motion-blur";
|
||||
|
||||
const PEAK_VELOCITY_PPS = 2000;
|
||||
const MAX_BLUR_PX = 8;
|
||||
const VELOCITY_THRESHOLD_PPS = 15;
|
||||
|
||||
export interface MotionBlurState {
|
||||
lastFrameTimeMs: number;
|
||||
prevCamX: number;
|
||||
prevCamY: number;
|
||||
prevCamScale: number;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
export function createMotionBlurState(): MotionBlurState {
|
||||
return {
|
||||
lastFrameTimeMs: 0,
|
||||
prevCamX: 0,
|
||||
prevCamY: 0,
|
||||
prevCamScale: 1,
|
||||
initialized: false,
|
||||
};
|
||||
}
|
||||
|
||||
interface TransformParams {
|
||||
cameraContainer: Container;
|
||||
blurFilter: BlurFilter | null;
|
||||
motionBlurFilter?: MotionBlurFilter | null;
|
||||
stageSize: { width: number; height: number };
|
||||
baseMask: { x: number; y: number; width: number; height: number };
|
||||
zoomScale: number;
|
||||
zoomProgress?: number;
|
||||
focusX: number;
|
||||
focusY: number;
|
||||
motionIntensity: number;
|
||||
motionVector?: { x: number; y: number };
|
||||
isPlaying: boolean;
|
||||
motionBlurEnabled?: boolean;
|
||||
motionBlurAmount?: number;
|
||||
transformOverride?: AppliedTransform;
|
||||
motionBlurState?: MotionBlurState;
|
||||
frameTimeMs?: number;
|
||||
}
|
||||
|
||||
export function applyZoomTransform({
|
||||
cameraContainer,
|
||||
blurFilter,
|
||||
interface AppliedTransform {
|
||||
scale: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface FocusFromTransformGeometry {
|
||||
stageSize: { width: number; height: number };
|
||||
baseMask: { x: number; y: number; width: number; height: number };
|
||||
zoomScale: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ZoomTransformGeometry {
|
||||
stageSize: { width: number; height: number };
|
||||
baseMask: { x: number; y: number; width: number; height: number };
|
||||
zoomScale: number;
|
||||
zoomProgress?: number;
|
||||
focusX: number;
|
||||
focusY: number;
|
||||
}
|
||||
|
||||
export function computeZoomTransform({
|
||||
stageSize,
|
||||
baseMask,
|
||||
zoomScale,
|
||||
zoomProgress = 1,
|
||||
focusX,
|
||||
focusY,
|
||||
motionIntensity,
|
||||
isPlaying,
|
||||
motionBlurEnabled = false,
|
||||
}: TransformParams) {
|
||||
}: ZoomTransformGeometry): AppliedTransform {
|
||||
if (
|
||||
stageSize.width <= 0 ||
|
||||
stageSize.height <= 0 ||
|
||||
baseMask.width <= 0 ||
|
||||
baseMask.height <= 0
|
||||
) {
|
||||
return;
|
||||
return { scale: 1, x: 0, y: 0 };
|
||||
}
|
||||
|
||||
// The focus point in stage coordinates (where the user clicked/selected)
|
||||
const focusStagePxX = focusX * stageSize.width;
|
||||
const focusStagePxY = focusY * stageSize.height;
|
||||
|
||||
// Stage center (where we want the focus to end up after zoom)
|
||||
const progress = Math.min(1, Math.max(0, zoomProgress));
|
||||
const focusStagePxX = baseMask.x + focusX * baseMask.width;
|
||||
const focusStagePxY = baseMask.y + focusY * baseMask.height;
|
||||
const stageCenterX = stageSize.width / 2;
|
||||
const stageCenterY = stageSize.height / 2;
|
||||
const scale = 1 + (zoomScale - 1) * progress;
|
||||
const finalX = stageCenterX - focusStagePxX * zoomScale;
|
||||
const finalY = stageCenterY - focusStagePxY * zoomScale;
|
||||
|
||||
// Apply zoom scale to camera container
|
||||
cameraContainer.scale.set(zoomScale);
|
||||
|
||||
// Calculate camera position to keep focus point centered
|
||||
// After scaling, the focus point moves to (focusX * zoomScale, focusY * zoomScale)
|
||||
// We want it at stage center, so offset = center - (focus * scale)
|
||||
const cameraX = stageCenterX - focusStagePxX * zoomScale;
|
||||
const cameraY = stageCenterY - focusStagePxY * zoomScale;
|
||||
|
||||
cameraContainer.position.set(cameraX, cameraY);
|
||||
|
||||
if (blurFilter) {
|
||||
const shouldBlur = motionBlurEnabled && isPlaying && motionIntensity > 0.0005;
|
||||
const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0;
|
||||
blurFilter.blur = motionBlur;
|
||||
}
|
||||
return {
|
||||
scale,
|
||||
x: finalX * progress,
|
||||
y: finalY * progress,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeFocusFromTransform({
|
||||
stageSize,
|
||||
baseMask,
|
||||
zoomScale,
|
||||
x,
|
||||
y,
|
||||
}: FocusFromTransformGeometry) {
|
||||
if (
|
||||
stageSize.width <= 0 ||
|
||||
stageSize.height <= 0 ||
|
||||
baseMask.width <= 0 ||
|
||||
baseMask.height <= 0 ||
|
||||
zoomScale <= 0
|
||||
) {
|
||||
return { cx: 0.5, cy: 0.5 };
|
||||
}
|
||||
|
||||
const stageCenterX = stageSize.width / 2;
|
||||
const stageCenterY = stageSize.height / 2;
|
||||
const focusStagePxX = (stageCenterX - x) / zoomScale;
|
||||
const focusStagePxY = (stageCenterY - y) / zoomScale;
|
||||
|
||||
return {
|
||||
cx: (focusStagePxX - baseMask.x) / baseMask.width,
|
||||
cy: (focusStagePxY - baseMask.y) / baseMask.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyZoomTransform({
|
||||
cameraContainer,
|
||||
blurFilter,
|
||||
motionBlurFilter,
|
||||
stageSize,
|
||||
baseMask,
|
||||
zoomScale,
|
||||
zoomProgress = 1,
|
||||
focusX,
|
||||
focusY,
|
||||
motionIntensity: _motionIntensity,
|
||||
motionVector: _motionVector,
|
||||
isPlaying,
|
||||
motionBlurAmount = 0,
|
||||
transformOverride,
|
||||
motionBlurState,
|
||||
frameTimeMs,
|
||||
}: TransformParams): AppliedTransform {
|
||||
if (
|
||||
stageSize.width <= 0 ||
|
||||
stageSize.height <= 0 ||
|
||||
baseMask.width <= 0 ||
|
||||
baseMask.height <= 0
|
||||
) {
|
||||
return { scale: 1, x: 0, y: 0 };
|
||||
}
|
||||
|
||||
const transform =
|
||||
transformOverride ??
|
||||
computeZoomTransform({
|
||||
stageSize,
|
||||
baseMask,
|
||||
zoomScale,
|
||||
zoomProgress,
|
||||
focusX,
|
||||
focusY,
|
||||
});
|
||||
|
||||
// Apply position & scale to camera container
|
||||
cameraContainer.scale.set(transform.scale);
|
||||
cameraContainer.position.set(transform.x, transform.y);
|
||||
|
||||
if (motionBlurState && motionBlurFilter && motionBlurAmount > 0 && isPlaying) {
|
||||
const now = frameTimeMs ?? performance.now();
|
||||
|
||||
if (!motionBlurState.initialized) {
|
||||
motionBlurState.prevCamX = transform.x;
|
||||
motionBlurState.prevCamY = transform.y;
|
||||
motionBlurState.prevCamScale = transform.scale;
|
||||
motionBlurState.lastFrameTimeMs = now;
|
||||
motionBlurState.initialized = true;
|
||||
motionBlurFilter.velocity = { x: 0, y: 0 };
|
||||
motionBlurFilter.kernelSize = 5;
|
||||
motionBlurFilter.offset = 0;
|
||||
if (blurFilter) blurFilter.blur = 0;
|
||||
} else {
|
||||
const dtMs = Math.min(80, Math.max(1, now - motionBlurState.lastFrameTimeMs));
|
||||
const dtSeconds = dtMs / 1000;
|
||||
motionBlurState.lastFrameTimeMs = now;
|
||||
|
||||
// Camera displacement this frame (stage-px)
|
||||
const dx = transform.x - motionBlurState.prevCamX;
|
||||
const dy = transform.y - motionBlurState.prevCamY;
|
||||
const dScale = transform.scale - motionBlurState.prevCamScale;
|
||||
|
||||
motionBlurState.prevCamX = transform.x;
|
||||
motionBlurState.prevCamY = transform.y;
|
||||
motionBlurState.prevCamScale = transform.scale;
|
||||
|
||||
// Velocity in px/s (translation + scale-change contribution)
|
||||
const velocityX = dx / dtSeconds;
|
||||
const velocityY = dy / dtSeconds;
|
||||
const scaleVelocity =
|
||||
Math.abs(dScale / dtSeconds) * Math.max(stageSize.width, stageSize.height) * 0.5;
|
||||
const speed = Math.sqrt(velocityX * velocityX + velocityY * velocityY) + scaleVelocity;
|
||||
|
||||
const normalised = Math.min(1, speed / PEAK_VELOCITY_PPS);
|
||||
const targetBlur =
|
||||
speed < VELOCITY_THRESHOLD_PPS
|
||||
? 0
|
||||
: normalised * normalised * MAX_BLUR_PX * motionBlurAmount;
|
||||
|
||||
const dirMag = Math.sqrt(velocityX * velocityX + velocityY * velocityY) || 1;
|
||||
const velocityScale = targetBlur * 1.2;
|
||||
motionBlurFilter.velocity =
|
||||
targetBlur > 0
|
||||
? { x: (velocityX / dirMag) * velocityScale, y: (velocityY / dirMag) * velocityScale }
|
||||
: { x: 0, y: 0 };
|
||||
motionBlurFilter.kernelSize = targetBlur > 4 ? 11 : targetBlur > 1.5 ? 9 : 5;
|
||||
motionBlurFilter.offset = targetBlur > 0.5 ? -0.2 : 0;
|
||||
|
||||
if (blurFilter) {
|
||||
blurFilter.blur = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (motionBlurFilter) {
|
||||
motionBlurFilter.velocity = { x: 0, y: 0 };
|
||||
motionBlurFilter.kernelSize = 5;
|
||||
motionBlurFilter.offset = 0;
|
||||
}
|
||||
if (blurFilter) {
|
||||
blurFilter.blur = 0;
|
||||
}
|
||||
if (motionBlurState) {
|
||||
motionBlurState.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scale: transform.scale,
|
||||
x: transform.x,
|
||||
y: transform.y,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Texture,
|
||||
type TextureSourceLike,
|
||||
} from "pixi.js";
|
||||
import { MotionBlurFilter } from "pixi-filters/motion-blur";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
CropRegion,
|
||||
@@ -17,13 +18,18 @@ import type {
|
||||
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
|
||||
import {
|
||||
DEFAULT_FOCUS,
|
||||
MIN_DELTA,
|
||||
SMOOTHING_FACTOR,
|
||||
ZOOM_SCALE_DEADZONE,
|
||||
ZOOM_TRANSLATION_DEADZONE_PX,
|
||||
} from "@/components/video-editor/videoPlayback/constants";
|
||||
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
|
||||
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
|
||||
import { applyZoomTransform } from "@/components/video-editor/videoPlayback/zoomTransform";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import {
|
||||
applyZoomTransform,
|
||||
computeFocusFromTransform,
|
||||
computeZoomTransform,
|
||||
createMotionBlurState,
|
||||
type MotionBlurState,
|
||||
} from "@/components/video-editor/videoPlayback/zoomTransform";
|
||||
import { renderAnnotations } from "./annotationRenderer";
|
||||
|
||||
interface FrameRenderConfig {
|
||||
@@ -50,6 +56,10 @@ interface AnimationState {
|
||||
scale: number;
|
||||
focusX: number;
|
||||
focusY: number;
|
||||
progress: number;
|
||||
x: number;
|
||||
y: number;
|
||||
appliedScale: number;
|
||||
}
|
||||
|
||||
interface LayoutCache {
|
||||
@@ -70,6 +80,7 @@ export class FrameRenderer {
|
||||
private backgroundSprite: HTMLCanvasElement | null = null;
|
||||
private maskGraphics: Graphics | null = null;
|
||||
private blurFilter: BlurFilter | null = null;
|
||||
private motionBlurFilter: MotionBlurFilter | null = null;
|
||||
private shadowCanvas: HTMLCanvasElement | null = null;
|
||||
private shadowCtx: CanvasRenderingContext2D | null = null;
|
||||
private compositeCanvas: HTMLCanvasElement | null = null;
|
||||
@@ -78,6 +89,7 @@ export class FrameRenderer {
|
||||
private animationState: AnimationState;
|
||||
private layoutCache: LayoutCache | null = null;
|
||||
private currentVideoTime = 0;
|
||||
private motionBlurState: MotionBlurState = createMotionBlurState();
|
||||
|
||||
constructor(config: FrameRenderConfig) {
|
||||
this.config = config;
|
||||
@@ -85,6 +97,10 @@ export class FrameRenderer {
|
||||
scale: 1,
|
||||
focusX: DEFAULT_FOCUS.cx,
|
||||
focusY: DEFAULT_FOCUS.cy,
|
||||
progress: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
appliedScale: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,7 +146,8 @@ export class FrameRenderer {
|
||||
this.blurFilter.quality = 5;
|
||||
this.blurFilter.resolution = this.app.renderer.resolution;
|
||||
this.blurFilter.blur = 0;
|
||||
this.videoContainer.filters = [this.blurFilter];
|
||||
this.motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
|
||||
this.videoContainer.filters = [this.blurFilter, this.motionBlurFilter];
|
||||
|
||||
// Setup composite canvas for final output with shadows
|
||||
this.compositeCanvas = document.createElement("canvas");
|
||||
@@ -179,14 +196,18 @@ export class FrameRenderer {
|
||||
) {
|
||||
// Image background
|
||||
const img = new Image();
|
||||
const imageUrl = await this.resolveWallpaperImageUrl(wallpaper);
|
||||
// Don't set crossOrigin for same-origin images to avoid CORS taint.
|
||||
if (
|
||||
imageUrl.startsWith("http") &&
|
||||
window.location.origin &&
|
||||
!imageUrl.startsWith(window.location.origin)
|
||||
) {
|
||||
img.crossOrigin = "anonymous";
|
||||
// Don't set crossOrigin for same-origin images to avoid CORS taint
|
||||
// Only set it for cross-origin URLs
|
||||
let imageUrl: string;
|
||||
if (wallpaper.startsWith("http")) {
|
||||
imageUrl = wallpaper;
|
||||
if (!imageUrl.startsWith(window.location.origin)) {
|
||||
img.crossOrigin = "anonymous";
|
||||
}
|
||||
} else if (wallpaper.startsWith("file://") || wallpaper.startsWith("data:")) {
|
||||
imageUrl = wallpaper;
|
||||
} else {
|
||||
imageUrl = window.location.origin + wallpaper;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -280,23 +301,6 @@ export class FrameRenderer {
|
||||
this.backgroundSprite = bgCanvas;
|
||||
}
|
||||
|
||||
private async resolveWallpaperImageUrl(wallpaper: string): Promise<string> {
|
||||
if (
|
||||
wallpaper.startsWith("file://") ||
|
||||
wallpaper.startsWith("data:") ||
|
||||
wallpaper.startsWith("http")
|
||||
) {
|
||||
return wallpaper;
|
||||
}
|
||||
|
||||
const resolved = await getAssetPath(wallpaper.replace(/^\/+/, ""));
|
||||
if (resolved.startsWith("/") && window.location.protocol.startsWith("http")) {
|
||||
return `${window.location.origin}${resolved}`;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise<void> {
|
||||
if (!this.app || !this.videoContainer || !this.cameraContainer) {
|
||||
throw new Error("Renderer not initialized");
|
||||
@@ -338,14 +342,18 @@ export class FrameRenderer {
|
||||
applyZoomTransform({
|
||||
cameraContainer: this.cameraContainer,
|
||||
blurFilter: this.blurFilter,
|
||||
motionBlurFilter: this.motionBlurFilter,
|
||||
stageSize: layoutCache.stageSize,
|
||||
baseMask: layoutCache.maskRect,
|
||||
zoomScale: this.animationState.scale,
|
||||
zoomProgress: this.animationState.progress,
|
||||
focusX: this.animationState.focusX,
|
||||
focusY: this.animationState.focusY,
|
||||
motionIntensity: maxMotionIntensity,
|
||||
isPlaying: true,
|
||||
motionBlurEnabled: this.config.motionBlurEnabled ?? false,
|
||||
motionBlurAmount: this.config.motionBlurEnabled ? 0.35 : 0,
|
||||
motionBlurState: this.motionBlurState,
|
||||
frameTimeMs: timeMs,
|
||||
});
|
||||
|
||||
// Render the PixiJS stage to its canvas (video only, transparent background)
|
||||
@@ -456,63 +464,104 @@ export class FrameRenderer {
|
||||
private updateAnimationState(timeMs: number): number {
|
||||
if (!this.cameraContainer || !this.layoutCache) return 0;
|
||||
|
||||
const { region, strength } = findDominantRegion(this.config.zoomRegions, timeMs);
|
||||
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||
this.config.zoomRegions,
|
||||
timeMs,
|
||||
{ connectZooms: true },
|
||||
);
|
||||
|
||||
const defaultFocus = DEFAULT_FOCUS;
|
||||
let targetScaleFactor = 1;
|
||||
let targetFocus = { ...defaultFocus };
|
||||
let targetProgress = 0;
|
||||
|
||||
if (region && strength > 0) {
|
||||
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
|
||||
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
|
||||
const regionFocus = this.clampFocusToStage(region.focus, region.depth);
|
||||
|
||||
targetScaleFactor = 1 + (zoomScale - 1) * strength;
|
||||
targetFocus = {
|
||||
cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength,
|
||||
cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * strength,
|
||||
};
|
||||
targetScaleFactor = zoomScale;
|
||||
targetFocus = regionFocus;
|
||||
targetProgress = strength;
|
||||
|
||||
if (transition) {
|
||||
const startTransform = computeZoomTransform({
|
||||
stageSize: this.layoutCache.stageSize,
|
||||
baseMask: this.layoutCache.maskRect,
|
||||
zoomScale: transition.startScale,
|
||||
zoomProgress: 1,
|
||||
focusX: transition.startFocus.cx,
|
||||
focusY: transition.startFocus.cy,
|
||||
});
|
||||
const endTransform = computeZoomTransform({
|
||||
stageSize: this.layoutCache.stageSize,
|
||||
baseMask: this.layoutCache.maskRect,
|
||||
zoomScale: transition.endScale,
|
||||
zoomProgress: 1,
|
||||
focusX: transition.endFocus.cx,
|
||||
focusY: transition.endFocus.cy,
|
||||
});
|
||||
|
||||
const interpolatedTransform = {
|
||||
scale:
|
||||
startTransform.scale +
|
||||
(endTransform.scale - startTransform.scale) * transition.progress,
|
||||
x: startTransform.x + (endTransform.x - startTransform.x) * transition.progress,
|
||||
y: startTransform.y + (endTransform.y - startTransform.y) * transition.progress,
|
||||
};
|
||||
|
||||
targetScaleFactor = interpolatedTransform.scale;
|
||||
targetFocus = computeFocusFromTransform({
|
||||
stageSize: this.layoutCache.stageSize,
|
||||
baseMask: this.layoutCache.maskRect,
|
||||
zoomScale: interpolatedTransform.scale,
|
||||
x: interpolatedTransform.x,
|
||||
y: interpolatedTransform.y,
|
||||
});
|
||||
targetProgress = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const state = this.animationState;
|
||||
|
||||
const prevScale = state.scale;
|
||||
const prevFocusX = state.focusX;
|
||||
const prevFocusY = state.focusY;
|
||||
const prevScale = state.appliedScale;
|
||||
const prevX = state.x;
|
||||
const prevY = state.y;
|
||||
|
||||
const scaleDelta = targetScaleFactor - state.scale;
|
||||
const focusXDelta = targetFocus.cx - state.focusX;
|
||||
const focusYDelta = targetFocus.cy - state.focusY;
|
||||
state.scale = targetScaleFactor;
|
||||
state.focusX = targetFocus.cx;
|
||||
state.focusY = targetFocus.cy;
|
||||
state.progress = targetProgress;
|
||||
|
||||
let nextScale = prevScale;
|
||||
let nextFocusX = prevFocusX;
|
||||
let nextFocusY = prevFocusY;
|
||||
const projectedTransform = computeZoomTransform({
|
||||
stageSize: this.layoutCache.stageSize,
|
||||
baseMask: this.layoutCache.maskRect,
|
||||
zoomScale: state.scale,
|
||||
zoomProgress: state.progress,
|
||||
focusX: state.focusX,
|
||||
focusY: state.focusY,
|
||||
});
|
||||
|
||||
if (Math.abs(scaleDelta) > MIN_DELTA) {
|
||||
nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR;
|
||||
} else {
|
||||
nextScale = targetScaleFactor;
|
||||
}
|
||||
const appliedScale =
|
||||
Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE
|
||||
? projectedTransform.scale
|
||||
: projectedTransform.scale;
|
||||
const appliedX =
|
||||
Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX
|
||||
? projectedTransform.x
|
||||
: projectedTransform.x;
|
||||
const appliedY =
|
||||
Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX
|
||||
? projectedTransform.y
|
||||
: projectedTransform.y;
|
||||
|
||||
if (Math.abs(focusXDelta) > MIN_DELTA) {
|
||||
nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR;
|
||||
} else {
|
||||
nextFocusX = targetFocus.cx;
|
||||
}
|
||||
|
||||
if (Math.abs(focusYDelta) > MIN_DELTA) {
|
||||
nextFocusY = prevFocusY + focusYDelta * SMOOTHING_FACTOR;
|
||||
} else {
|
||||
nextFocusY = targetFocus.cy;
|
||||
}
|
||||
|
||||
state.scale = nextScale;
|
||||
state.focusX = nextFocusX;
|
||||
state.focusY = nextFocusY;
|
||||
state.x = appliedX;
|
||||
state.y = appliedY;
|
||||
state.appliedScale = appliedScale;
|
||||
|
||||
return Math.max(
|
||||
Math.abs(nextScale - prevScale),
|
||||
Math.abs(nextFocusX - prevFocusX),
|
||||
Math.abs(nextFocusY - prevFocusY),
|
||||
Math.abs(appliedScale - prevScale),
|
||||
Math.abs(appliedX - prevX) / Math.max(1, this.layoutCache.stageSize.width),
|
||||
Math.abs(appliedY - prevY) / Math.max(1, this.layoutCache.stageSize.height),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -594,6 +643,7 @@ export class FrameRenderer {
|
||||
this.videoContainer = null;
|
||||
this.maskGraphics = null;
|
||||
this.blurFilter = null;
|
||||
this.motionBlurFilter = null;
|
||||
this.shadowCanvas = null;
|
||||
this.shadowCtx = null;
|
||||
this.compositeCanvas = null;
|
||||
|
||||
Reference in New Issue
Block a user