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(":", "/");
}