diff --git a/package-lock.json b/package-lock.json index d9edd82e..86295865 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ef2c1975..fbb618a9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index 43feeafa..0d94efac 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -104,19 +104,22 @@ export function SourceSelector() { return (
- + - Screens + Screens ({screenSources.length}) - Windows + Windows ({windowSources.length})
diff --git a/src/components/video-editor/CropControl.tsx b/src/components/video-editor/CropControl.tsx index 078b6e53..07e769d6 100644 --- a/src/components/video-editor/CropControl.tsx +++ b/src/components/video-editor/CropControl.tsx @@ -16,7 +16,7 @@ interface CropControlProps { aspectRatio: AspectRatio; } -type DragHandle = "top" | "right" | "bottom" | "left" | null; +type DragHandle = "top" | "right" | "bottom" | "left" | "move" | null; export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) { const canvasRef = useRef(null); @@ -99,6 +99,11 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont case "right": newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x)); break; + case "move": { + newCrop.x = Math.max(0, Math.min(initialCrop.x + deltaX, 1 - initialCrop.width)); + newCrop.y = Math.max(0, Math.min(initialCrop.y + deltaY, 1 - initialCrop.height)); + break; + } } onCropChange(newCrop); @@ -178,6 +183,18 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
+
handlePointerDown(e, "move")} + /> +
(GRADIENTS[0]); const [showCropModal, setShowCropModal] = useState(false); const cropSnapshotRef = useRef(null); + const [cropAspectLocked, setCropAspectLocked] = useState(false); + const [cropAspectRatio, setCropAspectRatio] = useState(""); + + const videoWidth = videoElement?.videoWidth || 1920; + const videoHeight = videoElement?.videoHeight || 1080; + + const handleCropNumericChange = useCallback( + (field: "x" | "y" | "width" | "height", pixelValue: number) => { + if (!cropRegion || !onCropChange) return; + + const next = { ...cropRegion }; + switch (field) { + case "x": + next.x = Math.max(0, Math.min(pixelValue / videoWidth, 1 - next.width)); + break; + case "y": + next.y = Math.max(0, Math.min(pixelValue / videoHeight, 1 - next.height)); + break; + case "width": { + const newWidth = Math.max(0.05, Math.min(pixelValue / videoWidth, 1 - next.x)); + if (cropAspectLocked && next.width > 0 && next.height > 0) { + const ratio = next.width / next.height; + const newHeight = newWidth / ratio; + if (next.y + newHeight <= 1) { + next.width = newWidth; + next.height = newHeight; + } + } else { + next.width = newWidth; + } + break; + } + case "height": { + const newHeight = Math.max(0.05, Math.min(pixelValue / videoHeight, 1 - next.y)); + if (cropAspectLocked && next.width > 0 && next.height > 0) { + const ratio = next.width / next.height; + const newWidth = newHeight * ratio; + if (next.x + newWidth <= 1) { + next.height = newHeight; + next.width = newWidth; + } + } else { + next.height = newHeight; + } + break; + } + } + + onCropChange(next); + }, + [cropRegion, onCropChange, videoWidth, videoHeight, cropAspectLocked], + ); + + const applyCropAspectPreset = useCallback( + (preset: string) => { + if (!cropRegion || !onCropChange) return; + + setCropAspectRatio(preset); + if (preset === "") { + setCropAspectLocked(false); + return; + } + + const [wStr, hStr] = preset.split(":"); + const targetRatio = Number(wStr) / Number(hStr); + const next = { ...cropRegion }; + + const nextHeight = (next.width * videoWidth) / (targetRatio * videoHeight); + if (next.y + nextHeight <= 1 && nextHeight >= 0.05) { + next.height = nextHeight; + } else { + const nextWidth = (next.height * videoHeight * targetRatio) / videoWidth; + if (next.x + nextWidth <= 1 && nextWidth >= 0.05) { + next.width = nextWidth; + } + } + + onCropChange(next); + setCropAspectLocked(true); + }, + [cropRegion, onCropChange, videoWidth, videoHeight], + ); + + const getCropPixelValue = useCallback( + (field: "x" | "y" | "width" | "height"): number => { + if (!cropRegion) return 0; + switch (field) { + case "x": + return Math.round(cropRegion.x * videoWidth); + case "y": + return Math.round(cropRegion.y * videoHeight); + case "width": + return Math.round(cropRegion.width * videoWidth); + case "height": + return Math.round(cropRegion.height * videoHeight); + } + }, + [cropRegion, videoWidth, videoHeight], + ); const zoomEnabled = Boolean(selectedZoomDepth); const trimEnabled = Boolean(selectedTrimId); @@ -747,14 +848,95 @@ export function SettingsPanel({ onCropChange={onCropChange} aspectRatio={aspectRatio} /> -
- +
+
+ {[ + { label: "X", field: "x" as const, max: videoWidth }, + { label: "Y", field: "y" as const, max: videoHeight }, + { label: "W", field: "width" as const, max: videoWidth }, + { label: "H", field: "height" as const, max: videoHeight }, + ].map(({ label, field, max }) => ( +
+ + handleCropNumericChange(field, Number(e.target.value))} + className="w-[90px] h-8 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+ ))} + +
+ +
+ + +
+
+ +

+ {videoWidth} × {videoHeight}px +

+
+ +
+ +
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 49aab9bb..46e49984 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -18,7 +18,7 @@ import { VideoExporter, } from "@/lib/exporter"; import { matchesShortcut } from "@/lib/shortcuts"; -import { getAspectRatioValue } from "@/utils/aspectRatioUtils"; +import { getAspectRatioValue, getNativeAspectRatioValue } from "@/utils/aspectRatioUtils"; import { ExportDialog } from "./ExportDialog"; import PlaybackControls from "./PlaybackControls"; import { @@ -904,9 +904,12 @@ export default function VideoEditor() { videoPlaybackRef.current?.pause(); } - const aspectRatioValue = getAspectRatioValue(aspectRatio); const sourceWidth = video.videoWidth || 1920; const sourceHeight = video.videoHeight || 1080; + const aspectRatioValue = + aspectRatio === "native" + ? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion) + : getAspectRatioValue(aspectRatio); // Get preview CONTAINER dimensions for scaling const playbackRef = videoPlaybackRef.current; @@ -1234,7 +1237,14 @@ export default function VideoEditor() { style={{ width: "auto", height: "100%", - aspectRatio: getAspectRatioValue(aspectRatio), + aspectRatio: + aspectRatio === "native" + ? getNativeAspectRatioValue( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + cropRegion, + ) + : getAspectRatioValue(aspectRatio), maxWidth: "100%", margin: "0 auto", boxSizing: "border-box", diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 98a4c236..7998e6df 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -7,6 +7,7 @@ import { Texture, VideoSource, } from "pixi.js"; +import { MotionBlurFilter } from "pixi-filters/motion-blur"; import type React from "react"; import { forwardRef, @@ -18,7 +19,11 @@ import { useState, } from "react"; import { getAssetPath } from "@/lib/assetPath"; -import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils"; +import { + type AspectRatio, + formatAspectRatioForCSS, + getNativeAspectRatioValue, +} from "@/utils/aspectRatioUtils"; import { AnnotationOverlay } from "./AnnotationOverlay"; import { type AnnotationRegion, @@ -29,14 +34,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 +128,7 @@ const VideoPlayback = forwardRef( }, ref, ) => { + const ZOOM_MOTION_BLUR_AMOUNT = 0.35; const videoRef = useRef(null); const containerRef = useRef(null); const appRef = useRef(null); @@ -131,8 +147,13 @@ const VideoPlayback = forwardRef( scale: 1, focusX: DEFAULT_FOCUS.cx, focusY: DEFAULT_FOCUS.cy, + progress: 0, + x: 0, + y: 0, + appliedScale: 1, }); const blurFilterRef = useRef(null); + const motionBlurFilterRef = useRef(null); const isDraggingFocusRef = useRef(false); const stageSizeRef = useRef({ width: 0, height: 0 }); const videoSizeRef = useRef({ width: 0, height: 0 }); @@ -149,6 +170,7 @@ const VideoPlayback = forwardRef( const trimRegionsRef = useRef([]); const speedRegionsRef = useRef([]); const motionBlurEnabledRef = useRef(motionBlurEnabled); + const motionBlurStateRef = useRef(createMotionBlurState()); const onTimeUpdateRef = useRef(onTimeUpdate); const onPlayStateChangeRef = useRef(onPlayStateChange); const videoReadyRafRef = useRef(null); @@ -412,8 +434,15 @@ const VideoPlayback = forwardRef( 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 +475,7 @@ const VideoPlayback = forwardRef( focusY: DEFAULT_FOCUS.cy, motionIntensity: 0, isPlaying: false, - motionBlurEnabled: motionBlurEnabledRef.current, + motionBlurAmount: motionBlurEnabledRef.current ? ZOOM_MOTION_BLUR_AMOUNT : 0, }); requestAnimationFrame(() => { @@ -605,14 +634,20 @@ const VideoPlayback = forwardRef( 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 +697,10 @@ const VideoPlayback = forwardRef( blurFilterRef.current.destroy(); blurFilterRef.current = null; } + if (motionBlurFilterRef.current) { + motionBlurFilterRef.current.destroy(); + motionBlurFilterRef.current = null; + } videoTexture.destroy(true); videoSpriteRef.current = null; @@ -676,97 +715,154 @@ const VideoPlayback = forwardRef( 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); - - // 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, - }; + const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; + const regionFocus = region.focus; + + 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; - - const scaleDelta = targetScaleFactor - state.scale; - const focusXDelta = targetFocus.cx - state.focusX; - const focusYDelta = targetFocus.cy - state.focusY; - - let nextScale = prevScale; - let nextFocusX = prevFocusX; - let nextFocusY = prevFocusY; + state.scale = targetScaleFactor; + state.focusX = targetFocus.cx; + state.focusY = targetFocus.cy; + state.progress = targetProgress; - 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; - } + const projectedTransform = computeZoomTransform({ + stageSize: stageSizeRef.current, + baseMask: baseMaskRef.current, + zoomScale: state.scale, + zoomProgress: state.progress, + focusX: state.focusX, + focusY: state.focusY, + }); - 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 +871,7 @@ const VideoPlayback = forwardRef( app.ticker.remove(ticker); } }; - }, [pixiReady, videoReady, clampFocusToStage]); + }, [pixiReady, videoReady]); const handleLoadedMetadata = (e: React.SyntheticEvent) => { const video = e.currentTarget; @@ -881,7 +977,19 @@ const VideoPlayback = forwardRef( return (
{/* Background layer - always render as DOM element with blur */}
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 baseFocus = clampFocusToDepth(focus, depth); + const bounds = getFocusBounds(depth); - const zoomScale = ZOOM_DEPTH_SCALES[depth]; + return { + cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX), + cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY), + }; +} - const windowWidth = stageSize.width / zoomScale; - const windowHeight = stageSize.height / zoomScale; +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); - const marginX = windowWidth / (2 * stageSize.width); - const marginY = windowHeight / (2 * stageSize.height); + return { + cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX), + cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY), + }; +} - const baseFocus = clampFocusToDepth(focus, depth); +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: Math.max(marginX, Math.min(1 - marginX, baseFocus.cx)), - cy: Math.max(marginY, Math.min(1 - marginY, baseFocus.cy)), + cx: softClampToRange(baseFocus.cx, bounds.minX, bounds.maxX, horizontalSoftness), + cy: softClampToRange(baseFocus.cy, bounds.minY, bounds.maxY, verticalSoftness), }; } diff --git a/src/components/video-editor/videoPlayback/mathUtils.ts b/src/components/video-editor/videoPlayback/mathUtils.ts index 5995b903..78c9414f 100644 --- a/src/components/video-editor/videoPlayback/mathUtils.ts +++ b/src/components/video-editor/videoPlayback/mathUtils.ts @@ -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); +} diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index b87ba12b..3ceace87 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -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); +} + +function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus { + return { + cx: lerp(start.cx, end.cx, amount), + cy: lerp(start.cy, end.cy, amount), + }; +} + +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; } -export function findDominantRegion(regions: ZoomRegion[], timeMs: number) { - let bestRegion: ZoomRegion | null = null; - let bestStrength = 0; +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 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; + } - for (const region of regions) { - const strength = computeRegionStrength(region, timeMs); - if (strength > bestStrength) { - bestStrength = strength; - bestRegion = region; + const connectedHold = getConnectedRegionHold(timeMs, connectedPairs); + if (connectedHold) { + return { ...connectedHold, transition: null }; } } - return { region: bestRegion, strength: bestStrength }; + const activeRegion = getActiveRegion(regions, timeMs, connectedPairs); + return activeRegion + ? { ...activeRegion, transition: null } + : { region: null, strength: 0, blendedScale: null, transition: null }; } diff --git a/src/components/video-editor/videoPlayback/zoomTransform.ts b/src/components/video-editor/videoPlayback/zoomTransform.ts index 8dbb5b3a..8fcf397f 100644 --- a/src/components/video-editor/videoPlayback/zoomTransform.ts +++ b/src/components/video-editor/videoPlayback/zoomTransform.ts @@ -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; +} + +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, +}: ZoomTransformGeometry): AppliedTransform { + if ( + stageSize.width <= 0 || + stageSize.height <= 0 || + baseMask.width <= 0 || + baseMask.height <= 0 + ) { + return { scale: 1, x: 0, y: 0 }; + } + + 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; + + 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: _motionIntensity, + motionVector: _motionVector, isPlaying, - motionBlurEnabled = false, -}: TransformParams) { + motionBlurAmount = 0, + transformOverride, + motionBlurState, + frameTimeMs, +}: TransformParams): 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; + const transform = + transformOverride ?? + computeZoomTransform({ + stageSize, + baseMask, + zoomScale, + zoomProgress, + focusX, + focusY, + }); - // Stage center (where we want the focus to end up after zoom) - const stageCenterX = stageSize.width / 2; - const stageCenterY = stageSize.height / 2; + // 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; - // Apply zoom scale to camera container - cameraContainer.scale.set(zoomScale); + // Camera displacement this frame (stage-px) + const dx = transform.x - motionBlurState.prevCamX; + const dy = transform.y - motionBlurState.prevCamY; + const dScale = transform.scale - motionBlurState.prevCamScale; - // 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; + motionBlurState.prevCamX = transform.x; + motionBlurState.prevCamY = transform.y; + motionBlurState.prevCamScale = transform.scale; - cameraContainer.position.set(cameraX, cameraY); + // 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; - if (blurFilter) { - const shouldBlur = motionBlurEnabled && isPlaying && motionIntensity > 0.0005; - const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0; - blurFilter.blur = motionBlur; + 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, + }; } diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 7ad52042..a4003efc 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -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((resolve, reject) => { @@ -280,23 +301,6 @@ export class FrameRenderer { this.backgroundSprite = bgCanvas; } - private async resolveWallpaperImageUrl(wallpaper: string): Promise { - 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 { 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, - }; - } - - const state = this.animationState; - - const prevScale = state.scale; - const prevFocusX = state.focusX; - const prevFocusY = state.focusY; - - const scaleDelta = targetScaleFactor - state.scale; - const focusXDelta = targetFocus.cx - state.focusX; - const focusYDelta = targetFocus.cy - state.focusY; - - let nextScale = prevScale; - let nextFocusX = prevFocusX; - let nextFocusY = prevFocusY; + 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, + }); - if (Math.abs(scaleDelta) > MIN_DELTA) { - nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR; - } else { - nextScale = targetScaleFactor; + 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; + } } - if (Math.abs(focusXDelta) > MIN_DELTA) { - nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR; - } else { - nextFocusX = targetFocus.cx; - } + const state = this.animationState; - if (Math.abs(focusYDelta) > MIN_DELTA) { - nextFocusY = prevFocusY + focusYDelta * SMOOTHING_FACTOR; - } else { - nextFocusY = targetFocus.cy; - } + const prevScale = state.appliedScale; + const prevX = state.x; + const prevY = state.y; + + state.scale = targetScaleFactor; + state.focusX = targetFocus.cx; + state.focusY = targetFocus.cy; + state.progress = targetProgress; + + const projectedTransform = computeZoomTransform({ + stageSize: this.layoutCache.stageSize, + baseMask: this.layoutCache.maskRect, + zoomScale: state.scale, + zoomProgress: state.progress, + focusX: state.focusX, + focusY: state.focusY, + }); - 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; + + 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; diff --git a/src/utils/aspectRatioUtils.ts b/src/utils/aspectRatioUtils.ts index 3fc6f90b..2ad7e442 100644 --- a/src/utils/aspectRatioUtils.ts +++ b/src/utils/aspectRatioUtils.ts @@ -1,11 +1,20 @@ -export const ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "4:5", "16:10", "10:16"] as const; +export const ASPECT_RATIOS = [ + "16:9", + "9:16", + "1:1", + "4:3", + "4:5", + "16:10", + "10:16", + "native", +] as const; export type AspectRatio = (typeof ASPECT_RATIOS)[number]; /** * Returns the numeric value of an aspect ratio. - * Uses exhaustive type checking to ensure all AspectRatio cases are handled. - * If TypeScript errors here, a new ratio was added to the type but not handled. + * For "native", returns a fallback ratio of 16/9. + * Callers with source/crop context should use getNativeAspectRatioValue(). */ export function getAspectRatioValue(aspectRatio: AspectRatio): number { switch (aspectRatio) { @@ -23,14 +32,25 @@ export function getAspectRatioValue(aspectRatio: AspectRatio): number { return 16 / 10; case "10:16": return 10 / 16; + case "native": + return 16 / 9; default: { - // Ensures all cases are handled - TypeScript errors if missing const _exhaustiveCheck: never = aspectRatio; return _exhaustiveCheck; } } } +export function getNativeAspectRatioValue( + videoWidth: number, + videoHeight: number, + cropRegion?: { x: number; y: number; width: number; height: number }, +): number { + const cropW = cropRegion?.width ?? 1; + const cropH = cropRegion?.height ?? 1; + return (videoWidth * cropW) / (videoHeight * cropH); +} + export function getAspectRatioDimensions( aspectRatio: AspectRatio, baseWidth: number, @@ -43,9 +63,11 @@ export function getAspectRatioDimensions( } export function getAspectRatioLabel(aspectRatio: AspectRatio): string { + if (aspectRatio === "native") return "Native"; return aspectRatio; } -export function formatAspectRatioForCSS(aspectRatio: AspectRatio): string { +export function formatAspectRatioForCSS(aspectRatio: AspectRatio, nativeRatio?: number): string { + if (aspectRatio === "native") return String(nativeRatio ?? 16 / 9); return aspectRatio.replace(":", "/"); }